生产里漏掉工单号、报错栈、API 名和产品词,经常不是 Embedding 不够强,而是这些词本就不该只走语义检索。本文用 Node.js 搭一套不绑死向量库的双路召回:BM25 走稀疏、向量走稠密,再用 Reciprocal Rank Fusion 融合,并用 Recall@k / MRR 决定稀疏路的开关与权重。换模型或换语料后,同一套评测脚本仍能回归。

漏召回经常不是 Embedding 不够强

语义检索擅长处理「连接总是超时怎么办」这类同义改写,但对 ECONNRESET、getUserById、INV-20240312-8831、内部产品名往往不友好。它们在语料里出现次数少,分词后几乎是独立符号,向量空间里的近邻不一定还带着原词。

换更大的 Embedding,有时能把部分产品词「记住」,但不稳定,也解决不了编号和报错栈这种必须精确命中的查询。更常见的生产形态是:语义路负责改写,稀疏路负责字面。漏召回要先问「这条 query 该不该只走向量」,而不是先换模型。

稀疏路不是银弹。纯口语、同义表达多的集合上,BM25 可能灌进一堆字面巧合。所以后面会把「开不开稀疏路、融合时占多少」交给集合级指标,而不是拍脑袋。

把检索拆成可插拔的装配层

不要把召回写成「某个 SDK 的 search 调用」。至少拆成四段,彼此用稳定的数据结构相连:

  1. 稀疏索引:文档进 BM25,query 出有序列表 { id, rank, score }。
  2. 稠密索引:向量库可换成内存、pgvector 或专用引擎,只要 query 出同样形状的列表。
  3. 融合:RRF 只看排名,不假设两路分数可加。
  4. 评测:固定 query 集和相关文档集合,算 Recall@k、MRR,用来开关稀疏路、调 k 与截断。

向量侧只依赖「能返回 number[] 的 embed 函数」。本地模型、远程 API 都可以,本文示例用手工指定的低维向量,是为了脚本零依赖可跑;接入真实模型时只换 embed(),召回与评测不用改。

Node.js 实现 BM25 与向量双路

下面是一份可直接 node hybrid-recall.mjs 运行的脚本。BM25 用常见的 Lucene 风格 IDF(加 1 避免负值),k1=1.2、b=0.75。分词保留 ASCII 标识符,汉字按字切开——对编号、API 名够用;中文正文若要更好,应换成真实分词器,但不要把分词细节写进融合层。

// hybrid-recall.mjs
function tokenize(text) {
  const s = String(text).toLowerCase();
  const tokens = [];
  let buf = "";
  for (const ch of s) {
    if (/[a-z0-9_]/.test(ch)) buf += ch;
    else {
      if (buf) {
        tokens.push(buf);
        buf = "";
      }
      if (ch >= "\u4e00" && ch <= "\u9fff") tokens.push(ch);
    }
  }
  if (buf) tokens.push(buf);
  return tokens;
}

class Bm25Index {
  constructor({ k1 = 1.2, b = 0.75 } = {}) {
    this.k1 = k1;
    this.b = b;
    this.docs = new Map();
    this.df = new Map();
  }

  add(id, text) {
    const tokens = tokenize(text);
    const tf = new Map();
    for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
    if (this.docs.has(id)) this.#removeDf(this.docs.get(id).tf);
    this.docs.set(id, { tf, dl: tokens.length });
    for (const t of tf.keys()) this.df.set(t, (this.df.get(t) || 0) + 1);
  }

  #removeDf(tf) {
    for (const t of tf.keys()) {
      const n = (this.df.get(t) || 1) - 1;
      if (n <= 0) this.df.delete(t);
      else this.df.set(t, n);
    }
  }

  search(query, k = 10) {
    const qTokens = tokenize(query);
    const N = this.docs.size || 1;
    let totalDl = 0;
    for (const d of this.docs.values()) totalDl += d.dl;
    const avgdl = totalDl / N;
    const scores = new Map();

    for (const q of qTokens) {
      const df = this.df.get(q);
      if (!df) continue;
      const idf = Math.log(1 + (N - df + 0.5) / (df + 0.5));
      for (const [id, doc] of this.docs) {
        const f = doc.tf.get(q);
        if (!f) continue;
        const denom = f + this.k1 * (1 - this.b + (this.b * doc.dl) / avgdl);
        const add = idf * ((f * (this.k1 + 1)) / denom);
        scores.set(id, (scores.get(id) || 0) + add);
      }
    }

    return [...scores.entries()]
      .sort((a, b) => b[1] - a[1])
      .slice(0, k)
      .map(([id, score], i) => ({ id, score, rank: i + 1 }));
  }
}

