生产里漏掉工单号、报错栈、API 名和产品词,经常不是 Embedding 不够强,而是这些词本就不该只走语义检索。本文用 Node.js 搭一套不绑死向量库的双路召回:BM25 走稀疏、向量走稠密,再用 Reciprocal Rank Fusion 融合,并用 Recall@k / MRR 决定稀疏路的开关与权重。换模型或换语料后,同一套评测脚本仍能回归。
漏召回经常不是 Embedding 不够强
语义检索擅长处理「连接总是超时怎么办」这类同义改写,但对 ECONNRESET、getUserById、INV-20240312-8831、内部产品名往往不友好。它们在语料里出现次数少,分词后几乎是独立符号,向量空间里的近邻不一定还带着原词。
换更大的 Embedding,有时能把部分产品词「记住」,但不稳定,也解决不了编号和报错栈这种必须精确命中的查询。更常见的生产形态是:语义路负责改写,稀疏路负责字面。漏召回要先问「这条 query 该不该只走向量」,而不是先换模型。
稀疏路不是银弹。纯口语、同义表达多的集合上,BM25 可能灌进一堆字面巧合。所以后面会把「开不开稀疏路、融合时占多少」交给集合级指标,而不是拍脑袋。
把检索拆成可插拔的装配层
不要把召回写成「某个 SDK 的 search 调用」。至少拆成四段,彼此用稳定的数据结构相连:
- 稀疏索引:文档进 BM25,query 出有序列表
{ id, rank, score }。 - 稠密索引:向量库可换成内存、pgvector 或专用引擎,只要 query 出同样形状的列表。
- 融合:RRF 只看排名,不假设两路分数可加。
- 评测:固定 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 的产品几乎无感。
决策可以很克制:
- 先算 dense-only 基线。
- 加上 BM25 做 RRF,若 Recall@k 上升而 MRR 不掉(或掉在你能接受的范围),保留稀疏路。
- 若整体 Recall 持平、MRR 下降,按 query 类型拆开看:只对「含编号 / 驼峰 API / 全大写报错码」走稀疏,比全局加一路更干净。
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 决定稀疏路的开关与截断。这套层不绑死向量库,也不依赖某次换模型的体感;评测集还在,回归就能告诉你稀疏路还值不值得开。
评论