RAG 检索为什么找不准?用 JavaScript 从零实现中文 BM25 检索
一、前言
随着大模型应用的发展,RAG(检索增强生成)已经成为构建智能知识库的常见方案。
但在实际开发中,我们经常遇到一个问题:知识库里明明存在相关文档,检索系统却没有把它排在前面。
这说明,RAG 的效果不仅取决于大模型,还与文档检索和相关性排序密切相关。
本文不依赖向量数据库,也不需要调用大模型 API,而是通过 JavaScript 实现一个轻量级 BM25 检索器,了解知识库检索背后的基本原理。
二、BM25 是什么?
BM25 是一种经典的信息检索排序算法,主要根据以下因素计算文档与查询的相关性:
-
词频(TF): 查询词在文档中出现的次数。
-
逆文档频率(IDF): 查询词在整个文档集合中的区分能力。
-
文档长度: 对不同长度的文档进行归一化处理。
简单来说,BM25 不只是判断某个关键词是否出现,还会综合考虑它的出现频率与文档长度,从而计算相关性得分。
在中文场景下,由于文本没有天然的空格分词,我们可以先采用字符二元组(Bigram)进行简化处理。
例如:
输入:中文检索
分词结果:
["中文", "文检", "检索"]
这种方式不需要额外安装中文分词库,适合学习和简单的技术演示。
三、JavaScript 实战
下面使用原生 JavaScript 构建一个包含四篇技术文档的小型知识库,并实现 BM25 检索。
运行环境:Node.js 18 及以上版本,无需安装第三方依赖。
完整代码
const docs = [
{
title: "RAG 知识库基础",
text: "RAG 通过检索知识库文档为大模型提供参考内容"
},
{
title: "BM25 排序实践",
text: "BM25 根据词频和文档长度计算关键词相关性"
},
{
title: "AI Agent 安全控制",
text: "AI Agent 工具调用需要重复检测和次数限制"
},
{
title: "中文检索方法",
text: "中文知识库检索可以使用字符二元组与 BM25 排序"
}
];
// 中文字符二元组分词
function tokenize(text) {
const blocks = text.toLowerCase()
.match(/[\u4e00-\u9fff]+|[a-z0-9]+/g) || [];
return blocks.flatMap(w => {
if (!/[\u4e00-\u9fff]/.test(w)) return [w];
return w.length < 2 ? [w] :
Array.from(
{ length: w.length - 1 },
(_, i) => w.slice(i, i + 2)
);
});
}
// 建立文档词频统计
const tokens = docs.map(d => tokenize(d.text));
const counts = tokens.map(ts => {
const m = {};
for (const t of ts) {
m[t] = (m[t] || 0) + 1;
}
return m;
});
const avgLen = tokens.reduce(
(s, ts) => s + ts.length, 0
) / docs.length;
// BM25 评分函数
function bm25(query, i) {
let score = 0;
const k1 = 1.5;
const b = 0.75;
const N = docs.length;
for (const term of new Set(tokenize(query))) {
const freq = counts[i][term] || 0;
if (!freq) continue;
const df = counts.filter(
c => c[term] > 0
).length;
const idf = Math.log(
1 + (N - df + 0.5) / (df + 0.5)
);
score += idf * (freq * (k1 + 1)) /
(freq + k1 * (
1 - b + b * tokens[i].length / avgLen
));
}
return score;
}
// 执行检索并排序
const query = "中文知识库 BM25 检索";
const results = docs.map((d, i) => ({
title: d.title,
score: bm25(query, i)
}))
.filter(r => r.score > 0)
.sort((a, b) => b.score - a.score);
console.table(results.map(r => ({
title: r.title,
score: r.score.toFixed(3)
})));
四、运行结果
将代码保存为 bm25.js,执行:
node bm25.js
本示例的实际运行结果如下:
| 排名 | 文档标题 | BM25 得分 |
|---|---|---|
| 1 | 中文检索方法 | 5.115 |
| 2 | RAG 知识库基础 | 2.002 |
| 3 | BM25 排序实践 | 0.702 |
可以看到,包含多个查询关键词的「中文检索方法」获得了最高的相关性得分。
而「AI Agent 安全控制」没有匹配到查询中的关键词,因此不会出现在结果中。
需要注意的是,BM25 得分并不是匹配概率,而是用于当前查询下文档排序的相关性指标。
五、BM25 的局限性
虽然 BM25 简单高效,但它主要依赖词项匹配,因此存在一些不足。
例如,当用户搜索「如何降低接口响应时间」时,如果文档使用的是「API 性能优化」等不同表达,关键词检索可能无法充分识别它们在语义上的关联。
实际 RAG 系统可以结合向量检索与 BM25,构建混合检索方案,提高文档召回的覆盖范围。
此外,字符二元组只是一个简化方案,实际项目中还可以引入更完善的中文分词、停用词处理与查询扩展机制。
六、总结
本文通过原生 JavaScript 实现了一个简化的中文 BM25 检索器,完成了分词、词频统计、相关性评分与结果排序。
整个示例无需复杂框架,也不依赖外部 API,适合初学者理解 RAG 系统中的检索环节。
RAG 不仅要让大模型能够回答问题,更需要在回答之前找到真正相关的知识。
需要说明的是,本文实现的是 RAG 的检索组件,而非包含大模型生成能力的完整 RAG 系统。
- 点赞
- 收藏
- 关注作者
评论(0)