function cosine(a, b) {
  let dot = 0, na = 0, nb = 0;
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i];
    na += a[i] * a[i];
    nb += b[i] * b[i];
  }
  const d = Math.sqrt(na) * Math.sqrt(nb);
  return d === 0 ? 0 : dot / d;
}

class VectorIndex {
  constructor(embed) {
    this.embed = embed;
    this.items = [];
  }

  add(id, text) {
    this.items.push({ id, vector: this.embed(text) });
  }

  search(query, k = 10) {
    const qv = this.embed(query);
    return this.items
      .map((it) => ({ id: it.id, score: cosine(qv, it.vector) }))
      .sort((a, b) => b.score - a.score)
      .slice(0, k)
      .map((r, i) => ({ ...r, rank: i + 1 }));
  }
}

function rrf(lists, { k = 60, limit = 10 } = {}) {
  const acc = new Map();
  for (const list of lists) {
    for (const hit of list) {
      const add = 1 / (k + hit.rank);
      const cur = acc.get(hit.id) || { id: hit.id, score: 0 };
      cur.score += add;
      acc.set(hit.id, cur);
    }
  }
  return [...acc.values()]
    .sort((a, b) => b.score - a.score)
    .slice(0, limit)
    .map((r, i) => ({ ...r, rank: i + 1 }));
}

function recallAtK(predIds, relevantSet, k) {
  const top = predIds.slice(0, k);
  let hit = 0;
  for (const id of top) if (relevantSet.has(id)) hit += 1;
  return relevantSet.size === 0 ? 0 : hit / relevantSet.size;
}

function mrr(predIds, relevantSet) {
  for (let i = 0; i < predIds.length; i++) {
    if (relevantSet.has(predIds[i])) return 1 / (i + 1);
  }
  return 0;
}

const docs = [
  { id: "d1", text: "TCP 连接被对端重置,常见日志含 ECONNRESET,请检查上游 keep-alive。" },
  { id: "d2", text: "HTTP 客户端超时重试策略,适用于网关 504 与上游响应过慢。" },
  { id: "d3", text: "用户查询接口 getUserById 需要传入 userId,权限不足返回 403。" },
  { id: "d4", text: "发票编号 INV-20240312-8831 的开票失败应走财务补开流程。" },
  { id: "d5", text: "产品词 CloudBox 的容量扩容说明,与计算包计费无关。" },
];

// 演示用:按主题放在不同方向。换模型时改为真实 embed()。
const topic = {
  d1: [1, 0.1, 0, 0],
  d2: [0.95, 0.2, 0, 0],
  d3: [0, 1, 0.1, 0],
  d4: [0, 0, 1, 0],
  d5: [0, 0, 0.1, 1],
};
const queryVec = {
  "报错 ECONNRESET 怎么处理": [0.2, 0.2, 0.2, 0.2],
  "用户资料接口叫什么": [0.05, 0.9, 0.05, 0],
  "连接总是超时怎么办": [1, 0.15, 0, 0],
};

const bm25 = new Bm25Index();
const dense = new VectorIndex((text) => {
  const doc = docs.find((d) => d.text === text);
  if (doc) return topic[doc.id];
  return queryVec[text] || [0, 0, 0, 0];
});

for (const d of docs) {
  bm25.add(d.id, d.text);
  dense.add(d.id, d.text);
}

const gold = [
  { q: "报错 ECONNRESET 怎么处理", relevant: ["d1"] },
  { q: "用户资料接口叫什么", relevant: ["d3"] },
  { q: "连接总是超时怎么办", relevant: ["d2"] },
];

