Семантический поиск в дневнике: как эмбеддинги NaturalLanguage находят смысл, а не слова#
Работая над Lanternly — приложением-дневником, которое сейчас у меня в разработке, — я довольно быстро упёрся в ограничение обычного поиска. Пользователь пишет запись «не могу заснуть, в голове крутится одно и то же», а через месяц ищет по слову «тревога» — и не находит эту запись, потому что слова «тревога» там буквально нет. contains() и полнотекстовый индекс ищут совпадения строк, а не смысл. Чтобы дневник действительно «помнил» человека, поиск должен понимать, что «не могу заснуть, крутится одно и то же» и «тревога перед сном» — это об одном и том же переживании. Решение оказалось куда ближе, чем облачный API эмбеддингов: фреймворк NaturalLanguage, который Apple годами встраивает в iOS, уже умеет превращать текст в вектор смысла — прямо на устройстве, без единого сетевого запроса.
Зачем дневнику «поиск по смыслу», если есть Cmd+F#
Ключевое слово редко совпадает с тем, как человек вспоминает событие. Дневник — это не лог с тегами, это поток свободного текста, и запросы к нему тоже свободные: «когда я в последний раз злился на маму», «записи про выгорание на работе», «дни, когда было хорошо с деньгами». Ни в одной из этих записей может не быть слова из запроса — но смысл совпадает.
Полнотекстовый поиск (тот же NSPredicate с CONTAINS[cd] или FTS5 в Core Data/SQLite) отлично решает другую задачу — точный поиск по терминам, именам, датам. Это разные инструменты для разных вопросов, и семантический поиск не отменяет полнотекстовый, а дополняет его там, где пользователь ищет не строку, а переживание.
Здесь и появляется идея эмбеддинга: превратить текст в точку в многомерном пространстве так, чтобы близкие по смыслу тексты оказывались рядом, а несвязанные — далеко. Дальше вся задача поиска сводится к геометрии: найти точки, ближайшие к точке запроса.
NLEmbedding и NLContextualEmbedding: что Apple даёт из коробки#
В NaturalLanguage есть два принципиально разных способа получить вектор.
NLEmbedding — статические эмбеддинги слов и предложений (NLEmbedding.wordEmbedding(for:), NLEmbedding.sentenceEmbedding(for:)). Модель уже встроена в систему, векторы вычисляются мгновенно, но каждому слову соответствует один и тот же вектор независимо от контекста — «ключ» в «ключ от квартиры» и «ключ» в «ключ к разгадке» получат одинаковое представление.
NLContextualEmbedding (iOS 17+) — эмбеддинги на основе трансформер-модели (BERT-подобной). Вектор токена учитывает соседние слова, поэтому многозначность и оттенки смысла передаются точнее. Плата за это — модель весит существенно больше и её ассеты нужно один раз скачать на устройство через requestAssets(completionHandler:), прежде чем вызывать load().
Для дневника, где важны нюансы состояния человека, я выбрал NLContextualEmbedding как основной путь — а NLEmbedding держу как быстрый fallback для языков без контекстной модели.
| Критерий | NLEmbedding (статические) | NLContextualEmbedding | Внешняя векторная БД (Pinecone / pgvector / Weaviate) |
|---|---|---|---|
| Тип модели | Статическая, без учёта контекста | Transformer (BERT-подобная), учитывает контекст | Любая внешняя embedding-модель по выбору |
| Где считается | On-device, встроено в ОС | On-device, ассеты грузятся по требованию | На сервере/в облаке |
| Инфраструктура | Не нужна | Не нужна | Сервер БД, API-ключи, биллинг |
| Офлайн-работа | Да | Да, после первой загрузки ассетов | Нет, нужен сетевой запрос |
| Приватность | Данные не покидают устройство | Данные не покидают устройство | Текст уходит на сторонний сервер |
| Языки | Ограниченный набор | ~27 языков по группам скриптов (WWDC23+) | Любой — зависит от модели |
| Точность на нюансах | Ниже, не различает многозначность | Выше, учитывает контекст предложения | Максимальная, SOTA + тюнинг под домен |
| Масштаб данных | Тысячи записей, линейный перебор | Тысячи записей, линейный перебор | Миллионы+, ANN-индексы (HNSW и др.) |
| Когда выбрать | MVP, простой прототип | Личные данные пользователя: дневник, заметки, письма | Общий контент, много пользователей, огромные объёмы |
Получаем эмбеддинг предложения на устройстве#
Ниже — минимальный рабочий путь: создать контекстную модель для языка, при необходимости скачать ассеты, загрузить модель и получить один вектор для целого предложения через пулинг векторов токенов.
import NaturalLanguage
/// Loads a contextual (transformer-based) embedding model for a language,
/// downloading the on-device assets on first use if they are not cached yet.
func makeContextualEmbedding(for language: NLLanguage) throws -> NLContextualEmbedding? {
guard let embedding = NLContextualEmbedding(language: language) else {
return nil // No contextual model ships for this language/script
}
if !embedding.hasAvailableAssets {
let group = DispatchGroup()
group.enter()
embedding.requestAssets { _, _ in group.leave() }
group.wait()
}
try embedding.load()
return embedding
}
/// Produces one fixed-length vector for a whole sentence by mean-pooling
/// the subword token vectors that NLContextualEmbedding returns.
func sentenceVector(
for text: String,
embedding: NLContextualEmbedding,
language: NLLanguage
) throws -> [Double] {
let result = try embedding.embeddingResult(for: text, language: language)
var sum = [Double](repeating: 0, count: embedding.dimension)
var tokenCount = 0
result.enumerateTokenVectors(in: text.startIndex..<text.endIndex) { vector, _ in
for i in 0..<vector.count { sum[i] += vector[i] }
tokenCount += 1
return true // keep iterating
}
guard tokenCount > 0 else { return sum }
return sum.map { $0 / Double(tokenCount) }
}NLContextualEmbeddingResult отдаёт вектор для каждого subword-токена, а не один вектор на предложение — это осознанный выбор Apple: токен-уровневые векторы нужны и для более тонких задач (NER, классификация токенов). Для поиска по записям дневника достаточно среднего (mean pooling) — это простой и предсказуемый способ получить один вектор на всю запись.
Косинусная близость: сравниваем два вектора#
Как только оба текста представлены векторами одинаковой размерности, вопрос «похожи ли они по смыслу» превращается в вопрос «какой угол между этими векторами». Косинусная близость не зависит от длины текста — короткая запись и длинная запись на ту же тему всё равно окажутся рядом.
/// Cosine similarity between two vectors.
/// 1.0 = same direction (same meaning), 0.0 = unrelated, -1.0 = opposite.
func cosineSimilarity(_ a: [Double], _ b: [Double]) -> Double {
guard a.count == b.count, !a.isEmpty else { return 0 }
var dot = 0.0
var normA = 0.0
var normB = 0.0
for i in 0..<a.count {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
guard normA > 0, normB > 0 else { return 0 }
return dot / (normA.squareRoot() * normB.squareRoot())
}Сам NLEmbedding тоже умеет считать расстояние из коробки — distance(between:and:distanceType:) с NLDistanceType.cosine. Но для NLContextualEmbedding, где вектор предложения мы собираем сами через пулинг, готовую реализацию косинусной близости нужно написать самостоятельно — она универсальна и подходит для любых массивов [Double].
Индексация записей: считаем эмбеддинги один раз#
Самая частая ошибка при первой реализации — считать эмбеддинг запроса и всех записей заново при каждом поиске. Векторы записей меняются только тогда, когда меняется сама запись, поэтому их нужно посчитать один раз — при сохранении — и хранить рядом с текстом.
/// A diary entry paired with its precomputed semantic vector.
struct IndexedEntry {
let id: UUID
let text: String
let vector: [Double]
}
/// Builds an in-memory semantic index for a set of entries.
/// In a real app this runs once per entry, on save — never on every search.
final class SemanticEntryIndex {
private var embedding: NLContextualEmbedding?
private let language: NLLanguage
private(set) var entries: [IndexedEntry] = []
init(language: NLLanguage = .russian) {
self.language = language
}
func prepare() throws {
embedding = try makeContextualEmbedding(for: language)
}
func index(id: UUID, text: String) throws {
guard let embedding else { return }
let vector = try sentenceVector(for: text, embedding: embedding, language: language)
entries.append(IndexedEntry(id: id, text: text, vector: vector))
}
}На практике вектор записи удобно хранить в SwiftData/Core Data рядом с текстом (как [Double] или упакованным в Data), а не пересчитывать при каждом запуске приложения — пересчёт нужен только когда пользователь редактирует текст записи.
Ranked-поиск: находим записи «об этом же»#
Финальный шаг — превратить запрос пользователя в такой же вектор и отсортировать все проиндексированные записи по убыванию косинусной близости к нему.
/// Returns entries closest in meaning to `query`, best match first.
func semanticSearch(
query: String,
in index: SemanticEntryIndex,
embedding: NLContextualEmbedding,
language: NLLanguage,
limit: Int = 10
) throws -> [(entry: IndexedEntry, score: Double)] {
let queryVector = try sentenceVector(for: query, embedding: embedding, language: language)
return index.entries
.map { entry in (entry: entry, score: cosineSimilarity(queryVector, entry.vector)) }
.sorted { $0.score > $1.score }
.prefix(limit)
.map { $0 }
}Для дневника на несколько тысяч записей такой линейный перебор занимает миллисекунды — приближённые индексы (HNSW, IVF) имеют смысл только когда записей становится действительно много, о чём ниже.
Где потолок: языки, RAG-тренд 2026 и когда нужна настоящая векторная БД#
У подхода есть реальные границы, и честно о них сказать важнее, чем продать идею как серебряную пулю.
Языки. NLContextualEmbedding поддерживает не любой язык, а модели, сгруппированные по типам письменности (Apple показала это на WWDC23 вместе с многоязычными Create ML моделями на базе BERT) — латиница, иероглифика и другие группы скриптов охвачены неравномерно. Перед использованием стоит проверять NLContextualEmbedding.languages для конкретной модели и предусматривать путь без контекстных эмбеддингов там, где модели нет.
Масштаб. Линейный перебор косинусной близости отлично работает до нескольких тысяч записей — типичный объём личного дневника за годы. Для миллионов записей и множества пользователей уже нужны приближённые индексы и внешняя векторная БД (Pinecone, Weaviate, pgvector) — это другой класс задачи, с другими компромиссами по стоимости и приватности.
Латентность и точность. Контекстная модель считает вектор не бесплатно: на длинном тексте embeddingResult(for:language:) занимает заметно больше времени, чем простое сравнение строк, поэтому индексацию стоит выносить с главного потока и вызывать при сохранении записи, а не в реальном времени по мере набора текста. Пулинг усреднением — не единственный вариант: можно взвешивать токены по TF-IDF-подобной схеме или брать вектор конкретного значимого токена, если для домена это важнее общей картины.
RAG-тренд. В 2026 году заметен явный сдвиг мобильных экосистем в сторону «local-first»: приложения всё чаще держат embedding-модель и векторный поиск прямо на устройстве, а в облако отправляют только то, что действительно требует общих знаний или синхронизации между пользователями. Личная память ассистента — ровно тот случай, где on-device подход не компромисс, а более правильная архитектура: данные никогда не покидают телефон, а поиск работает даже в самолёте.
Для Lanternly этот выбор был очевиден с самого начала: дневник — это, возможно, самые приватные данные, которые человек вообще пишет. Отправлять их куда-либо ради поиска по смыслу означало бы предавать саму идею приложения. NLEmbedding и NLContextualEmbedding дают семантический поиск без единой строчки серверного кода — и это именно тот случай, когда ограничение оборачивается правильным архитектурным решением, а не компромиссом.



