P-204 与 P-205 的售后条款只差产品号和期限,向量检索却可能把另一份排在前面。相关性计算没有识别出业务上最重要的那一位数字,这时再要求生成器“认真阅读”无法保证补救。
本篇继续使用同一个售后知识库,分别处理词面匹配、语义匹配和候选排序。每种方法回答的问题不同,理解它们的分工,才知道什么时候应该组合。
关键词检索不是逐篇查找字符串
倒排索引把词映射到包含它的文档及位置信息。查询 P-204 时,可以直接进入该词的倒排列表,不必重新读取全部文档。索引的词来自分析器,所以中文分词、大小写、连字符和编号标准化都会影响结果。
例如 P-204 若被拆成 p 和 204,而另一个系统保存成 p204,查询与文档的词项就无法直接匹配。可将规范化产品号存成独立字段,原始值仍保留用于展示;不能把编号标准化误用于任意自然语言。
手算 BM25 的三个因素
本篇用一种常见的 BM25 形式:对查询词 t 求和 IDF(t) × tf(t,d)(k₁+1) / [tf(t,d)+k₁(1-b+b|d|/avgdl)]。tf 是词在文档里的出现次数,|d| 是分析器得到的词项数,avgdl 是语料平均长度,k₁ 控制词频饱和,b 控制长度归一化。
用 IDF(t)=ln(1+(N-df(t)+0.5)/(df(t)+0.5)) 作示例,其中 N 是文档数,df 是包含该词的文档数。三篇文档里 P-204 只出现一篇,IDF≈0.981;运费出现两篇,IDF≈0.470。稀有词因此更有区分力,但不同 BM25 实现的 IDF 变体并不完全相同。信息检索教材解释了词频与长度归一化的思路。
当文档长度等于平均长度、k₁=1.2 时,tf 从 1、2 增到 3,词频因子依次为 1、1.375、约 1.571;收益逐渐变小。这可以避免机械重复一个词无限抬高分数。相同词频出现在更长文档中,长度归一化通常会降低其贡献,因为匹配只是较长内容的一部分。
下面从人工分好的词项计算分数,不声称实现了中文分析器。完整文件见 bm25_walkthrough.py下载。
"""用预先分词的中文教学语料实现一种 BM25 形式;不把空格切分当中文分词器。"""
from collections import Counter
import math
# 分词结果由作者给定;真实应用需要一致的中文分析器与编号规范化。
docs = {
"p204": ["P-204", "质量", "退货", "运费", "商家"],
"p205": ["P-205", "质量", "退货", "运费", "商家"],
"invoice": ["发票", "申请", "电子", "发票"],
}
query = ["P-204", "运费"]
N = len(docs)
avg_len = sum(map(len, docs.values())) / N
k1, b = 1.2, 0.75
scores = {}
for doc_id, tokens in docs.items():
frequencies = Counter(tokens)
score = 0.0
for term in dict.fromkeys(query):
df = sum(term in words for words in docs.values())
# 使用带 +1 的非负 IDF 形式;其他库的 BM25 变体可能不同。
idf = math.log(1 + (N - df + 0.5) / (df + 0.5))
tf = frequencies[term]
norm = tf + k1 * (1 - b + b * len(tokens) / avg_len)
score += idf * tf * (k1 + 1) / norm
scores[doc_id] = score
print(sorted(scores.items(), key=lambda pair: (-pair[1], pair[0])))
先看向量容易忽略的精确差异
新增一条“产品 P-204 电池退换规则”,用户恰好询问 P-204。向量检索可能返回 P-205,因为两份文本几乎一样,只差一个编号。语义模型常把相似话题放在一起,而编号的精确差异对业务却至关重要。
这种问题适合增加精确匹配或关键词检索。关键词方法依赖文本里出现了什么词;BM25 进一步考虑词频、文档频率和长度归一化。一个在全库很少出现的编号,往往比“商品”“售后”更有区分力。词频贡献通常会饱和,避免重复写十次关键词就无限提高得分。
中文的分词方式、连字符和大小写规范会影响匹配。不能简单认为搜索 P-204 就一定会命中保存为 P204 的文本。编号可以单独提取到元数据字段,通过标准化后的等值查询处理;原始值仍然保留,以便核验来源。
为了实际观察这个问题,配套的 retrieval_experiment.py下载沿用第一篇的真实句向量模型,加入两条虚构产品规则:P-204 的期限为三十天,P-205 为十五天。对同一组问题,先做全库向量排序,再按问题里明确出现的产品编号限定范围。历史版本记录的结果如下;本轮未重新运行。
| 问题 | 纯向量第一名 | 限定产品范围后的第一名 |
|---|---|---|
| P-204 电池签收十天,质量问题运费谁出 | P-204 | P-204 |
| P-205 电池签收二十天,质量问题运费谁出 | P-204 | P-205 |
第二条确实暴露了编号错配。限定范围后找到了 P-205,但它的规则只有十五天,因此仍不能据此承诺二十天可以免费退回。这个小实验验证了精确字段的作用,不是大型检索基准,也不是 BM25 的效果报告。脚本还比较了残缺结论与完整条款的真实相似度,帮助读者观察分数和条件完整性之间的区别。
向量适合弥补“邮费”和“运费”的表达差异,关键词适合保留稀有术语和精确编号,两者结合叫混合检索。它不是把一个分数随便加到另一个上:BM25 分数和余弦分数通常不在同一尺度,未经处理直接相加,权重可能没有你想象的含义。
一种容易理解的合并方法是 Reciprocal Rank Fusion,简称 RRF。它只看各路名次:RRF(d) = Σ 1 / (k + rank(d))。rank 从 1 开始,k 是平滑常数,未出现在某一路的文档对那一路贡献零。同一文档在两路都靠前,会得到较高总分。它省去了分数尺度对齐,但也丢掉了原始分数之间的差距信息。
下面用人工设置的两份名次演示 RRF。它没有执行向量编码,也不是 BM25 实现;它只说明候选怎样合并。完整代码见 fusion_demo.py下载。
# 教学名次:向量通道更偏向通用质量政策,精确通道把目标产品规则排第一。
# 列表内容是稳定文档 ID;真实系统从两个检索器取得这两份排序。
rankings = [
["policy-quality", "product-P204", "policy-normal"],
["product-P204", "policy-quality"],
]
k = 60 # 平滑常数;这里只演示算法,不能当成对所有知识库最优的值。
scores = {}
for ranking in rankings:
# 同一路出现重复 ID 时只保留首次,避免重复计分抬高文档名次。
for rank, doc_id in enumerate(dict.fromkeys(ranking), start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
# 同分时按 ID 排序,保证离线演示能够复现;生产中也要明确同分处理。
merged = sorted(scores, key=lambda doc_id: (-scores[doc_id], doc_id))
for doc_id in merged:
print(doc_id, round(scores[doc_id], 6))
这组输入会让两个主要候选同分,提醒我们融合并不保证唯一正确答案。若问题里明确指定 P-204,后续还应利用产品字段过滤或进一步判断。融合扩大了候选覆盖,不能取代适用范围约束。
正确证据已经召回,为什么还需要重排
假设第一阶段找回二十条资料,真正支持问题的条款位于第十八。直接取前五条交给模型会错过它,把二十条全部交过去又可能引入干扰。可以增加重排阶段,让一个更细致的模型比较“问题与候选是否真的相关”。
常见双编码器分别编码问题和文档,文档向量可提前计算,检索时只需要向量比较。交叉编码器把问题和一条候选一起输入,能在两段文本之间建立更细的相互作用,但每个候选都要重新计算,成本较高。因此常用先召回、再重排的两阶段方法。Sentence Transformers 的检索重排说明介绍了这种组合。
重排器只能重排已经拿到的候选。正确条款根本不在前二十时,换更强的重排器也看不到它。先确认召回覆盖,再调最终排序,能避免花时间修错阶段。
召回数量也不是越多越好。候选越多,重排的计算越多,最终上下文可能更长;如果新增结果大部分无关,就增加了成本而没有带来证据。用固定问题集比较“相关证据是否进入候选”和“最终选择是否正确”,才能找到适合当前资料规模的范围。
用真实交叉编码器观察候选重排
下面固定三条候选,以 BGE 重排模型计算问题与每条材料的联合分数。候选由作者给定,分数在执行时来自真实模型;本轮没有运行,也不承诺某一条一定排第一。模型用途和 Transformers 调用方式参照模型卡。
"""对固定候选执行真实交叉编码器重排;首次会下载权重,本轮未执行。"""
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
MODEL = "BAAI/bge-reranker-base"
question = "P-205 电池签收二十天,质量问题退货运费谁承担?"
# 这里把召回候选固定下来,只观察重排。真实系统用检索结果替换这个列表。
documents = [
("p204", "P-204 电池经核实质量问题,签收三十天内由商家承担退货运费。"),
("p205", "P-205 电池经核实质量问题,签收十五天内由商家承担退货运费。"),
("invoice", "订单完成后可申请电子发票。"),
]
tokenizer = AutoTokenizer.from_pretrained(MODEL)
model = AutoModelForSequenceClassification.from_pretrained(MODEL).eval()
pairs = [[question, text] for _, text in documents]
# 联合输入的问题与文档共享模型长度预算;显式拒绝过长内容,避免条件静默丢失。
for query, text in pairs:
if len(tokenizer(query, text, truncation=False)["input_ids"]) > 512:
raise ValueError("问题与候选超过本例长度预算,请先保留条件并重新组织")
inputs = tokenizer(pairs, padding=True, truncation=False, return_tensors="pt")
with torch.inference_mode():
# 每对问题/文档得到一个相关性 logit,不把它直接解释为事实正确概率。
scores = model(**inputs, return_dict=True).logits.view(-1).float().tolist()
ranked = sorted(zip(documents, scores), key=lambda item: (-item[1], item[0][0]))
for (doc_id, text), score in ranked:
print(doc_id, score, text)
# 即使 p205 排第一,二十天仍超出十五天;最终回答必须再判断适用条件。
完整文件见 rerank_real.py下载。先固定候选可以单独观察重排器,之后再接混合召回。检查结果时同时阅读原文:模型可以正确找出 P-205 的十五天规则,但不能因此把二十天解释成符合条件。
过滤、候选数量与重复内容怎样改变结果
假设先全库取前五条,再删除无权限候选,可能一条不剩,而授权范围里其实有答案。应让检索在允许的范围内找候选;具体数据库如何执行过滤与 ANN,需要对照实现。用户身份来自后端,模型不能通过改写查询来修改授权条件。
产品字段也不能不分青红皂白地过滤。如果通用质量政策没有 product_id=P-204,单纯只保留该产品字段可能把通用规则删掉。应表达“该产品专用或当前适用通用政策”的业务范围,并对精确字段的解析结果核验。
相同政策从网页、PDF 和导出文件各入库一次,前三名可能只有一条实质证据。可以在融合后按来源和版本去重,再选择更多互补内容。多样性选择不能覆盖适用性要求:为了不同来源而引入其他产品条款,并没有改善答案。
RRF 的平滑常数、每路 Top-k、重排候选数与最终阅读数量是不同参数。先扩大候选池可以降低召回遗漏,但重排工作量也增加;最后放进上下文的资料还受阅读预算影响。不要把所有阶段都用一个 k,从而不知道到底在哪层截掉了证据。
重排分数也不等于事实概率。四十天问题与三十天条款高度相关,恰好可能支持“本条款期限不满足”的结论,而非支持免费退货。下一篇先改善问题怎样表达成检索任务,第六篇再讨论证据如何支持回答。