function evalRun(name, searchFn) {
  let r = 0, m = 0;
  for (const g of gold) {
    const ids = searchFn(g.q).map((x) => x.id);
    const rel = new Set(g.relevant);
    r += recallAtK(ids, rel, 3);
    m += mrr(ids, rel);
  }
  const n = gold.length;
  console.log(`${name}\tRecall@3=${(r / n).toFixed(3)}\tMRR=${(m / n).toFixed(3)}`);
}

evalRun("dense-only", (q) => dense.search(q, 5));
evalRun("bm25-only", (q) => bm25.search(q, 5));
evalRun("rrf-hybrid", (q) => rrf([bm25.search(q, 5), dense.search(q, 5)], { k: 60, limit: 5 }));

这组 gold 是刻意构造的:前两条依赖标识符,第三条是语义改写。跑出来你会看到 dense-only 在标识符 query 上容易空,hybrid 能补上。不要把这里的小数当成线上基准,它只证明评测钩子是通的。

用 RRF 融合,而不是硬对齐分数

BM25 与余弦相似度量纲不同,min-max 或 z-score 可以做,但对截断长度、分数空洞很敏感。RRF 只使用排名:

[ \mathrm{RRF}(d)=\sum_i \frac{1}{k+\mathrm{rank}_i(d)} ]

k 常用 60,来自 Reciprocal Rank Fusion 原论文的默认取值。k 越大,排名靠后的贡献衰减越慢。两路都没召回的文档不会出现;只被一路命中的文档仍可凭排名进融合列表,这正是稀疏路补漏的机制。

生产上建议固定每路截断(例如各取 50),再 RRF 取最终 limit。截断本身是超参,应和 k 一起交给下一节的评测,而不是按单条 query 手调。

分数归一并非不能用:若你已经在同一引擎里做加权线性组合,并且两路分数经过校准,线性融合可以更细。但对「换向量库 / 换 Embedding」的装配层,RRF 更少绑死实现细节。

用集合级指标决定稀疏路的开关与权重

不要用单条 bad case 决定架构。准备一个与线上同分布的评测集:query、相关文档 ID(可多条)、以及你真正关心的 K。至少看:

  • Recall@k:漏召回的直接量。标识符类 query 若 dense-only 明显低,稀疏路值得开。
  • MRR:第一条正确结果有多靠前。稀疏路若只是把相关文档从第 20 抬到第 15,对只展示 Top 5 的产品几乎无感。

决策可以很克制:

  1. 先算 dense-only 基线。
  2. 加上 BM25 做 RRF,若 Recall@k 上升而 MRR 不掉(或掉在你能接受的范围),保留稀疏路。
  3. 若整体 Recall 持平、MRR 下降,按 query 类型拆开看:只对「含编号 / 驼峰 API / 全大写报错码」走稀疏,比全局加一路更干净。
  4. k、每路截断、是否对标题字段加权,都用同一脚本扫,留下一张表,而不是留在代码注释里。

换 Embedding 或换语料时,不要先改融合。先冻结 gold 与指标,重跑 dense-only 与 hybrid。若新模型已经能稳住标识符 query 的 Recall,可以降低稀疏截断,甚至关掉;若语料变成日志和工单为主,稀疏路通常应留下。装配层的价值是:模型可以换,对比实验的定义不能换。

向量库替换时,保持 search(query, k) -> [{id, rank, score}] 即可。内存余弦、SQL 的 <=>、专用引擎的 knn,对 RRF 都一样。不要在融合层读取某家 SDK 的内部 score 含义。

总结

漏召回优先检查 query 形态:编号、报错栈、API 名和产品词需要字面匹配,语义检索只是其中一路。Node.js 里可以用很小的 BM25 实现(或日后换成专业倒排)加上可替换的 embed(),用 RRF 做融合,用 Recall@k 与 MRR 决定稀疏路的开关与截断。这套层不绑死向量库,也不依赖某次换模型的体感;评测集还在,回归就能告诉你稀疏路还值不值得开。