本文面向:想深入理解语义搜索实现原理的开发者。
预计阅读时间: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 函数:
typescript
// server/src/services/embedding.ts
export async function semanticSearch(
query: string,
requestedTopK = 10,
expandRelations = false,
): Promise<DirectSearchHit[]> {
// 1. 向量化查询
const embedding = await embedSearchQuery(query);
// 2. 向量检索,带候选集升级机制
let candidateK = requestedTopK;
let directResults: DirectSearchHit[] = [];
while (candidateK > 0) {
const results = await index.queryItems<NoteChunkMeta>(embedding, query, candidateK);
directResults = await materializeDirectSearchHits(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 边扩展...
}
return directResults;
}
Embedding 文本构建
核心问题:为什么不直接 Embedding 笔记原文?
因为 LLM 生成的笔记包含结构化字段(title、summary、key_conclusions、code_snippets),每个字段的信息密度不同。直接拼接原文会引入噪音------代码片段的字符占比大但语义信息少,标签虽短但关键词价值高。
buildNoteEmbeddingText 按策略组合各字段:
typescript
// server/src/services/embedding.ts
export function buildNoteEmbeddingText(input: BuildNoteEmbeddingTextInput): string {
const parts: string[] = [];
appendText(parts, input.title); // 标题:最高权重
appendText(parts, input.summary); // 摘要:核心语义
// 关键结论:逐条加入,每条独立成段
for (const conclusion of stringArrayFromJson(input.keyConclusionsJson)) {
appendText(parts, conclusion);
}
appendText(parts, input.tagsText); // 标签:关键词补充
// 代码片段:只取 description,不 Embedding 代码本身
const codeSnippets = safeParseJson(input.codeSnippetsJson);
if (Array.isArray(codeSnippets)) {
for (const snippet of codeSnippets) {
if (isRecord(snippet)) {
appendText(parts, snippet.description);
}
}
}
return dedupeExact(parts).join('\n\n');
}
关键设计决策:
- 标签参与 Embedding 。标签是人工或 LLM 提取的关键词,能显著提升检索精度。标题写"修复 bug"但标签含
SQLite、WAL、并发,搜索"数据库并发问题"依然能命中。 - 代码片段只取 description。代码本身字符多、语义密度低,Embedding description("使用 readline 逐行读取替代 fs.readFile 整文件加载")比 Embedding 代码体更有效。
- 去重 。
dedupeExact移除完全重复的文本段,避免噪音。
Memory 笔记的特殊处理
对于 agent-writeback 和 manual-note 类型的笔记,额外提取结构化字段:
typescript
if (isMemoryNoteSource(input.sourceType)) {
// 代码证据:截断到 1000 字符
for (const snippet of codeSnippets) {
appendCodeSnippetEvidence(parts, snippet);
}
// 结构化经验字段
const rawPayload = 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 字符处切分:
typescript
// server/src/services/embedding.ts
const CHUNK_SIZE = 500; // characters per chunk
function chunkText(text: string): string[] {
if (text.length <= CHUNK_SIZE) return [text];
const chunks: string[] = [];
const paragraphs = text.split(/\n\n+/); // 按段落边界切分
let current = '';
for (const para of paragraphs) {
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());
return chunks;
}
设计要点:
- 段落边界优先。不在句子中间切断,保持语义完整性。
- 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/ 目录下:
typescript
// server/src/services/vector-index.ts
import { LocalIndex } from 'vectra';
const INDEX_PATH = resolve(appConfig.dataDir, 'vectra-index');
export async function getIndex(): Promise<LocalIndex> {
if (_index) return _index;
_index = new LocalIndex(INDEX_PATH);
if (!(await _index.isIndexCreated())) {
await _index.createIndex();
}
return _index;
}
每个向量条目包含向量本身和元数据:
typescript
// 生成 Embedding 时的存储
const item = await index.insertItem({
vector: chunk.vector,
metadata: {
noteId: id,
chunkIndex: chunk.chunkIndex,
conversationId,
title,
projectName,
},
});
查询时,vectra 返回 top-K 最近邻:
typescript
const results = await index.queryItems<NoteChunkMeta>(embedding, query, candidateK);
候选集升级机制
一个笔记有多个 chunk,直接取 top-10 可能返回 10 个 chunk 但只来自 3 条笔记(去重后只有 3 条结果)。ChatCrystal 的做法是逐步翻倍候选集:
typescript
while (candidateK > 0) {
const results = await index.queryItems<NoteChunkMeta>(embedding, query, candidateK);
directResults = await materializeDirectSearchHits(db, results);
if (directResults.length >= requestedTopK || results.length < candidateK) break;
candidateK = candidateK * 2; // 10 → 20 → 40 → ...
}
materializeDirectSearchHits 做两件事:从 SQLite 读取 chunk 原文,按 noteId 去重保留最高分:
typescript
export async function materializeDirectSearchHits(
db: Pick<DatabaseLike, 'exec'>,
results: SemanticSearchHit[],
): Promise<DirectSearchHit[]> {
const materialized: DirectSearchHit[] = [];
for (const result of results) {
const chunkResult = 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 去重,保留最高分
const seen = new Map<number, DirectSearchHit>();
for (const result of materialized) {
if (!seen.has(result.noteId) || seen.get(result.noteId)!.score < result.score) {
seen.set(result.noteId, result);
}
}
return Array.from(seen.values());
}
关系扩展搜索
ChatCrystal 的笔记之间有 note_relations 边(由 LLM 在总结时自动生成)。开启 expand=true 后,搜索会沿关系图扩展:
typescript
// server/src/services/embedding.ts (简化)
if (expandRelations && directResults.length > 0) {
const resultMap = new Map(directResults.map((r) => [r.noteId, r]));
for (const dr of directResults) {
const relResult = 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 (const row of resultToObjects(relResult)) {
const linkedId = Number(row.linked_note_id);
if (resultMap.has(linkedId)) continue; // 已在结果中,跳过
// 分数折扣:原始分 × 0.7 × 置信度
const discountedScore = dr.score * 0.7 * (Number(row.confidence) || 0.5);
resultMap.set(linkedId, {
noteId: linkedId,
score: Math.round(discountedScore * 1000) / 1000,
viaRelation: row.relation_type, // 标记来源关系类型
// ...其他字段
});
}
}
return Array.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
bash
# 基础搜索
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"
返回格式:
json
{
"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
bash
# 基础搜索
crystal search "如何优化大文件解析速度"
# 指定返回数量
crystal search "死锁" --limit 5
# JSON 输出(适合脚本处理)
crystal search "并发" --json
MCP 工具
在 Claude Code 中通过 MCP 使用语义搜索:
json
// 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 | 多语言优化 |
配置方式:
bash
crystal config set embedding.provider ollama
crystal config set embedding.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),零外部依赖,适合个人知识库场景。