日記アプリの意味検索:NaturalLanguageのエンベディングが「言葉」ではなく「意味」を見つける仕組み#
現在開発中の日記アプリ Lanternly に取り組んでいて、私はすぐに従来の検索の限界にぶつかりました。ユーザーが「眠れない、同じ考えが頭の中をぐるぐる回っている」という記録を書き、1か月後に「不安」というキーワードで検索しても、その記録は見つかりません。文中に「不安」という言葉が literally 存在しないからです。contains() や全文検索インデックスは文字列の一致を探すだけで、意味を理解しません。日記が本当にその人を「覚えている」ためには、「眠れない、同じ考えがぐるぐる回る」と「寝る前の不安」が同じ体験について書かれていることを検索が理解する必要があります。答えはクラウドのエンベディングAPIよりずっと身近なところにありました。Appleが長年iOSに組み込んできた NaturalLanguage フレームワークは、すでにテキストを「意味のベクトル」に変換できます——それも端末上だけで、ネットワークリクエストを一切送らずに。
Cmd+Fがあるのに、なぜ日記に「意味で探す」検索が必要なのか#
キーワードは、人が出来事を思い出す方法とほとんど一致しません。日記はタグ付きログではなく自由な文章の流れであり、それに対するクエリも同じく自由です。「最後に母に腹を立てたのはいつだったか」「仕事のバーンアウトについての記録」「お金の面で調子が良かった日々」。これらのクエリに含まれる単語が記録の中に一つも存在しないことは十分にあり得ますが、それでも意味は一致します。
全文検索(NSPredicate の CONTAINS[cd] や Core Data/SQLite の FTS5)は、用語・人名・日付の正確な一致を探すという、まったく別の課題を見事に解決します。これらは異なる問いに対する異なる道具であり、意味検索は全文検索を置き換えるのではなく、ユーザーが「文字列」ではなく「体験」を探している場面を補完するものです。
ここでエンベディングという発想が登場します。テキストを多次元空間内の一点に変換し、意味が近いテキスト同士が近くに、関係のないテキストが遠くに位置するようにする——そうすれば検索の問題はすべて幾何学に帰着します。クエリの点にもっとも近い点を探すだけです。
NLEmbedding と NLContextualEmbedding:Appleが標準で用意しているもの#
NaturalLanguage にはベクトルを得るための本質的に異なる2つの方法があります。
NLEmbedding — 単語・文の静的エンベディング(NLEmbedding.wordEmbedding(for:)、NLEmbedding.sentenceEmbedding(for:))。モデルはすでにOSに組み込まれており計算は瞬時ですが、同じ単語には文脈にかかわらず常に同じベクトルが対応します。「鍵」が「家の鍵」を指す場合も「謎を解く鍵」を指す場合も、同じ表現になってしまいます。
NLContextualEmbedding(iOS 17+)— トランスフォーマーモデル(BERT系)に基づくエンベディング。トークンのベクトルは周囲の単語を考慮するため、多義性やニュアンスがより正確に反映されます。代償として、モデルは大幅に重くなり、load() を呼ぶ前に requestAssets(completionHandler:) で一度だけアセットを端末にダウンロードする必要があります。
人の状態のニュアンスが重要になる日記では、私は NLContextualEmbedding を主な手段として選び、コンテキストモデルのない言語向けの高速なフォールバックとして NLEmbedding を残しています。
| 基準 | NLEmbedding(静的) | NLContextualEmbedding | 外部ベクトルDB(Pinecone / pgvector / Weaviate) |
|---|---|---|---|
| モデルの種類 | 静的、文脈を考慮しない | トランスフォーマー(BERT系)、文脈を考慮 | 任意の外部エンベディングモデル |
| 実行場所 | オンデバイス、OSに組み込み | オンデバイス、アセットはオンデマンドでダウンロード | サーバー/クラウド |
| インフラ | 不要 | 不要 | DBサーバー、APIキー、課金 |
| オフライン動作 | 可能 | 初回アセット取得後は可能 | 不可、ネットワーク呼び出しが必要 |
| プライバシー | データは端末から出ない | データは端末から出ない | テキストが外部サーバーへ送信される |
| 対応言語 | 限定的 | スクリプトのグループ別に約27言語(WWDC23以降) | モデル次第で任意 |
| ニュアンスの精度 | 低い、多義性を区別しない | 高い、文の文脈を考慮 | 最高、SOTA + ドメイン特化チューニング |
| データ規模 | 数千件、線形探索 | 数千件、線形探索 | 数百万件以上、ANNインデックス(HNSWなど) |
| 適した場面 | MVP、簡単なプロトタイプ | ユーザーの個人データ:日記、メモ、メール | 一般的なコンテンツ、多数ユーザー、大規模データ |
端末上で文のエンベディングを取得する#
以下は最小限の実装パスです:言語ごとにコンテキストモデルを作成し、必要ならアセットをダウンロードし、モデルをロードして、トークンベクトルのプーリングによって文全体を表す1本のベクトルを得ます。
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 は文ごとに1本のベクトルではなく、サブワードトークンごとに1本のベクトルを返します。これはAppleの意図的な設計で、トークン単位のベクトルはNERやトークン分類のような、より細かいタスクにも必要だからです。日記の記録を検索する用途には平均化(mean pooling)で十分——これは記録全体に対して1本のベクトルを得るためのシンプルで予測可能な方法です。
コサイン類似度:2つのベクトルを比較する#
両方のテキストが同じ次元数のベクトルで表現されれば、「意味が似ているか」という問いは「これらのベクトルの間の角度はどれくらいか」という問いに置き換わります。コサイン類似度はテキストの長さに依存しません。同じテーマについての短い記録と長い記録は、それでも近い位置に収まります。
/// 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 にパックして)保存しておくのが便利です。再計算が必要なのは、ユーザーが記録のテキストを編集したときだけです。
ランク付き検索:「同じことについて」の記録を見つける#
最後のステップは、ユーザーのクエリを同じ種類のベクトルに変換し、インデックス化済みの全記録をそれとのコサイン類似度が高い順に並べ替えることです。
/// 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など)が意味を持つのは、記録の件数が本当に多くなってからです——次はその限界について説明します。
この手法の限界:対応言語、2026年のRAGトレンド、本物のベクトルDBが必要になるとき#
この手法には現実的な限界があり、それを万能薬のように売り込むよりも、正直に語るほうがずっと重要です。
対応言語。 NLContextualEmbedding はすべての言語をサポートしているわけではありません。モデルは文字体系のグループごとにまとめられており(Appleは WWDC23 で、BERTベースの多言語 Create ML モデルと合わせてこれを示しました)、ラテン文字・CJK・その他のスクリプトグループのカバー範囲は均一ではありません。利用前に特定モデルの NLContextualEmbedding.languages を確認し、コンテキストモデルが存在しない言語向けの代替パスを用意しておくべきです。
規模。 コサイン類似度による線形探索は、数千件——長年書き溜めた個人の日記としては典型的な規模——までは非常によく機能します。数百万件・多数のユーザーとなると、近似インデックスと外部ベクトルDB(Pinecone、Weaviate、pgvector)が本当に必要になります。これはコストとプライバシーのトレードオフがまったく異なる、別種の問題です。
RAGトレンド。 2026年、モバイルのエコシステムは明確に「ローカルファースト」へと傾いています。アプリはエンベディングモデルとベクトル検索をますます端末側に置くようになり、クラウドに送るのは本当に共有知識やユーザー間同期を必要とするものだけになっています。パーソナルアシスタントの記憶は、まさにオンデバイスが妥協ではなく、より正しいアーキテクチャである典型例です。データは端末から一切出ず、検索は機内モードでも動作します。
Lanternly にとって、この選択は最初から明白でした。日記はおそらく、人が書くもっともプライベートなデータです。それを意味検索のためだけにどこかへ送信することは、アプリの前提そのものを裏切ることになります。NLEmbedding と NLContextualEmbedding は、サーバーコードを一行も書かずに意味検索を実現してくれます——そしてこれこそ、制約が妥協ではなく正しいアーキテクチャ上の決断へと転じる、まさにその事例なのです。



