当前位置: 首页 > news >正文

语义搜索实战:从关键词到向量检索

本文面向:想深入理解语义搜索实现原理的开发者。
预计阅读时间:10 分钟


关键词搜索已经够用了?试试搜"怎么解决数据库死锁"——你可能漏掉所有标题写"SQLite WAL mode"、"并发写入冲突"的笔记。语义搜索能跨越措辞差异,直接理解意图。

这篇文章拆解 ChatCrystal 的语义搜索实现,从 Embedding 文本构建到 vectra 向量检索,再到关系图扩展,给出可运行的代码和可调的参数。

语义搜索 vs 关键词搜索

一个具体例子:

用户查询: "如何优化大文件解析速度"

关键词搜索(SQL LIKE)在titlesummary字段里匹配字面量。它能找到标题含"解析速度"的笔记,但会漏掉:

  • “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"但标签含SQLiteWAL并发,搜索"数据库并发问题"依然能命中。
  • 代码片段只取 description。代码本身字符多、语义密度低,Embedding description(“使用 readline 逐行读取替代 fs.readFile 整文件加载”)比 Embedding 代码体更有效。
  • 去重dedupeExact移除完全重复的文本段,避免噪音。

Memory 笔记的特殊处理

对于agent-writebackmanual-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&&current.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"并发"--json

MCP 工具

在 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-text

2. 查询措辞

语义搜索对查询的措辞不敏感,但以下技巧能提升精度:

  • 具体 > 模糊。"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

http://www.jsqmd.com/news/854041/

相关文章:

  • 别再被数据线坑了!手把手教你用STLINK-V3E给NUCLEO-H7A3ZI-Q开发板下载程序(附驱动安装避坑指南)
  • CRM工单系统开发实战:分支流程引擎与全链路追踪的设计与实现
  • DeepSeek 两次降价打到 2 分钱、Kimi 再融 140 亿:2026 中国大模型没有终局,只有下一轮
  • 从Faster R-CNN到Cascade R-CNN:一个‘打补丁’思路如何刷爆COCO榜单?
  • (技术解析)面向极端天气的配电网韧性强化:应急移动电源预配置的鲁棒优化建模与求解
  • 测试工程师的写作技巧:如何写出受欢迎的测试文章
  • 从零到一:Deformable-DETR实战个人数据集训练与调优
  • 国内高校学生最适用的AI论文写作软件有哪些?
  • 避坑指南:展锐平台Camera驱动移植中那些容易出错的配置项(以OV08A10为例)
  • 开源3D打印人形机器人平台设计与实现
  • Unity VR开发实战:Oculus Quest 2环境配置与开发者工具链全解析
  • 告别Office安装烦恼:5分钟实现个性化部署的智能方案
  • 3分钟解决方案:G-Helper如何让华硕笔记本性能提升40%并减少90%资源占用
  • 嵌入式工控平台升级实战:从EM9161到EM9171的平滑迁移指南
  • AI论文写作软件的合规使用指南:什么程度算学术不端?
  • 测试工程师的演讲技巧:如何做好测试技术分享
  • STM32串口发送浮点数的“坑”我帮你踩完了:从sprintf截断到大小端问题,一篇讲透
  • 3步搞定Windows安卓应用:APK Installer终极安装指南
  • 毕业党救急必看!10款论文降AI工具红黑榜,告别生硬同义词替换
  • 告别盲目充电:手把手教你为51单片机太阳能路灯添加智能充放电保护
  • 如何快速为代码生成软著文档:Flutter版智能工具终极指南
  • 别再只改Host头了!深入理解HTTP Host头攻击的5种变异场景与防御盲区
  • 沈阳网站制作与建设公司推荐
  • Postman脚本进阶:用JavaScript自动管理登录Token,告别接口测试的复制粘贴
  • 鸿蒙PC三方库和命令行工具迁移实战--直播PPT
  • 不止是安装:用RT-Thread Studio图形化配置系统,5分钟创建一个能点灯的NANO工程
  • 告别音乐播放器自带的简陋歌词!在Ubuntu 22.04上用OSD Lyrics打造桌面KTV(附Audacious联动配置)
  • 2026年华南地区GEO优化服务商专业甄选:3家优质机构深度解析 - 产业观察网
  • 从51单片机到STM32:我踩过的坑和快速上手指南(基于Keil5和标准库)
  • 中性蛋白酶选购指南:如何科学选择合适产品 - 资讯速览