
端侧语义搜索: 无需服务器的AI驱动搜索
如何构建完全在用户手机上运行的语义搜索。本地嵌入、向量相似度,以及无需服务器或API即可对用户内容进行自然语言查询。
当用户不知道确切的词语时,关键词搜索就会失败。"上周关于预算会议的那封邮件"无法匹配标题为"第三季度财务回顾"的邮件。语义搜索理解含义,而非仅仅匹配关键词。
标准方案将语义搜索放在带向量数据库的服务器上。但对于用户内容存储在本地的移动应用(笔记、消息、照片、文档),将这些内容发送到服务器就违背了保持端侧处理的初衷。
端侧语义搜索让一切保持在本地。嵌入模型在手机上运行。向量索引存储在本地。搜索查询永远不会离开设备。
语义搜索的工作原理
- 索引: 每段内容使用小型模型转换为嵌入向量(代表其含义的数字列表)
- 存储: 嵌入向量与内容一起存储在本地数据库中
- 查询: 用户的搜索查询使用同一模型转换为嵌入向量
- 匹配: 查询向量与所有存储的向量使用余弦相似度进行比较
- 排序: 结果按相似度分数排列返回
核心在于嵌入。关于同一主题的两段文本会产生相似的向量,即使它们没有共同的关键词。
嵌入模型
端侧嵌入模型小巧且快速。不同于生成式LLM(600MB-1.7GB),嵌入模型通常只有20-80MB:
| 模型 | 大小 | 维度 | 速度 (iPhone 15) |
|---|---|---|---|
| all-MiniLM-L6-v2 | 23MB | 384 | 500+嵌入/秒 |
| nomic-embed-text-v1.5 | 55MB | 768 | 200+嵌入/秒 |
| bge-small-en-v1.5 | 33MB | 384 | 400+嵌入/秒 |
以每秒200-500个嵌入的速度,索引1,000条笔记只需2-5秒。查询嵌入几乎是即时的(5ms以内)。
运行嵌入模 型
你可以通过以下方式运行嵌入模型:
ONNX Runtime Mobile: 支持ONNX格式的嵌入模型。适用于iOS(通过Swift)和Android(通过Kotlin)。是移动端嵌入推理最成熟的选项。
// iOS使用ONNX Runtime
let session = try ORTSession(env: env, modelPath: embeddingModelPath)
let inputTensor = try ORTValue(tensorData: tokenizedInput, shape: shape)
let outputs = try session.run(withInputs: ["input_ids": inputTensor])
let embedding = outputs["embeddings"]!.tensorData()
llama.cpp嵌入模式: llama.cpp可以使用嵌入标志从GGUF模型生成嵌入。这让你可以使用同一个推理引擎来进行生成和嵌入。
向量存储
SQLite加自定义扩展
最简单的移动端方案: 将向量作为BLOB存储在SQLite中,在应用代码中计算相似度。
// Android: 存储嵌入
fun storeEmbedding(db: SQLiteDatabase, contentId: Long, embedding: FloatArray) {
val blob = ByteBuffer.allocate(embedding.size * 4)
embedding.forEach { blob.putFloat(it) }
db.execSQL(
"INSERT INTO embeddings (content_id, vector) VALUES (?, ?)",
arrayOf(contentId, blob.array())
)
}
// 按相似度搜索
fun search(db: SQLiteDatabase, queryEmbedding: FloatArray, limit: Int): List<SearchResult> {
val cursor = db.rawQuery("SELECT content_id, vector FROM embeddings", null)
val results = mutableListOf<SearchResult>()
while (cursor.moveToNext()) {
val blob = cursor.getBlob(1)
val stored = FloatArray(blob.size / 4)
ByteBuffer.wrap(blob).asFloatBuffer().get(stored)
val similarity = cosineSimilarity(queryEmbedding, stored)
results.add(SearchResult(cursor.getLong(0), similarity))
}
return results.sortedByDescending { it.similarity }.take(limit)
}
这种方式简单,适用于最多约10,000个条目的集合。超过此数量时,线性扫描会变慢。
SQLite加向量扩展
对于更大的集合,使用支持近似最近邻(ANN)搜索的SQLite向量扩展:
- sqlite-vss: 使用Faiss进行向量搜索的SQLite扩展。支持iOS和Android。
- sqlite-vec: 专为嵌入式使用设计的轻量级向量搜索扩展。
这些扩展在向量上创建索引,可以在数十万条目中实现亚毫秒级搜索。
完整流程
第1步: 索引内容
当用户创建或修改内容(笔记、消息、文档)时,生成并存储其嵌入:
func indexContent(_ content: Content) async {
let embedding = await embeddingModel.encode(content.text)
database.storeEmbedding(contentId: content.id, vector: embedding)
}
在后台运行索引。用户不应等待嵌入计算完成。
第2步: 搜索
当用户输入搜索查询时:
func search(query: String) async -> [Content] {
let queryEmbedding = await embeddingModel.encode(query)
let results = database.similaritySearch(queryEmbedding, limit: 10)
return results.map { fetchContent($0.contentId) }
}
搜索返回按语义相似度排序的结果。"预算会议记录"可以匹配"第三季度财务回顾",因为嵌入捕获了语义关系。
第3步: 混合搜索
将语义搜索与关键词搜索结合以获得最佳结果:
- 运行关键词搜索(SQLite FTS5)进行精确匹配
- 运行语义搜索进行含义匹配
- 合并并去重结果
- 按综合分数排序(关键词匹配加权)