日记应用中的语义搜索:NaturalLanguage 嵌入向量如何找到「含义」而非「文字」#
在开发 Lanternly——一款目前仍在开发中的日记应用——的过程中,我很快就撞上了传统搜索的一个限制。用户写下一条记录:"睡不着,脑子里一直在重复同一个念头",一个月后他用"焦虑"这个词去搜索,却找不到这条记录——因为文字里根本没有"焦虑"这两个字。contains() 和全文索引匹配的是字符串,而不是含义。要让日记真正"记得"一个人,搜索就必须理解"睡不着,脑子里一直重复同一个念头"和"睡前的焦虑"说的其实是同一种体验。而答案比云端嵌入 API 近得多:Apple 多年来一直内置在 iOS 里的 NaturalLanguage 框架,早就能把文本转换成一个"含义向量"——完全在设备端完成,不发出任何网络请求。
既然有 Cmd+F,日记为什么还需要「按含义搜索」#
关键词很少会和一个人回忆事件的方式完全一致。日记不是带标签的日志,而是一段自由书写的文字流,针对它的查询同样是自由的:"上次对妈妈发火是什么时候""关于工作倦怠的记录""手头宽裕的那些日子"。这些查询里的词,可能一个字都不会出现在对应的记录里,但含义却是吻合的。
全文搜索(比如 Core Data/SQLite 里带 CONTAINS[cd] 的 NSPredicate,或者 FTS5)擅长解决的是另一类问题——对术语、人名、日期的精确匹配。这是两种回答不同问题的工具,语义搜索并不会取代全文搜索,而是在用户寻找的是一种"体验"而非一个"字符串"时,对它加以补充。
嵌入向量的想法正是在这里出现:把文本变成高维空间里的一个点,让含义相近的文本彼此靠近,不相关的文本相互远离。这样一来,整个搜索问题就归结为几何问题:找出离查询点最近的那些点。
NLEmbedding 与 NLContextualEmbedding:Apple 原生提供了什么#
NaturalLanguage 提供了两种本质上完全不同的获取向量的方式。
NLEmbedding ——静态的词/句嵌入(NLEmbedding.wordEmbedding(for:)、NLEmbedding.sentenceEmbedding(for:))。模型已经内置在系统里,向量计算是即时的,但同一个词无论上下文如何,总是对应同一个向量——"钥匙"在"家门钥匙"里和在"解谜的钥匙"里,得到的是完全相同的表示。
NLContextualEmbedding(iOS 17+)——基于 Transformer 模型(类 BERT)的嵌入。一个 token 的向量会考虑周围的词,因此多义性和语义的细微差别能被更准确地捕捉。代价是:模型体积明显更大,而且在调用 load() 之前,需要通过 requestAssets(completionHandler:) 把资源一次性下载到设备上。
对于日记这种需要捕捉人的状态细微差别的场景,我选择了 NLContextualEmbedding 作为主要方案,而把 NLEmbedding 保留为没有上下文模型的语言的快速兜底方案。
| 维度 | NLEmbedding(静态) | NLContextualEmbedding | 外部向量数据库(Pinecone / pgvector / Weaviate) |
|---|---|---|---|
| 模型类型 | 静态,不考虑上下文 | Transformer(类 BERT),感知上下文 | 任意可选的外部嵌入模型 |
| 运行位置 | 设备端,系统内置 | 设备端,资源按需下载 | 服务器/云端 |
| 基础设施 | 无需 | 无需 | 数据库服务器、API 密钥、计费 |
| 离线可用 | 可以 | 首次下载资源后可以 | 不行,需要网络请求 |
| 隐私 | 数据不离开设备 | 数据不离开设备 | 文本会发送到第三方服务器 |
| 支持语言 | 有限 | 按文字系统分组,约 27 种语言(WWDC23 起) | 任意,取决于模型 |
| 语义细微差别精度 | 较低,不区分多义词 | 较高,考虑句子上下文 | 最高,SOTA + 领域微调 |
| 数据规模 | 数千条,线性遍历 | 数千条,线性遍历 | 百万级以上,ANN 索引(HNSW 等) |
| 适用场景 | MVP、快速原型 | 用户的个人数据:日记、笔记、邮件 | 通用内容、多用户、海量数据 |
在设备端获取一句话的嵌入向量#
下面是最简可行的实现路径:为某种语言创建上下文模型,按需下载其资源,加载模型,再通过对 token 向量做池化来得到整句话的单个向量。
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)token 一个向量,而不是每句话一个向量——这是 Apple 有意为之的设计,因为 token 级别的向量同样是命名实体识别、token 分类等更精细任务所需要的。对于搜索日记记录而言,做平均池化(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),而不是每次启动应用都重新计算——只有当用户编辑了记录的文本时,才需要重新计算。
排序搜索:找到「说的是同一件事」的记录#
最后一步,是把用户的查询转换成同样类型的向量,再按余弦相似度从高到低,对所有已建立索引的记录排序。
/// 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 趋势,以及何时真正需要一个向量数据库#
这套方案有真实存在的边界,坦诚地谈论它们,比把这个想法当成万能药来推销重要得多。
语言覆盖。 NLContextualEmbedding 并不支持所有语言——模型是按文字系统分组的(Apple 在 WWDC23 上,连同基于 BERT 的多语言 Create ML 模型一起展示了这一点),拉丁文、中日韩文字(CJK)以及其他文字系统组的覆盖并不均衡。在依赖某个模型之前,应该检查它的 NLContextualEmbedding.languages,并为没有上下文模型的语言准备好备用方案。
数据规模。 基于余弦相似度的线性遍历,在几千条记录的规模下——正是多年积累的个人日记的典型体量——表现非常出色。而对于百万级记录和大量用户,才真正需要近似索引和外部向量数据库(Pinecone、Weaviate、pgvector)——那是另一类问题,有着完全不同的成本和隐私权衡。
RAG 趋势。 到了 2026 年,移动端生态明显转向"本地优先"(local-first):应用越来越倾向于把嵌入模型和向量搜索留在设备端,只把真正需要共享知识或跨用户同步的部分发往云端。个人助理的记忆功能正是这样一个典型场景——设备端方案不是妥协,而是更正确的架构:数据永远不离开手机,搜索在飞行模式下依然可用。
对 Lanternly 来说,这个选择从一开始就是显而易见的:日记很可能是一个人写下的最私密的数据。仅仅为了实现语义搜索就把它发送到别处,会背叛这款应用最根本的前提。NLEmbedding 和 NLContextualEmbedding 让语义搜索得以实现,却不需要一行服务器代码——而这正是一个限制转变为正确架构决策、而非妥协的典型案例。



