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

本文面向:想深入理解语义搜索实现原理的开发者。

预计阅读时间: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 函数:

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

Memory 笔记的特殊处理

对于 agent-writebackmanual-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),零外部依赖,适合个人知识库场景。


项目地址:github.com/ZengLiangYi/ChatCrystal

相关推荐
GreatSQL社区3 小时前
解决 GreatSQL 报错:存储过程字符集排序规则不兼容问题
数据库
肖有米XTKF86463 小时前
肖有米开发团队:双迹美业水光系统小程序模式
数据库·人工智能·团队开发·csdn开发云
KaMeidebaby3 小时前
卡梅德生物技术快报|多肽库筛选技术构建药物递送功能肽库:流程、算法与质控体
前端·数据库·其他·百度·新浪微博
思麟呀3 小时前
MySQL的视图特性和用户权限管理
数据库·mysql
wljt3 小时前
Redis的5种数据类型
数据库·redis·缓存
sakiko_3 小时前
Swift学习笔记30-数据库SQlite语句
数据库·学习·swift
IvorySQL4 小时前
用生成列提升 JSONB 查询效率:PostgreSQL 三种索引方案实测对比
数据库·postgresql
STDD4 小时前
Abiotic Factor多人生存建筑游戏《非生物因素》 专用服务器搭建教程
服务器·数据库·游戏
happyness444 小时前
2026 主流 AI 编码全景对比表
人工智能·ai编程