如果要做一个“基于自己资料回答问题”的 AI 应用,通常会遇到一个很现实的问题:
用户问一句话,系统应该先从一堆文档里找到相关内容,再把这些内容交给大模型组织成回答。
这个过程看起来像“搜索 + 回答”,但里面有一个关键环节:怎么把真正有用的资料找出来。
如果找错了,大模型后面写得再流畅,也只是基于错误资料进行发挥。
如果找少了,大模型可能答得很保守,甚至漏掉关键信息。
如果找得太杂,大模型又可能被无关内容干扰。
这就是检索问题。
前面讨论文本向量和向量数据库时,已经接触过一种很重要的思路:把文本切成一段一段的小块,再用 Embedding 模型把文本变成向量,最后根据向量距离找出语义相近的内容。
这里有几个词先简单放平:
| 术语 | 可以先这样理解 |
|---|---|
| 文本块 / chunk | 把一篇长文档切成更小的段落,方便检索和放进模型上下文 |
| Embedding | 把文本转换成一串数字,让机器可以比较“语义像不像” |
| 向量检索 | 用这些数字向量去找语义相近的文本 |
| 召回 | 从大量资料里先捞出一批可能相关的候选内容 |
| Top K | 按相关性排序后取前 K 条,比如 Top 5 就是取最相关的 5 条 |
| RAG | Retrieval-Augmented Generation,检索增强生成;可以先理解成“先查资料,再让大模型回答” |
顺着向量检索这条线看,很容易形成一个直觉:既然向量可以表达语义,那是不是以后检索都用向量就够了?
刚开始接触知识库问答时,我也很容易这样理解。向量检索确实很强,它能处理同义表达、近义问题和不完全匹配的查询。用户问“出差回来多久报销”,原文写“返程后 10 个工作日内提交报销申请”,向量检索有机会把它们关联起来。
但继续往真实项目想,就会发现事情没这么简单。
如果用户问的是:
ERR-5021 怎么处理?
或者:
policy-demo-v3-travel-001 这条规则是什么?
这类查询不一定需要系统先“理解一大段语义”。它更需要系统精准抓住字面上的错误码、编号、产品名、函数名、制度条款号。向量检索有时会把语义相近但编号不一致的内容召回,反而不如关键词检索稳定。
所以这一篇想解决的问题不是“BM25、TF-IDF 和向量检索谁更高级”,而是:
在知识库检索里,关键词检索和向量检索分别擅长什么,为什么很多“先查资料、再回答”的系统会把它们组合起来。
为了避免后文例子跳来跳去,这篇使用一组虚构的内部知识库数据。假设有一家 SaaS 公司,把客服 FAQ、接口错误码和费用规则都整理成了可以检索的文本块:
| 文本块 ID | 文本内容 | 类型 |
|---|---|---|
support-api-err-5021 |
ERR-5021 表示支付网关超时,建议重试支付请求并检查网关状态。 | 错误码 |
support-api-err-5030 |
ERR-5030 表示签名校验失败,需要检查请求参数排序和密钥配置。 | 错误码 |
support-refund-policy |
用户退款申请应在订单完成后 7 个自然日内提交。 | 业务规则 |
support-travel-policy |
国内差旅应在返程后 10 个工作日内提交报销申请。 | 费用规则 |
support-payment-delay |
如果支付结果长时间未返回,可以先查询订单状态,再决定是否发起补偿流程。 | 支付流程 |
这些都是为了讲解而构造的数据,不来自真实公司制度。后文会用它们观察三件事:
可以先把检索分成两种能力。
关键词检索关注“字面有没有命中”。
用户查询:
ERR-5021
关键词检索会非常在意文档里是否真的出现了 ERR-5021。如果某个文本块包含这个错误码,它就应该排得很靠前。
向量检索关注“语义上像不像”。
用户查询:
支付一直没返回怎么办?
即使原文没有“一直没返回”这几个字,只要写了“支付结果长时间未返回”“查询订单状态”“补偿流程”,向量检索也可能认为它们语义接近。
这两种能力的差别,可以先放在一张表里看:
| 查询类型 | 更适合的检索方式 | 原因 |
|---|---|---|
ERR-5021 |
关键词检索 | 错误码必须精确命中 |
policy-demo-v3-travel-001 |
关键词检索 | ID、编号、条款号不能靠语义猜 |
支付回调超时 |
关键词 + 向量 | 有明确术语,也可能存在不同表述 |
付款后页面卡住了怎么办 |
向量检索 | 用户表达可能和文档措辞不同 |
住宿发票 2000 元审批 |
关键词 + 向量 | 数字和术语都重要 |
向量检索解决的是“说法不一样但意思相近”的问题。
关键词检索解决的是“某些字面信息必须对上”的问题。
如果只用向量检索,容易漏掉错误码、版本号、字段名、函数名、产品型号这类精确符号。如果只用关键词检索,又很难处理自然语言里的换一种说法。
混合检索的基本思路就是:
这里的“候选结果”可以理解成:系统暂时还不能确定哪几段最可靠,所以先把可能相关的内容都捞出来。
“去重”是因为同一段文本可能同时被关键词检索和向量检索找到,最终给大模型时不应该重复塞进去。
“Top K”则是最终只取前面几条,比如从 40 条候选里选出最有用的 5 条。
它不是为了把系统搞复杂,而是承认一个事实:真实用户的提问里,经常同时包含“精确符号”和“模糊意图”。
要理解 BM25,最好先理解 TF-IDF。因为 BM25 很大程度上是在 TF-IDF 的思想上继续改进。
TF-IDF 的完整名字是 Term Frequency-Inverse Document Frequency,通常翻译成“词频-逆文档频率”。
这个名字看着有点拗口,但它想解决的问题很直观:
一个词在某篇文档里到底重不重要?
只看词出现次数是不够的。
比如一篇文档里,“的”“是”“在”可能出现很多次,但它们不能代表文档主题。相反,“ERR-5021”“支付网关”“签名校验”出现次数可能没那么多,但更能区分文档内容。
TF-IDF 就是把两个因素乘起来:
TF:词在当前文档里出现得多不多。
TF 是 Term Frequency,词频。
最简单的 TF 就是词出现的次数:
其中:
有时为了避免长文档天然占便宜,也会使用标准化词频:
假设一个文本块是:
ERR-5021 表示支付网关超时,支付请求可以重试。
简单分词后可以理解成:
ERR-5021 / 支付 / 网关 / 超时 / 支付 / 请求 / 重试
这里“支付”出现 2 次,ERR-5021 出现 1 次。如果只看 TF,“支付”似乎比 ERR-5021 更重要。
但这还不够。因为“支付”可能在很多文档里都出现,而 ERR-5021 可能只在一条错误码说明里出现。
这就需要 IDF。
IDF:词在整个文档集合里稀不稀有。
IDF 是 Inverse Document Frequency,逆文档频率。
它不是看一个词在当前文档里出现多少次,而是看它出现在多少篇文档里。
如果一个词在很多文档里都出现,它的区分能力就弱。
如果一个词只在少数文档里出现,它就更像某些文档的“指纹”。
一个常见的 IDF 形式是:
其中:
用这篇文章开头的 5 条演示数据来看:
| 词 | 出现在哪些文档 | DF |
|---|---|---|
支付 |
support-api-err-5021、support-payment-delay |
2 |
ERR-5021 |
support-api-err-5021 |
1 |
报销 |
support-travel-policy |
1 |
用户 |
support-refund-policy |
1 |
如果总文档数 ,那么:
ERR-5021 的 IDF 更高,因为它更稀有,更能区分具体文档。
这背后的直觉很像查资料:
ERR-5021 像一个精确定位码,能直接指向某条故障说明。TF-IDF:当前文档里常见,同时在全局里稀有。
把 TF 和 IDF 乘起来,就是 TF-IDF:
它想表达的是:
一个词如果在当前文档里出现较多,同时在整个文档集合里不常见,那么它就更能代表当前文档。
可以把四种情况放在一起看:
| TF | IDF | 含义 | 例子 |
|---|---|---|---|
| 高 | 高 | 当前文档频繁出现,全局又稀有,很重要 | 某篇故障说明里的 ERR-5021 |
| 高 | 低 | 当前文档常见,但全局也常见,区分度有限 | 很多支付文档里的“支付” |
| 低 | 高 | 全局稀有,但当前文档只偶尔提到 | 误提到一次的专业名词 |
| 低 | 低 | 当前不突出,全局也普通 | 停用词或泛词 |
TF-IDF 的价值在于简单、可解释、计算快。它可以用于关键词提取、文档相似度计算、简单搜索排序、传统机器学习的文本特征。
但 TF-IDF 也有明显局限:它通常基于词袋模型,不理解词序,也不理解语义。
苹果 公司 发布 新 手机
和:
市场 苹果 价格 上涨
都包含“苹果”,但前者可能是科技公司,后者可能是水果价格。TF-IDF 本身很难知道这里的“苹果”到底是哪一种含义。
这也是后来向量检索能补上的部分。
前面讲 TF-IDF 时,已经知道它会给词语计算权重:
但把 TF-IDF 放进搜索场景以后,还会遇到两个具体问题。
假设用户搜索:
支付网关超时
知识库里有三份候选文档:
| 文档 | 内容特点 |
|---|---|
| A | 一段很短的 FAQ,只出现一次“支付网关超时”,但直接给出处理办法 |
| B | 一篇很长的故障报告,里面反复出现“支付”“网关”“超时”,还包含许多其他问题 |
| C | 一篇普通支付说明,只提到一次“支付”,没有解释网关超时 |
TF-IDF 大致会认为:
看起来没什么问题,但 B 有两个天然优势:
然而从用户角度看,A 可能比 B 更有用。
A 直接回答“支付网关超时怎么处理”,B 只是因为内容长、关键词多而排得靠前。
所以 BM25 不是突然出现的另一个神秘算法。它要做的事情很明确:
保留 TF-IDF 对关键词和稀有词的判断,同时限制“重复太多”和“文档太长”带来的不公平。
BM25 仍然是关键词检索方法。它不会理解“付款后一直没结果”和“支付结果长时间未返回”其实意思相近,那是向量检索更擅长的事情。
BM25 主要解决的是:
当查询词真的出现时,怎样更合理地给文档排序?
先只看“词出现了多少次”这件事。
一个文档里出现 1 次“支付网关”,另一个文档里出现 3 次“支付网关”,后者通常更值得关注,这很合理。
但如果一个文档出现 30 次,另一个出现 300 次,后者真的应该比前者相关 10 倍吗?
通常不应该。
因为关键词出现到一定次数后,已经足以证明文档和它有关。继续重复,可能只是:
这些重复不应该无限增加文档的相关性。
BM25 用“词频饱和”处理这个问题。
“饱和”可以先理解成:词频带来的加分会越来越接近一个上限。
为了先看懂趋势,可以把它简化成:
这个公式暂时只看三个东西:
| 符号 | 含义 |
|---|---|
| 某个查询词,例如“超时” | |
| 某篇候选文档 | |
| 这个词在文档里出现了几次 | |
| 控制“多出现几次以后开始趋于饱和” |
假设 ,把不同词频代入:
| 词频 | 计算结果约为 | 说明 |
|---|---|---|
| 1 | 1.00 | 第一次命中,获得基础贡献 |
| 2 | 1.43 | 第二次命中仍有明显帮助 |
| 3 | 1.67 | 继续增加,但增幅变小 |
| 5 | 1.92 | 已经接近上限 |
| 50 | 2.43 | 重复很多次,增加有限 |
| 500 | 2.49 | 几乎不再明显增加 |
不要把这里的 1.00、1.43 理解成概率。它们只是公式产生的相对贡献值,用来说明增长趋势。
这个趋势比“词频直接乘上权重”更符合搜索直觉:
第一次命中:说明可能相关
第二次命中:进一步确认相关
重复很多次:不要再无限奖励
参数 也不需要现在背具体数值。先理解它的作用就够了:
再看文档长度。
假设有两篇文档:
文档 A:支付网关超时,请先检查网关状态。
文档 B:一篇 10 页的支付系统故障报告,其中某一段提到支付网关超时。
文档 B 可能包含更多查询词,但它并不一定比 A 更直接。
如果评分完全不考虑长度,长文档容易因为“包含的词更多”获得额外优势。BM25 会根据当前文档长度和平均文档长度进行调整。
长度调整项通常写成:
这里的符号可以这样理解:
| 符号 | 含义 |
|---|---|
| $ | d |
| 所有文档的平均长度 | |
| 长度调整的强度 |
假设平均文档长度是 100,:
| 当前文档长度 | 长度调整项 | 直观含义 |
|---|---|---|
| 50 | 文档较短,长度惩罚较小 | |
| 100 | 和平均长度一样 | |
| 200 | 文档较长,后续分母会变大 |
这里容易产生一个疑问:短文档的调整项更小,是不是短文档反而被惩罚了?
恰好相反。
这个调整项会进入 BM25 公式的分母:
所以它表达的是:
短而精准的文档,不因为字数少而吃亏;
长而宽泛的文档,不因为字数多而占便宜。
参数 可以理解为“要不要认真考虑长度”:
到这里,BM25 的思路其实已经完整了:
因此,BM25 的常见公式可以写成:
这条公式不需要一次背下来。可以把它从外到内拆成:
最终得分
= 查询词的重要程度
× 当前词频的饱和贡献
× 文档长度调整
更准确地说,长度调整放在分母中,所以文档越长,通常越会压低同等词频下的贡献。
公式里的三块分别对应:
| 部分 | 它在回答什么问题 |
|---|---|
| 这个查询词有区分度吗? | |
| 和 | 这个词出现多次后,是否已经够说明相关? |
| 、$ | D |
为了不让完整公式停留在“看过但不会用”,假设查询只有一个词“超时”,并且:
IDF("超时") = 1
k1 = 1.5
b = 0.75
平均文档长度 = 100
现在比较两篇文档:
| 文档 | “超时”出现次数 | 文档长度 |
|---|---|---|
| A | 1 | 50 |
| B | 10 | 200 |
文档 A 的长度调整项是:
代入 BM25 的这一项:
文档 B 的长度调整项是:
代入同样的计算:
文档 B 仍然因为命中了 10 次而得到更高贡献,但它并没有获得“10 倍分数”。同时,文档长度也让它的得分受到了一定压制。
这个例子想说明的不是要手算 BM25,而是看清三个变化:
如果用一句话解释 BM25:
BM25 是一种关键词检索评分方法:它奖励命中有区分度的查询词,但会限制重复词频和文档长度带来的虚高分。
它不需要训练大模型,也不需要 GPU。只要有分词后的文档,就可以建立关键词检索能力。
但它的边界也要记住:
“支付网关超时” 和 “付款后一直没结果”
即使两句话意思相近,只要关键词不同,BM25 也不一定能把它们联系起来。
这正是向量检索可以补上的部分。
把 TF-IDF、BM25 和向量检索放在一起看,会更容易理解它们为什么能组合。
| 能力 | TF-IDF | BM25 | 向量检索 |
|---|---|---|---|
| 精确词命中 | 可以 | 很强 | 不稳定 |
| 错误码、ID、函数名 | 可以 | 很强 | 容易语义漂移 |
| 同义表达 | 弱 | 弱 | 强 |
| 词频影响 | 线性或简单变体 | 有饱和控制 | 由模型表示决定 |
| 文档长度影响 | 通常靠归一化间接处理 | 内置长度归一化 | 取决于切分和模型 |
| 可解释性 | 强 | 强 | 较弱 |
| 中文依赖分词 | 是 | 是 | 通常不需要显式分词 |
| 冷启动成本 | 低 | 低 | 需要 Embedding 模型和向量库 |
这里最重要的不是背表格,而是建立一个判断:
关键词检索擅长“必须字面对上”的场景,向量检索擅长“表达方式不一样但意思接近”的场景。
例如用户问:
ERR-5021 支付失败
BM25 很容易命中:
ERR-5021 表示支付网关超时,建议重试支付请求并检查网关状态。
因为 ERR-5021 是强字面线索。
但用户如果问:
付款后一直没有结果,应该先查什么?
向量检索更可能命中:
如果支付结果长时间未返回,可以先查询订单状态,再决定是否发起补偿流程。
因为“付款后一直没有结果”和“支付结果长时间未返回”字面不完全一样,但语义接近。
真实问题经常混在一起:
ERR-5021 支付一直没返回怎么办?
这里既有精确错误码,又有自然语言描述。
只走 BM25,可能过度依赖 ERR-5021。只走向量,可能召回一些“支付超时”“订单状态查询”相关但没有 ERR-5021 的内容。
混合检索就更自然:
最简单的混合方式,是分别从 BM25 和向量检索里取一些结果,然后合并。
但真正要做好,至少要考虑四个问题:
第一步:分别召回候选。
假设用户查询:
ERR-5021 支付一直没返回怎么办?
BM25 可能返回:
| 排名 | 文本块 | 命中原因 |
|---|---|---|
| 1 | support-api-err-5021 |
精确命中 ERR-5021、支付 |
| 2 | support-payment-delay |
命中 支付 |
| 3 | support-api-err-5030 |
命中错误码结构相近,但编号不同 |
向量检索可能返回:
| 排名 | 文本块 | 命中原因 |
|---|---|---|
| 1 | support-payment-delay |
语义接近“支付一直没返回” |
| 2 | support-api-err-5021 |
和支付网关超时相关 |
| 3 | support-refund-policy |
与支付、订单有弱相关 |
单看任意一路都有风险。
BM25 可能召回编号相近但实际不对的错误码。向量检索可能召回语义相关但缺少精确错误码的内容。
两路合起来,support-api-err-5021 和 support-payment-delay 同时被召回,可信度就更高。
第二步:不要直接相信原始分数。
BM25 分数和向量距离通常不在同一个尺度上。
BM25 返回的可能是:
support-api-err-5021 8.7
support-payment-delay 3.2
support-api-err-5030 1.1
向量检索返回的可能是余弦距离:
support-payment-delay 0.18
support-api-err-5021 0.24
support-refund-policy 0.41
这里 BM25 是分数越大越相关,余弦距离可能是越小越相关。它们不能直接相加。
直接写:
通常是不靠谱的。
更稳的做法有两类。
一种是分数归一化后加权。这里要先做一个转换:不管原始返回的是“分数”还是“距离”,最终都要先变成同一个方向的相关性分数。
也就是说,要统一成:
数值越大,表示越相关。
如果某一路返回的是“距离”,而且距离越小越相关,就不能直接拿来加权。要先把它转换成“越大越好”的分数,再做归一化。
归一化的作用,是把每一路结果缩放到相似区间,比如 到 :
这个公式本身并不神秘,只是在做一件事:
当前值在这一批结果里大概处于什么位置。
然后再加权:
其中:
不过这里也要留一个边界:归一化加权只是混合检索的一种做法,不是唯一标准答案。数据分布不同、距离含义不同、查询类型不同,都会影响权重效果。
另一种是按排名融合。有时不直接融合分数,而是融合排名。比如某条结果在 BM25 里排第 1,在向量检索里排第 2,那它比只在一路里排第 8 的结果更值得关注。
具体的排名融合方法可以单独展开。这里先不急着引入更多算法名,只抓住一个直觉:
多路检索融合时,原始分数不一定能直接比较,排名和归一化通常比直接相加更稳。
第三步:去重和保留来源。
混合检索会出现同一个文本块被多路召回的情况。
比如:
BM25 返回:support-api-err-5021
向量检索返回:support-api-err-5021
这时不能把它当成两条内容塞给大模型。否则上下文会重复,浪费 token,还可能让模型误以为这条证据出现了两次。
更好的做法是按稳定 ID 去重,并保留召回来源:
{
"id": "support-api-err-5021",
"document": "ERR-5021 表示支付网关超时,建议重试支付请求并检查网关状态。",
"retrieval_sources": ["bm25", "vector"],
"bm25_rank": 1,
"vector_rank": 2
}
这样后续排查时也能知道:
这对评估检索系统很有用。
第四步:根据查询类型调整策略。
不是所有查询都应该使用同样的混合比例。
可以先粗略分几类:
| 查询特征 | 检索策略倾向 |
|---|---|
| 包含错误码、订单号、接口名、字段名 | 提高 BM25 权重 |
| 包含自然语言描述、同义表达 | 提高向量检索权重 |
| 同时包含编号和自然语言 | 两路都要召回 |
| 查询非常短,比如“退款” | 增加候选数量,避免过早过滤 |
| 查询很长,像完整问题 | 向量检索通常更有帮助 |
例如:
ERR-5030
可以优先关键词检索,因为这是明确错误码。
为什么付款成功后页面还是显示处理中?
可以优先向量检索,因为这是自然语言问题。
ERR-5021 支付成功但回调没到
混合检索更合适,因为它同时包含错误码和语义描述。
下面用伪代码表达一个最小流程。重点不是把它做成可直接上线的框架,而是看清数据怎么流动。
可以写成类似这样的结构:
def hybrid_search(query, top_k=5):
bm25_results = bm25_search(query, n=20)
vector_results = vector_search(query, n=20)
candidates = {}
for rank, item in enumerate(bm25_results, start=1):
candidates.setdefault(item.id, item)
candidates[item.id].bm25_rank = rank
candidates[item.id].sources.add("bm25")
for rank, item in enumerate(vector_results, start=1):
candidates.setdefault(item.id, item)
candidates[item.id].vector_rank = rank
candidates[item.id].sources.add("vector")
fused = fuse_and_sort(candidates.values())
return fused[:top_k]
这里有几个细节值得注意。
BM25 和向量检索都可以多召回一点。
不要一开始每路只取 3 条。因为融合之前就截得太狠,另一条路线可能还没机会补上。
常见做法是:
BM25 Top 20
向量 Top 20
合并去重
再取最终 Top 5
最终 Top K 不一定等于每路 Top K。
如果最终只需要 5 条证据,也可以每路先召回 20 条。初始召回阶段宁可稍微宽一些,再在融合和重排阶段压缩。
候选记录必须有稳定 ID。
如果没有稳定 ID,去重会很麻烦。前面讨论向量数据库时提到过,ID 不只是方便存储,也会影响后续检索结果能不能被稳定合并。
下面这部分是动手示例,不是理解主线的前置条件。
如果当前只想先读懂混合检索的思路,可以先看代码前后的解释,代码细节以后再回来跑。
如果想本地理解 BM25,可以用 rank_bm25 做一个最小示例。这个库的用法很直接:语料和查询都要先分词,再交给 BM25Okapi。
英文示例可以简单用空格分词:
from rank_bm25 import BM25Okapi
documents = [
"ERR-5021 payment gateway timeout retry request",
"ERR-5030 signature validation failed check secret",
"refund request must be submitted within seven days",
"payment result pending query order status first",
]
tokenized_documents = [doc.split(" ") for doc in documents]
bm25 = BM25Okapi(tokenized_documents)
query = "ERR-5021 payment timeout"
tokenized_query = query.split(" ")
scores = bm25.get_scores(tokenized_query)
top_docs = bm25.get_top_n(tokenized_query, documents, n=2)
print(scores)
print(top_docs)
中文要先分词。这里用 jieba 举例:
from rank_bm25 import BM25Okapi
import jieba
documents = [
"ERR-5021 表示支付网关超时,建议重试支付请求并检查网关状态。",
"ERR-5030 表示签名校验失败,需要检查请求参数排序和密钥配置。",
"用户退款申请应在订单完成后 7 个自然日内提交。",
"如果支付结果长时间未返回,可以先查询订单状态。",
]
def tokenize(text):
return list(jieba.cut(text))
tokenized_documents = [tokenize(doc) for doc in documents]
bm25 = BM25Okapi(tokenized_documents)
query = "ERR-5021 支付一直没返回"
tokenized_query = tokenize(query)
scores = bm25.get_scores(tokenized_query)
top_docs = bm25.get_top_n(tokenized_query, documents, n=2)
print(scores)
print(top_docs)
rank_bm25 的关键点是:
get_scores(query_tokens) 返回查询对所有文档的分数;get_top_n(query_tokens, documents, n=...) 返回最相关的原始文档。这里也能看到中文检索的一个现实问题:分词质量会直接影响 BM25 质量。
如果 ERR-5021 被切坏,或者“支付网关”被切成不合理的片段,关键词检索效果就会受影响。中文知识库做 BM25,通常要认真处理:
下面这部分同样是工具示例。它的目的不是把 TF-IDF 变成另一个重点,而是帮助看清:传统关键词方法也可以把文本表示成“向量”,只是这个向量和 Embedding 向量不是一回事。
TF-IDF 也可以直接把文本变成一个稀疏向量。
这里的“向量”不是 Embedding 那种神经网络语义向量,而是“词表维度上的权重向量”。
假设词表是:
[ERR-5021, ERR-5030, 支付, 网关, 超时, 签名, 退款]
某个文档可以表示成:
[0.8, 0, 0.3, 0.4, 0.4, 0, 0]
这表示它在 ERR-5021、支付、网关、超时 等维度上有权重。
用 scikit-learn 可以这样写:
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
documents = [
"ERR-5021 payment gateway timeout retry request",
"ERR-5030 signature validation failed check secret",
"refund request must be submitted within seven days",
"payment result pending query order status first",
]
vectorizer = TfidfVectorizer()
tfidf_matrix = vectorizer.fit_transform(documents)
query = "ERR-5021 payment timeout"
query_vector = vectorizer.transform([query])
scores = cosine_similarity(query_vector, tfidf_matrix)[0]
ranked = sorted(
enumerate(scores),
key=lambda item: item[1],
reverse=True,
)
for index, score in ranked[:3]:
print(score, documents[index])
这里要注意 fit_transform 和 transform 的区别:
fit_transform(documents) 会从语料里学习词表和 IDF,同时转换文档;transform([query]) 会复用已经学好的词表和 IDF,把新查询转换到同一个空间。查询不能重新 fit_transform,否则查询会有自己的词表,和文档空间对不上。
这一点和 Embedding 检索里“文档和查询要用同一个模型”很像。TF-IDF 检索里,文档和查询也必须使用同一个词表、同一套 IDF 统计。
到这里,TF-IDF、BM25 和向量检索都讲完了。接下来可以把它们放回一个更真实的场景:基于知识库回答问题。
比如公司内部有一堆文档:
用户问一句:
ERR-5021 支付一直没返回怎么办?
系统不能只把这个问题直接丢给大模型。因为大模型本身不一定知道这家公司内部的错误码含义,也不一定知道当前业务文档里的最新处理流程。
更合理的做法是:
这类“先检索,再生成”的思路,常见名称就是 RAG,全称是 Retrieval-Augmented Generation,中文常译作“检索增强生成”。
拆开看并不神秘:
| 部分 | 含义 |
|---|---|
| Retrieval | 先从外部资料里检索相关内容 |
| Augmented | 用检索到的内容增强大模型上下文 |
| Generation | 再让大模型生成回答 |
所以 RAG 不是一个单独的模型,也不是某一家厂商的专有能力。它更像一种应用架构:大模型负责组织语言,知识库负责提供依据,检索负责把依据找出来。
也正因为这样,检索质量会直接影响最终回答质量。
这会带来一个更严格的要求:
检索结果不只是“相关”,还要能支持后续回答。
在普通搜索里,用户可以自己点开多个结果判断,发现不对还能换一个关键词继续搜。但在知识库问答里,用户看到的往往是大模型已经整理好的答案。如果错误证据进入上下文,大模型可能会基于错误证据编出看似合理的回答。
混合检索的价值在这里会更明显。
BM25 负责抓住硬线索。
比如:
ERR-5021createPaymentcallback_urlX100-Propolicy-demo-v3-travel-001这些内容如果没被召回,回答很可能偏题。
向量检索负责补上软语义。
比如:
这些表达不一定和文档完全同词,但语义上可能指向同一类问题。
两者合在一起,系统更容易同时拿到:
精确错误码说明 + 相关处理流程
而不是只拿到其中一边。
可以把混合检索理解成一个更稳的“找资料策略”:
混合检索真正落地时,通常不是一次就调好。可以从几个方向逐步调整。
召回数量。
如果结果经常漏掉正确证据,可以先扩大每路召回数量:
| 配置 | 适合情况 |
|---|---|
| BM25 Top 10 + Vector Top 10 | 小型知识库或延迟敏感 |
| BM25 Top 20 + Vector Top 20 | 常见默认起点 |
| BM25 Top 50 + Vector Top 50 | 召回不足,后面有重排能力 |
这里的 Top 10、Top 20、Top 50 不是固定标准,而是一种取舍。
可以把它想成查资料时的两步:
召回越多,正确证据进入候选集的机会越大,但也会带来更多噪声和计算成本。如果后面没有足够好的排序、去重或重排能力,盲目扩大召回数量也可能让结果变乱。
权重比例。
如果使用加权分数,可以按查询类型调权重:
| 查询类型 | BM25 权重 | 向量权重 |
|---|---|---|
| 错误码、ID、字段名 | 高 | 中 |
| 自然语言问答 | 中 | 高 |
| 术语 + 自然语言混合 | 高 | 高 |
| 泛泛问题 | 中 | 高 |
不要一开始追求一个万能比例。更实际的做法是准备一组真实问题,看不同策略下正确证据是否进入 Top K。
分词和清洗。
BM25、TF-IDF 对分词和清洗非常敏感。
需要特别保护:
ERR-5021create_paymenttimeout_mssettings.yamlPro Max2000 元、10 个工作日如果这些词被切碎,关键词检索会明显变差。
一个常见的预处理思路是:
原始文本
↓
统一大小写、全角半角、标点
↓
保护错误码、函数名、字段名
↓
中文分词
↓
停用词过滤
↓
进入 BM25 / TF-IDF
向量检索通常不需要显式分词,但它也需要稳定的文本清洗和文本切分策略。两路检索的预处理不一定完全相同,但都要可复现。
混合检索是否真的变好,不能只看几个例子。
可以准备一个小型评估集:
| 问题 | 正确证据 ID |
|---|---|
ERR-5021 怎么处理? |
support-api-err-5021 |
签名校验失败是什么原因? |
support-api-err-5030 |
退款最晚什么时候提交? |
support-refund-policy |
支付结果一直没回来怎么办? |
support-payment-delay |
出差回来多久内报销? |
support-travel-policy |
然后分别测试:
可以先看一个简单指标:
比如 20 个问题里,有 17 个问题的正确证据进入了前 5 条:
这个指标不完美,但它能回答一个基础问题:正确证据有没有被召回。
除此之外,还要人工看:
这一步很重要。因为混合检索不是一定更好。如果融合策略做得粗糙,它也可能把两路噪声一起带进来。
坑一:以为向量检索可以替代所有关键词检索。
向量检索很强,但它不擅长保证精确符号匹配。错误码、订单号、函数名、字段名、制度编号,仍然应该让关键词检索参与。
坑二:把 BM25 分数和向量距离直接相加。
两者尺度不同,方向也可能不同。BM25 通常越大越相关,向量数据库返回的可能是距离,越小越相关。融合前必须明确含义。
坑三:只看最终回答,不看检索证据。
大模型可能把不完整证据说得很顺。排查知识库问答质量时,要先看检索结果,再看生成答案。也就是说,先确认“资料有没有找对”,再判断“回答有没有写好”。
坑四:忽略中文分词。
BM25 和 TF-IDF 的效果高度依赖分词。如果“支付网关超时”被切得很差,检索质量会被直接拖垮。
坑五:混合检索后不去重。
同一个文本块被两路召回是好事,说明它既命中了关键词,也和查询语义接近。但给模型上下文时应该去重。重复内容会浪费 token,也会干扰模型判断。
坑六:把混合检索当成最终排序。
混合检索主要解决“多召回一些可能相关的候选”。它先让关键词检索和向量检索都参与,把更可能有用的资料放进候选集。
但候选集不等于最终答案列表。
如果候选较多,后面还可以继续做更细的排序、去冗余和多样性控制。这些属于“检索质量继续优化”的问题,不是这一篇必须一次讲完的内容。
TF-IDF、BM25 和向量检索不是三个互相替代的时代产物,而是三种不同的检索视角。
TF-IDF 解决的是:
哪些词在当前文档里重要,同时在全局里有区分度。
BM25 在这个基础上进一步处理:
词频不能无限加分,长文档也不能因为词多而天然占便宜。
向量检索解决的是:
查询和文档即使字面不同,只要语义接近,也应该有机会被召回。
在“先查资料、再让大模型回答”的知识库应用里,真实问题往往同时包含精确符号和自然语言表达。只用关键词检索会错过同义表达,只用向量检索又可能忽略错误码、编号和术语。
所以混合检索的核心不是“多用几个算法显得高级”,而是让系统同时具备两种能力:
到这里,这一篇可以先收住:它解决的是“为什么不能只靠一种检索方式,以及 BM25、TF-IDF 和向量检索为什么可以配合”。
继续往下,新的问题会变得更具体:候选资料已经找回来了,但谁应该排第一?两路排名怎么更稳定地合并?如果前几条内容高度重复,要不要保留这么多相似片段?
这些就是后面要继续展开的检索质量优化问题。
还没有公开评论
欢迎留下第一条想法,评论会在博主审核后显示。