语义搜索实战:从关键词到向量检索
本文面向:想深入理解语义搜索实现原理的开发者。
预计阅读时间:10 分钟
关键词搜索已经够用了?试试搜"怎么解决数据库死锁"——你可能漏掉所有标题写"SQLite WAL mode"、"并发写入冲突"的笔记。语义搜索能跨越措辞差异,直接理解意图。
这篇文章拆解 ChatCrystal 的语义搜索实现,从 Embedding 文本构建到 vectra 向量检索,再到关系图扩展,给出可运行的代码和可调的参数。
语义搜索 vs 关键词搜索
一个具体例子:
用户查询: "如何优化大文件解析速度"关键词搜索(SQL LIKE)在title和summary字段里匹配字面量。它能找到标题含"解析速度"的笔记,但会漏掉:
- “JSONL 流式读取性能调优”——同一概念,不同措辞
- “使用 readline 替代 fs.readFile”——解决方案,没有"优化"二字
- “Cursor 适配器 SQLite 查询慢”——相关场景,关键词完全不重叠
语义搜索把查询和笔记都转成向量(浮点数组),通过余弦相似度匹配。"优化大文件解析速度"和"JSONL 流式读取性能调优"在向量空间中距离很近,因为 Embedding 模型理解它们的语义关联。
ChatCrystal 两种搜索都支持:/api/notes?search=xxx走关键词,/api/search?q=xxx走语义。本文聚焦后者。
完整搜索流程
从用户输入查询到返回结果,经过五个阶段:
查询字符串 → embedSearchQuery() // 1. 向量化查询 → vectra.queryItems() // 2. 向量检索候选集 → materializeDirectSearchHits() // 3. 物化 + 去重 → expandRelations() // 4. 关系扩展(可选) → enrichWithTags() // 5. 批量补充标签 → 返回结果对应server/src/services/embedding.ts中的semanticSearch函数:
// server/src/services/embedding.tsexportasyncfunctionsemanticSearch(query:string,requestedTopK=10,expandRelations=false,):Promise<DirectSearchHit[]>{// 1. 向量化查询constembedding=awaitembedSearchQuery(query);// 2. 向量检索,带候选集升级机制letcandidateK=requestedTopK;letdirectResults:DirectSearchHit[]=[];while(candidateK>0){constresults=awaitindex.queryItems<NoteChunkMeta>(embedding,query,candidateK);directResults=awaitmaterializeDirectSearchHits(db,results);if(directResults.length>=requestedTopK||results.length<candidateK)break;candidateK=candidateK*2;// 结果不够,翻倍候选集}// 3. 去重:同一笔记多个 chunk 取最高分directResults=directResults.slice(0,requestedTopK);// 4. 关系扩展if(expandRelations){// 沿 note_relations 边扩展...}returndirectResults;}Embedding 文本构建
核心问题:为什么不直接 Embedding 笔记原文?
因为 LLM 生成的笔记包含结构化字段(title、summary、key_conclusions、code_snippets),每个字段的信息密度不同。直接拼接原文会引入噪音——代码片段的字符占比大但语义信息少,标签虽短但关键词价值高。
buildNoteEmbeddingText按策略组合各字段:
// server/src/services/embedding.tsexportfunctionbuildNoteEmbeddingText(input:BuildNoteEmbeddingTextInput):string{constparts:string[]=[];appendText(parts,input.title);// 标题:最高权重appendText(parts,input.summary);// 摘要:核心语义// 关键结论:逐条加入,每条独立成段for(constconclusionofstringArrayFromJson(input.keyConclusionsJson)){appendText(parts,conclusion);}appendText(parts,input.tagsText);// 标签:关键词补充// 代码片段:只取 description,不 Embedding 代码本身constcodeSnippets=safeParseJson(input.codeSnippetsJson);if(Array.isArray(codeSnippets)){for(constsnippetofcodeSnippets){if(isRecord(snippet)){appendText(parts,snippet.description);}}}returndedupeExact(parts).join('\n\n');}关键设计决策:
- 标签参与 Embedding。标签是人工或 LLM 提取的关键词,能显著提升检索精度。标题写"修复 bug"但标签含
SQLite、WAL、并发,搜索"数据库并发问题"依然能命中。 - 代码片段只取 description。代码本身字符多、语义密度低,Embedding description(“使用 readline 逐行读取替代 fs.readFile 整文件加载”)比 Embedding 代码体更有效。
- 去重。
dedupeExact移除完全重复的文本段,避免噪音。
Memory 笔记的特殊处理
对于agent-writeback和manual-note类型的笔记,额外提取结构化字段:
if(isMemoryNoteSource(input.sourceType)){// 代码证据:截断到 1000 字符for(constsnippetofcodeSnippets){appendCodeSnippetEvidence(parts,snippet);}// 结构化经验字段constrawPayload=safeParseJson(input.rawPayloadJson);if(isRecord(rawPayload)){appendLabeledText(parts,'Root cause',rawPayload.root_cause);appendLabeledText(parts,'Resolution',rawPayload.resolution);appendLabeledArray(parts,'Pitfall',rawPayload.pitfalls);appendLabeledArray(parts,'Pattern',rawPayload.reusable_patterns);appendLabeledArray(parts,'Decision',rawPayload.decisions);}appendLabeledArray(parts,'Error signature',safeParseJson(input.errorSignaturesJson));appendLabeledArray(parts,'File',safeParseJson(input.filesTouchedJson));}带标签前缀(Root cause: ...、Error signature: ...)让 Embedding 模型理解字段语义角色,提升"这个错误怎么修"类查询的命中率。
分块策略
一条笔记的 Embedding 文本可能很长。Embedding 模型有 token 上限,且长文本的向量会"稀释"重点信息。ChatCrystal 在 500 字符处切分:
// server/src/services/embedding.tsconstCHUNK_SIZE=500;// characters per chunkfunctionchunkText(text:string):string[]{if(text.length<=CHUNK_SIZE)return[text];constchunks:string[]=[];constparagraphs=text.split(/\n\n+/);// 按段落边界切分letcurrent='';for(constparaofparagraphs){if(current.length+para.length+2>CHUNK_SIZE&¤t.length>0){chunks.push(current.trim());current=para;}else{current+=(current?'\n\n':'')+para;}}if(current.trim())chunks.push(current.trim());returnchunks;}设计要点:
- 段落边界优先。不在句子中间切断,保持语义完整性。
- 500 字符 ≈ 250 token。对大多数 Embedding 模型来说是一个 chunk 的舒适区——既不过长导致语义稀释,也不过短缺乏上下文。
- 每个 chunk 独立 Embedding。一个笔记可能产生 1-5 个向量,存储在 vectra 和 SQLite 的
embeddings表中。
向量检索:vectra 的工作原理
ChatCrystal 使用 vectra 作为本地向量索引。它是一个零依赖的 Node.js 向量数据库,基于 HNSW(Hierarchical Navigable Small World)算法。
索引存储在{dataDir}/vectra-index/目录下:
// server/src/services/vector-index.tsimport{LocalIndex}from'vectra';constINDEX_PATH=resolve(appConfig.dataDir,'vectra-index');exportasyncfunctiongetIndex():Promise<LocalIndex>{if(_index)return_index;_index=newLocalIndex(INDEX_PATH);if(!(await_index.isIndexCreated())){await_index.createIndex();}return_index;}每个向量条目包含向量本身和元数据:
// 生成 Embedding 时的存储constitem=awaitindex.insertItem({vector:chunk.vector,metadata:{noteId:id,chunkIndex:chunk.chunkIndex,conversationId,title,projectName,},});查询时,vectra 返回 top-K 最近邻:
constresults=awaitindex.queryItems<NoteChunkMeta>(embedding,query,candidateK);候选集升级机制
一个笔记有多个 chunk,直接取 top-10 可能返回 10 个 chunk 但只来自 3 条笔记(去重后只有 3 条结果)。ChatCrystal 的做法是逐步翻倍候选集:
while(candidateK>0){constresults=awaitindex.queryItems<NoteChunkMeta>(embedding,query,candidateK);directResults=awaitmaterializeDirectSearchHits(db,results);if(directResults.length>=requestedTopK||results.length<candidateK)break;candidateK=candidateK*2;// 10 → 20 → 40 → ...}materializeDirectSearchHits做两件事:从 SQLite 读取 chunk 原文,按noteId去重保留最高分:
exportasyncfunctionmaterializeDirectSearchHits(db:Pick<DatabaseLike,'exec'>,results:SemanticSearchHit[],):Promise<DirectSearchHit[]>{constmaterialized:DirectSearchHit[]=[];for(constresultofresults){constchunkResult=db.exec(`SELECT e.chunk_text FROM embeddings e JOIN notes n ON n.id = e.note_id WHERE e.note_id = ? AND e.chunk_index = ? AND n.embedding_status = 'done'`,[result.item.metadata.noteId,result.item.metadata.chunkIndex],);if(!chunkResult.length)continue;materialized.push({noteId:result.item.metadata.noteId,score:result.score,chunkText:String(chunkResult[0].values[0][0]),// ...其他字段});}// 按 noteId 去重,保留最高分constseen=newMap<number,DirectSearchHit>();for(constresultofmaterialized){if(!seen.has(result.noteId)||seen.get(result.noteId)!.score<result.score){seen.set(result.noteId,result);}}returnArray.from(seen.values());}关系扩展搜索
ChatCrystal 的笔记之间有note_relations边(由 LLM 在总结时自动生成)。开启expand=true后,搜索会沿关系图扩展:
// server/src/services/embedding.ts (简化)if(expandRelations&&directResults.length>0){constresultMap=newMap(directResults.map((r)=>[r.noteId,r]));for(constdrofdirectResults){constrelResult=db.exec(`SELECT r.relation_type, r.confidence, CASE WHEN r.source_note_id = ? THEN r.target_note_id ELSE r.source_note_id END as linked_note_id FROM note_relations r WHERE (r.source_note_id = ? OR r.target_note_id = ?) AND r.confidence >= 0.5`,[dr.noteId,dr.noteId,dr.noteId],);for(constrowofresultToObjects(relResult)){constlinkedId=Number(row.linked_note_id);if(resultMap.has(linkedId))continue;// 已在结果中,跳过// 分数折扣:原始分 × 0.7 × 置信度constdiscountedScore=dr.score*0.7*(Number(row.confidence)||0.5);resultMap.set(linkedId,{noteId:linkedId,score:Math.round(discountedScore*1000)/1000,viaRelation:row.relation_type,// 标记来源关系类型// ...其他字段});}}returnArray.from(resultMap.values()).sort((a,b)=>b.score-a.score);}分数折扣公式:score × 0.7 × confidence。直觉:关系扩展的结果天然不如直接命中可靠,0.7 的折扣让它们排在直接命中之后。confidence >= 0.5的门槛过滤掉弱关联。
viaRelation字段标记结果来源(如"related"、"duplicate"),前端可以据此展示关联路径。
搜索 API 详解
REST API
# 基础搜索curl"http://localhost:3721/api/search?q=SQLite%20性能优化"# 指定返回数量(最大 50)curl"http://localhost:3721/api/search?q=死锁&limit=5"# 开启关系扩展curl"http://localhost:3721/api/search?q=并发&expand=true"返回格式:
{"success":true,"data":[{"note_id":42,"conversation_id":"abc123","title":"SQLite WAL 模式下的并发写入问题","project_name":"my-project","score":0.891,"tags":["sqlite","并发","性能"],"via_relation":null},{"note_id":58,"conversation_id":"def456","title":"数据库连接池配置","project_name":"my-project","score":0.524,"tags":["database"],"via_relation":"related"}]}CLI
# 基础搜索crystal search"如何优化大文件解析速度"# 指定返回数量crystal search"死锁"--limit5# JSON 输出(适合脚本处理)crystal search"并发"--jsonMCP 工具
在 Claude Code 中通过 MCP 使用语义搜索:
// settings.json{"mcpServers":{"chatcrystal":{"command":"crystal","args":["mcp"]}}}MCP 暴露search_knowledge工具,AI 助手可以直接调用搜索你的知识库。
搜索质量调优
1. Embedding 模型选择
不同模型的向量维度和语义理解能力差异很大:
| 模型 | 维度 | 特点 |
|---|---|---|
nomic-embed-text(Ollama) | 768 | 本地运行,中文支持好 |
text-embedding-3-small(OpenAI) | 1536 | 性价比高 |
text-embedding-3-large(OpenAI) | 3072 | 最高精度 |
text-embedding-004(Google) | 768 | 多语言优化 |
配置方式:
crystal configsetembedding.provider ollama crystal configsetembedding.model nomic-embed-text2. 查询措辞
语义搜索对查询的措辞不敏感,但以下技巧能提升精度:
- 具体 > 模糊。"SQLite WAL 并发写入死锁"比"数据库问题"命中率高。
- 包含意图。"怎么解决 X"和"X 的原理"会匹配不同类型的笔记。
- 英文技术术语保持原样。Embedding 模型对英文术语的编码通常更精确。
3. 关系扩展的使用场景
expand=true适合探索式搜索——你想找的不只是直接匹配,还有相关联的知识。代价是结果中会混入间接相关的笔记,通过via_relation字段可以区分开。
精确查找时建议关闭,减少噪音。
4. 候选集大小
默认requestedTopK=10,如果你的知识库很大(500+ 笔记),可以适当增大到 20-30。候选集升级机制会自动处理 chunk 去重,不用担心返回结果太少。
下一步
- 混合检索:结合关键词和语义搜索的混合策略,对精确匹配场景(如错误代码、函数名)更友好。
- 重排序:在向量检索后用 Cross-Encoder 对 (query, chunk) 对重新打分,提升精度。
- 增量索引优化:当前每次更新笔记都重建所有 chunk 的向量,可以改为 diff 更新。
语义搜索不是银弹,但它让知识检索从"猜关键词"变成"表达意图"。ChatCrystal 的实现选择了本地优先(vectra + Ollama),零外部依赖,适合个人知识库场景。
项目地址:github.com/ZengLiangYi/ChatCrystal
