中文全文检索加向量,我用 SQLite 做到零依赖

10 阅读10分钟

delta search 第一版是废的。

FTS5 建好了,bm25 排序也接上了,delta search "pool" 能把所有聊过连接池的消息捞出来。然后我拿中文试:

$ delta search "连接池"
未检索到与「连接池」相关的历史对话

数据库里明明躺着一条 assistant 消息,原文是「可以引入连接池,控制最大连接数并复用连接」。

不是编码问题,不是索引没建,也不是 SQL 写错。是 FTS5 理解中文的方式跟我以为的完全不一样。

unicode61 把一整句中文当成一个词

建表用的是默认分词器:

CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
  text,
  session_id UNINDEXED,
  role UNINDEXED,
  tokenize = 'unicode61'
);

unicode61 按 Unicode 类别切:字母数字算词字符,标点空白算分隔,然后在这些边界上切分。

问题是汉字属于 Lo(Letter, other)类别,在它眼里就是字母。所以「上下文压缩引擎」这六个字连在一起,unicode61 认为它是一个完整的、不可拆的词。

索引里存的就是一个 token:上下文压缩引擎。我去搜 连接池,它拿这个串去比对 token,比不到,返回空。

搜英文能成,纯粹因为空格天然是分隔符。这个"能用"是假象,把我误导了很久。我一度怀疑 appendMessage 那条同步写 FTS 的路径没被触发,专门打了日志,看到 INSERT OR REPLACE INTO messages_fts 确实执行了,行数也对,就是搜不出来。

真正想明白是在 sqlite3 命令行里直接看 token:

SELECT rowid, text FROM messages_fts LIMIT 1;

输出一行,text 列是 可以引入连接池 控制最大连接数并复用连接。

原来如此。可以引入连接池 是一个 token。

索引里不再存原文

改法是写入之前先把文本切好,切完的空格串存进 FTS。这样 unicode61 只需要按空格切,它怎么理解汉字都无所谓。

export function ftsTokenize(text: string): string[] {
  const out: string[] = [];
  const lower = (text ?? '').toLowerCase();
  for (const w of lower.match(/[a-z0-9_]+/g) ?? []) out.push(w);
  const cjk = lower.match(/[\u3200-\u9FFF\uF900-\uFAFF]+/g) ?? [];
  for (const run of cjk) {
    const chars = [...run];
    if (chars.length === 1) {
      out.push(chars[0]!);
      continue;
    }
    for (let i = 0; i + 1 < chars.length; i++) out.push(chars[i]! + chars[i + 1]!);
  }
  return out;
}

英文和数字按整词,中文按相邻两字切 bigram。连接池 切成 连接 和 接池。

写入:

indexMessage(messageId: number, sessionId: string, role: string, content: string): void {
  const text = role === 'user' || role === 'assistant'
    ? (content ?? '').slice(0, FTS_INDEX_MAX_CHARS)
    : '';
  const fts = ftsTokenize(text).join(' ');
  this.db
    .prepare('INSERT OR REPLACE INTO messages_fts (rowid, text, session_id, role) VALUES (?, ?, ?, ?)')
    .run(messageId, fts, sessionId, role);
}
graph TD
    A[&#34;appendMessage<br/>原文进 messages 表&#34;] --> B{&#34;role 是 user 或 assistant&#34;}
    B -->|否| C[&#34;插一行空 text 占位<br/>让回填 anti-join 幂等&#34;]
    B -->|是| D[&#34;ftsTokenize<br/>英文按词,中文 bigram&#34;]
    D --> E[&#34;空格串存入 messages_fts<br/>索引里已没有原文&#34;]
    A --> F[&#34;截前 2000 字<br/>送 /embeddings&#34;]
    F --> G[&#34;Float32Array BLOB<br/>存 message_embeddings&#34;]
    G --> H[&#34;model 字段记下模型名&#34;]

图上 E 那个节点是整篇的转折:原文和索引分离之后,所有需要"看原文"的功能都得自己回头捞。makeSnippet 就是这条后果的产物。

export function makeSnippet(content: string, query: string, width = 96): string {
  const text = content.replace(/\s+/g, ' ').trim();
  if (text.length <= width) return text;
  const lower = text.toLowerCase();
  const q = query.toLowerCase();
  // 候选:英文词(≥2 字符)+ 查询 CJK 串的 bigram
  const candidates = new Set<string>();
  for (const w of q.match(/[a-z0-9_]{2,}/g) ?? []) candidates.add(w);
  for (const run of q.match(/[\u3200-\u9FFF\uF900-\uFAFF]+/g) ?? []) {
    const chars = [...run];
    if (chars.length === 1) candidates.add(chars[0]!);
    for (let i = 0; i + 1 < chars.length; i++) candidates.add(chars[i]! + chars[i + 1]!);
  }
  let pos = -1;
  for (const c of candidates) {
    const idx = lower.indexOf(c);
    if (idx >= 0 && (pos < 0 || idx < pos)) pos = idx;
  }
  if (pos < 0) return text.slice(0, width) + '…';
  const start = Math.max(0, pos - Math.floor(width / 3));
  const end = Math.min(text.length, start + width);
  return (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '');
}

找查询词第一次出现的位置,往前留三分之一的窗口。一个都找不到就退回开头截断,这种情况出现在纯语义命中上,原文里确实没有那几个字。

另一件要一起改的是查询串必须走同一套分词,不然两边粒度对不上:

export function buildFtsQuery(query: string): string | null {
  const tokens = ftsTokenize(query);
  if (tokens.length === 0) return null;
  return tokens.map((t) => `"${t.replace(/"/g, '""')}"`).join(' AND ');
}

每个 token 加双引号,AND 连起来。加引号是因为 FTS5 的 MATCH 语法里 -、*、(、OR 都有含义,用户搜个 feat-123 或者 C++ 会被当语法解析,直接抛错。引号强制它当字面量。

为什么不用 trigram,也不用单字

FTS5 自带一个 trigram 分词器,本来是为 CJK 准备的,我没用。官方文档写得很直白:「Substrings consisting of fewer than 3 unicode characters do not match any rows when used with a full-text query.」

中文两字词占绝大多数。压缩、缓存、索引、部署、回滚,这些全都搜不到。为了三分之一的查询牺牲三分之二,不划算。

单字那条路我也试过,memoryTokenize 那版就是保留单字的:

const cjk = lower.match(/[\u3200-\u9FFF\uF900-\uFAFF]+/g) ?? [];
for (const run of cjk) {
  const chars = [...run];
  for (let i = 0; i < chars.length; i++) {
    out.add(chars[i]!);
    if (i + 1 < chars.length) out.add(chars[i]! + chars[i + 1]!);
  }
}

单字和 bigram 都进。那版给长期记忆用,记忆条目短,一个 链 字也有区分意义。

检索不能这么干。索引里塞了单字之后,对话 里的 对 会命中 对齐,压缩 里的 压 会命中 压力,上下文 的 上 会命中几乎所有带方向的句子。中文常用字就那几百个,单字进索引等于给每个字发一张通行证,precision 当场崩掉。

所以我拆成了两个函数:记忆用 memoryTokenize(含单字),检索用 ftsTokenize(只有 bigram)。代码注释里记了原因,这坑是我自己踩过才拆的。

代价是单字查询在全文通道永远是空的。delta search "链" 一条都搜不出来,索引里只有 链路、接链 这种两字 token。这个行为我没改,单字检索本来用处不大,真要支持得再加一个单字字段,索引体积翻倍。

补一条语义通道

全文通道的短板很明显:换个说法就搜不到。用户当时说的是「池化」,后来搜「连接池」,bigram 对不上,池化 和 连接+接池 一个共同 token 都没有。

所以加了一条向量通道:

CREATE TABLE IF NOT EXISTS message_embeddings (
  message_id INTEGER PRIMARY KEY,
  model TEXT NOT NULL,
  dim INTEGER NOT NULL,
  vec BLOB NOT NULL
);

向量直接存 Float32Array 的二进制:

insert.run(r.id, emb.model, vec.length, Buffer.from(new Float32Array(vec).buffer));

不用 JSON 数组。理由很实际:一条 1536 维的向量存 JSON 大概 20 到 30KB,存 Float32 是 6KB。几万条消息差几百兆。这也是 SQLite 能做这事的原因,它是真的适合装二进制。

读出来就地重建视图,不拷贝:

const vec = new Float32Array(r.vec.buffer, r.vec.byteOffset, r.vec.byteLength / 4);

byteOffset 和 byteLength / 4 这两个参数不能省。Node 的 Buffer 是 Uint8Array 的子类,不保证独占自己的 ArrayBuffer:小 Buffer 常常是从一块共享内存里切出来的,v.byteOffset 不是 0,v.buffer.byteLength 也不等于 v.length。写成 new Float32Array(r.vec.buffer) 会把邻居的字节一起读进来,维度对不上,余弦值算出来毫无意义。

我没能在这台机器上验证 better-sqlite3 当前版本到底走不走池化,本地装的二进制是按 Node 24 编的,我这儿是 Node 22,加载直接 ABI 不匹配。所以这句只能当通用防线看:传 byteOffset 和 byteLength 在任何情况下都对,不传就依赖了一个不该依赖的实现细节。

相似度是暴力全表扫:

const scored = rows
  .map((r) => {
    const vec = new Float32Array(r.vec.buffer, r.vec.byteOffset, r.vec.byteLength / 4);
    return { row: r, sim: vec.length === q.length ? cosine(q, vec) : 0 };
  })
  .filter((x) => x.sim > 0.3)
  .sort((a, b) => b.sim - a.sim)
  .slice(0, limit);

没有 ANN,没有 HNSW,没有 faiss。本地几万条消息,一次全表扫加余弦是毫秒级的事,上向量库属于给自己找麻烦。

sim > 0.3 是试出来的。低了会把一堆弱相关的捞进来,高了会把「池化」和「连接池」这种同义不同词的命中漏掉。text-embedding-v4 和 text-embedding-3-small 在这个阈值上的表现还不一样,我用的是偏保守的 0.3。

索引维护的杂事

向量表有个 model 字段。换 embedding 模型之后,旧向量和新向量在同一空间里没可比性,混着算出来是垃圾。所以第一次检索时比对一下,不一致就全清:

const stored = this.db.prepare('SELECT model FROM message_embeddings LIMIT 1').get() as
  | { model: string }
  | undefined;
if (stored && stored.model !== emb.model) {
  this.db.prepare('DELETE FROM message_embeddings').run();
}

然后补缺失的:

const missing = this.db
  .prepare(
    `SELECT m.id, m.content_json FROM messages m
     JOIN sessions s ON s.id = m.session_id AND s.deleted_at IS NULL
     WHERE m.role IN ('user', 'assistant')
       AND m.id NOT IN (SELECT message_id FROM message_embeddings)
     ORDER BY m.id DESC
     LIMIT ?`,
  )
  .all(EMBED_BACKFILL_MAX) as Array<{ id: number; content_json: string }>;

EMBED_BACKFILL_MAX 是 1000,EMBED_BATCH 是 32。所以第一次在有几千条历史的库上跑检索,最多要发 32 次网络请求,全在 search() 里同步等。

这是个真实的体验问题。我第一次在存量库上试,命令卡了十几秒,期间一个字都不输出,看着像挂了。后来加了 delta search 前面那句语义不可用的提示,但回填本身还是阻塞的。正确做法应该是后台慢慢补、先把全文结果返回,或者至少在 stderr 打一行「正在补建向量索引 32/1000」。两件都没做。

软删的会话要排除在两条通道之外,所以 s.deleted_at IS NULL 在 SQL 里出现三次,全文、向量回填、向量检索各一次。

还有个看起来像偷懒的写法:

const text = role === 'user' || role === 'assistant'
  ? (content ?? '').slice(0, FTS_INDEX_MAX_CHARS)
  : '';

非 user/assistant 的角色也往 FTS 里插一行空行,不是漏写。存量回填靠

SELECT m.id FROM messages m WHERE m.id NOT IN (SELECT rowid FROM messages_fts)

找缺失行。tool 消息如果压根不入表,每次回填都会把它们重新捞出来算一遍,再发现是空的,白跑。插个空行占位,这个 anti-join 才是幂等的。

两路结果怎么合

bm25 的分数是负数(越小越相关),cosine 是 0 到 1 的小数,量纲完全不同,直接相加没有意义,归一化又得给两边定权重,权重还得随查询调。

RRF 绕开了这件事,只用名次:

const bump = (list: Array<{ id: number; hit: SearchHit }>, src: 'fulltext' | 'semantic') => {
  list.forEach((item, rank) => {
    let entry = scores.get(item.id);
    if (!entry) {
      entry = { score: 0, sources: new Set(), hit: item.hit };
      scores.set(item.id, entry);
    }
    entry.score += 1 / (RRF_K + rank + 1);
    entry.sources.add(src);
  });
};
bump(ftsList, 'fulltext');
bump(semList, 'semantic');
/** RRF 常数(论文推荐 k=60) */
const RRF_K = 60;

第 1 名得 1/61,第 2 名 1/62,第 10 名 1/70,第 20 名 1/80。衰减很平,所以名次靠后也不算低分,这正是 RRF 想要的效果:两路里都排第 15 名的,比只在一路里排第 1 名的更可信。因为

1/76 + 1/76 = 0.0263
1/61         = 0.0164

两路都进前 20,加起来就超过任何单路的头名。这也是我不用加权求和的唯一理由:加权求和的结果对权重极其敏感,而 RRF 除了 k 没有别的参数,k=60 是原论文在 TREC 上跑出来的经验值。

k 的作用是压头部差异。k 越小名次越重要,越大越接近平均。60 这个数我直接抄了,没做自己的实验,要改它得先有一批标注好的查询,我没有。

查询时两路怎么走,一张图看得完:

graph TD
    A[&#34;query 进 search()&#34;] --> B[&#34;ensureIndexed<br/>补 FTS 缺行&#34;]
    B --> C[&#34;ftsTokenize 加 AND 拼 MATCH&#34;]
    B --> D[&#34;embed 查询串&#34;]
    C --> E[&#34;bm25 排序取 limit 条&#34;]
    D --> F{&#34;全表扫余弦<br/>sim 大于 0.3&#34;}
    F -->|可用| G[&#34;按相似度取 limit 条&#34;]
    F -->|embedder 不可用| H[&#34;本进程内永久降级<br/>只走全文&#34;]
    E --> I[&#34;RRF 累加 1/(60+名次)&#34;]
    G --> I
    H --> I
    I --> J[&#34;按分数排序<br/>标记 fulltext / semantic / hybrid&#34;]

hybrid 这个标记:

const via = entry.sources.size > 1 ? 'hybrid' : [...entry.sources][0]!;

两路都命中的标 hybrid,检索结果里会显示。这不只是为了好看:调索引的时候,hybrid 的比例就是判断两路是否互补的直接指标。全是 fulltext,说明语义通道没起作用;全是 semantic,说明倒排基本没命中,可能分词出了问题。

没有网络的时候怎么测

语义检索依赖外部 API,测试里不能真调。我写了个假 embedder,把概念词映射到固定维度:

class FakeEmbedder extends EmbeddingClient {
  private static DIMS: Array<{ dim: number; words: string[] }> = [
    { dim: 0, words: ['池化', 'pooling', '连接池'] },
    { dim: 1, words: ['缓存', 'cache'] },
    { dim: 2, words: ['性能', 'performance'] },
  ];
  override async embed(texts: string[]): Promise<number[][]> {
    return texts.map((t) => {
      const lower = t.toLowerCase();
      const v = [0.01, 0.01, 0.01];
      for (const d of FakeEmbedder.DIMS) {
        if (d.words.some((w) => lower.includes(w))) v[d.dim] = 1;
      }
      return v;
    });
  }
}

三个维度,每个对应一组同义词。池化 和 连接池 都映射到 dim 0,余弦接近 1。这样断网也能验证「换个说法也能搜到」这条链路通没通。

它只能测链路,测不出真实效果。真模型的同义能力、中文语料的相似度分布、0.3 这个阈值合不合适,全得靠真调用。测试过了只代表代码没接错,我在文件开头写了注释提醒自己,免得以后误以为覆盖率够了。

还欠着的三件事

第一件我自己最不满意:一次网络失败就永久降级。

} catch {
  // embedding 不可用(网络/配额)→ 本进程内降级为纯全文
  this.embedFailed = true;
  return undefined;
}

embedFailed 一置位,这个进程里再也不会尝试语义检索。没有重试,没有冷却期,没有退避。

设计意图本身没错,网络不通的时候不该让每次查询都等超时。但代价是一次瞬时的 502 就能把整个 CLI 会话降级成纯全文,用户完全不知道。semanticAvailable 那个 getter 只在命令启动时读一次,中途失效不提示。正确做法是记一个失败时间戳,过几分钟允许重试一次,现在这样等于把一次抖动放大成了会话级故障。

第二件是长查询的 AND 太严。buildFtsQuery 把所有 token 用 AND 连起来,搜「优化数据库连接池的性能问题」会切出 12 个 bigram,要求 12 个全中。而原文是「可以引入连接池,控制最大连接数并复用连接」,里面有 连接、接池,没有 据库(原文是「连接池」不是「数据库」),缺一个 token,整条 AND 就断,返回空。

改 OR 会宽松,但 precision 掉得厉害。折中是分段 AND、段间 OR,或者按命中数排序取 top。我没做,因为调这个得有一批真实查询做回归,否则改完不知道是变好了还是变坏了。

第三件小事:ensureIndexed 每次检索都跑一遍。

const missing = this.db
  .prepare(
    `SELECT m.id, m.session_id, m.role, m.content_json
     FROM messages m WHERE m.id NOT IN (SELECT rowid FROM messages_fts)
     ORDER BY m.id ASC`,
  )
  .all() as Array<{ id: number; session_id: string; role: string; content_json: string }>;

search() 第一行就调它。正常情况下 missing 是空的,一行都不插,但这个 anti-join 本身要扫一遍 messages。几万条的库上大概几毫秒到几十毫秒,不算贵,可它是每次检索都要付的固定成本,而且绝大多数时候什么都没做。

更好的做法是记一个 lastIndexedMessageId,只查 id 大于它的。留着这个懒回填是因为它天然幂等、不怕漏;改成增量就得处理"索引表被外部改坏"这种情况。这是用性能换正确性,不是疏忽。


这套东西的定位很清楚:本地单人用、消息几万条、不想依赖任何外部服务。这个约束下,SQLite 加 FTS5 加暴力向量扫描是合适的,所有数据在一个文件里,备份就是 cp。

消息量上到百万级,或者要支持多人共享,这套就该换。全表扫余弦、RRF 前的 limit 截断、anti-join 回填,三处都会先撑不住。但现在没这个需求,提前上向量库只会让自己多维护一套索引。

代码在 github.com/lwly/delta-…。这篇涉及:

  • 分词、FTS 维护、RRF 融合、语义检索 packages/core/src/session/search.ts
  • 记忆侧的另一套分词 packages/core/src/session/memory.ts(memoryTokenize,保留单字)
  • embedding 客户端与厂商默认配置 packages/core/src/llm/embeddings.ts
  • 建表语句 packages/core/src/session/migrations.ts(messages_fts / message_embeddings)
  • 假 embedder 与混合检索测试 packages/core/test/search.test.ts

上面那三件欠着的事,都是代码现在的真实状态,不是写文章时为了凑三段编的。