语义搜索实战:从关键词到向量检索 本文面向想深入理解语义搜索实现原理的开发者。预计阅读时间10 分钟关键词搜索已经够用了试试搜怎么解决数据库死锁——你可能漏掉所有标题写SQLite WAL mode、并发写入冲突的笔记。语义搜索能跨越措辞差异直接理解意图。这篇文章拆解 ChatCrystal 的语义搜索实现从 Embedding 文本构建到 vectra 向量检索再到关系图扩展给出可运行的代码和可调的参数。语义搜索 vs 关键词搜索一个具体例子用户查询: 如何优化大文件解析速度关键词搜索SQL LIKE在title和summary字段里匹配字面量。它能找到标题含解析速度的笔记但会漏掉“JSONL 流式读取性能调优”——同一概念不同措辞“使用 readline 替代 fs.readFile”——解决方案没有优化二字“Cursor 适配器 SQLite 查询慢”——相关场景关键词完全不重叠语义搜索把查询和笔记都转成向量浮点数组通过余弦相似度匹配。优化大文件解析速度和JSONL 流式读取性能调优在向量空间中距离很近因为 Embedding 模型理解它们的语义关联。ChatCrystal 两种搜索都支持/api/notes?searchxxx走关键词/api/search?qxxx走语义。本文聚焦后者。完整搜索流程从用户输入查询到返回结果经过五个阶段查询字符串 → embedSearchQuery() // 1. 向量化查询 → vectra.queryItems() // 2. 向量检索候选集 → materializeDirectSearchHits() // 3. 物化 去重 → expandRelations() // 4. 关系扩展可选 → enrichWithTags() // 5. 批量补充标签 → 返回结果对应server/src/services/embedding.ts中的semanticSearch函数// server/src/services/embedding.tsexportasyncfunctionsemanticSearch(query:string,requestedTopK10,expandRelationsfalse,):PromiseDirectSearchHit[]{// 1. 向量化查询constembeddingawaitembedSearchQuery(query);// 2. 向量检索带候选集升级机制letcandidateKrequestedTopK;letdirectResults:DirectSearchHit[][];while(candidateK0){constresultsawaitindex.queryItemsNoteChunkMeta(embedding,query,candidateK);directResultsawaitmaterializeDirectSearchHits(db,results);if(directResults.lengthrequestedTopK||results.lengthcandidateK)break;candidateKcandidateK*2;// 结果不够翻倍候选集}// 3. 去重同一笔记多个 chunk 取最高分directResultsdirectResults.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 代码本身constcodeSnippetssafeParseJson(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);}// 结构化经验字段constrawPayloadsafeParseJson(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_SIZE500;// characters per chunkfunctionchunkText(text:string):string[]{if(text.lengthCHUNK_SIZE)return[text];constchunks:string[][];constparagraphstext.split(/\n\n/);// 按段落边界切分letcurrent;for(constparaofparagraphs){if(current.lengthpara.length2CHUNK_SIZEcurrent.length0){chunks.push(current.trim());currentpara;}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 向量数据库基于 HNSWHierarchical Navigable Small World算法。索引存储在{dataDir}/vectra-index/目录下// server/src/services/vector-index.tsimport{LocalIndex}fromvectra;constINDEX_PATHresolve(appConfig.dataDir,vectra-index);exportasyncfunctiongetIndex():PromiseLocalIndex{if(_index)return_index;_indexnewLocalIndex(INDEX_PATH);if(!(await_index.isIndexCreated())){await_index.createIndex();}return_index;}每个向量条目包含向量本身和元数据// 生成 Embedding 时的存储constitemawaitindex.insertItem({vector:chunk.vector,metadata:{noteId:id,chunkIndex:chunk.chunkIndex,conversationId,title,projectName,},});查询时vectra 返回 top-K 最近邻constresultsawaitindex.queryItemsNoteChunkMeta(embedding,query,candidateK);候选集升级机制一个笔记有多个 chunk直接取 top-10 可能返回 10 个 chunk 但只来自 3 条笔记去重后只有 3 条结果。ChatCrystal 的做法是逐步翻倍候选集while(candidateK0){constresultsawaitindex.queryItemsNoteChunkMeta(embedding,query,candidateK);directResultsawaitmaterializeDirectSearchHits(db,results);if(directResults.lengthrequestedTopK||results.lengthcandidateK)break;candidateKcandidateK*2;// 10 → 20 → 40 → ...}materializeDirectSearchHits做两件事从 SQLite 读取 chunk 原文按noteId去重保留最高分exportasyncfunctionmaterializeDirectSearchHits(db:PickDatabaseLike,exec,results:SemanticSearchHit[],):PromiseDirectSearchHit[]{constmaterialized:DirectSearchHit[][];for(constresultofresults){constchunkResultdb.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 去重保留最高分constseennewMapnumber,DirectSearchHit();for(constresultofmaterialized){if(!seen.has(result.noteId)||seen.get(result.noteId)!.scoreresult.score){seen.set(result.noteId,result);}}returnArray.from(seen.values());}关系扩展搜索ChatCrystal 的笔记之间有note_relations边由 LLM 在总结时自动生成。开启expandtrue后搜索会沿关系图扩展// server/src/services/embedding.ts (简化)if(expandRelationsdirectResults.length0){constresultMapnewMap(directResults.map((r)[r.noteId,r]));for(constdrofdirectResults){constrelResultdb.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)){constlinkedIdNumber(row.linked_note_id);if(resultMap.has(linkedId))continue;// 已在结果中跳过// 分数折扣原始分 × 0.7 × 置信度constdiscountedScoredr.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# 基础搜索curlhttp://localhost:3721/api/search?qSQLite%20性能优化# 指定返回数量最大 50curlhttp://localhost:3721/api/search?q死锁limit5# 开启关系扩展curlhttp://localhost:3721/api/search?q并发expandtrue返回格式{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. 关系扩展的使用场景expandtrue适合探索式搜索——你想找的不只是直接匹配还有相关联的知识。代价是结果中会混入间接相关的笔记通过via_relation字段可以区分开。精确查找时建议关闭减少噪音。4. 候选集大小默认requestedTopK10如果你的知识库很大500 笔记可以适当增大到 20-30。候选集升级机制会自动处理 chunk 去重不用担心返回结果太少。下一步混合检索结合关键词和语义搜索的混合策略对精确匹配场景如错误代码、函数名更友好。重排序在向量检索后用 Cross-Encoder 对 (query, chunk) 对重新打分提升精度。增量索引优化当前每次更新笔记都重建所有 chunk 的向量可以改为 diff 更新。语义搜索不是银弹但它让知识检索从猜关键词变成表达意图。ChatCrystal 的实现选择了本地优先vectra Ollama零外部依赖适合个人知识库场景。项目地址github.com/ZengLiangYi/ChatCrystal