用户搜"退货流程",返回的十条结果全部来自同一份《售后手册》的不同段落——内容都对,但用户仍然找不到"该点哪里提交"的答案。因为同一文档的多个片段挤占了全部名额,而答案可能在另一份《操作指引》里。
检索系统的默认行为是"按相关度排序取前 N",而它在"单文档多片段"的语料上会退化成"只返回一份文档"。这个区分的价值在于:调分数改的是"谁排第一",去重改的是"有多少不同来源能出现"。前者无论怎么调,都无法让被挤掉的文档进入前十。
解法不是调分数,而是在取结果前做去重与打散。
1. 三种"数量够但多样性不够"
| 现象 | 表现 |
|---|---|
| 单文档垄断 | 前 10 条来自 1 份文档 |
| 单章节垄断 | 前 10 条来自 1 个章节的相邻段落 |
| 单版本垄断 | 新旧两版制度都返回,且新版排在后面 |
第一项最常见,尤其在"长文档切分很细"的语料里:一份 50 页手册被切成 200 个片段,于是任何相关查询都会被它占满。
第三项在"制度更新"时出现:旧版片段因为历史点击多而分数更高,于是旧制度排在前面。第三项还有个更隐蔽的版本:两版内容高度相似,于是"旧版排在前面"而用户完全看不出差别——直到他按旧流程办事失败。
2. 四种打散手段
| 手段 | 效果 |
|---|---|
| 按文档去重(每文档最多 N 条) | 最直接 |
| MMR(最大边际相关性) | 兼顾相关与差异 |
| 按来源分组轮询 | 保证来源覆盖 |
| 分数加"重复惩罚" | 平滑,实现简单 |
第一项的上限值有个取舍:per_doc=1 会让"同一文档的多角度内容"只出现一段,而 per_doc=3 又可能让两份文档占满六个名额。常见折中是 2,且"允许在名额不足时放宽"。
第一项最实用:限制"每个文档最多 2–3 条",其余名额留给其它文档。它不需要改打分,只是在取 Top-K 时做约束。
第二项更优雅但成本高:每次选下一个结果时,同时考虑"与查询的相关度"与"与已选结果的相似度"。第二项的代价在"两两相似度":选 10 条要做几十次比较,而每次比较都要算向量相似度。所以在"召回几百条"的场景里,通常是"先粗排再对前 50 条做 MMR"。
它适合"摘要类"查询。
// 按文档去重的取数逻辑
def diversify(hits, per_doc=2, k=10):
seen, out = {}, []
for h in hits: # hits 已按分数降序
n = seen.get(h.doc_id, 0)
if n >= per_doc: continue
seen[h.doc_id] = n + 1
out.append(h)
if len(out) == k: break
return out
// 兜底:若去重后不足 k 条,再按分数补足(并标注"同文档")
3. 去重与"相邻片段"
| 问题 | 说明 |
|---|---|
| 相邻片段内容重叠 | 切分带 overlap 时 |
| 同一段落被切成两半 | 边界效应 |
| 重复文档(多份副本) | 语料导入问题 |
第一项是"切分重叠"的副作用:overlap 200 字的两个片段,前 200 字完全相同,于是"两条结果"实际是一条。
第三项的现象很迷惑:两条结果标题一样、内容一样,但 ID 不同,于是"看起来是重复 bug 但删不掉"(因为按 ID 删了两个中的一个,另一个还在)。
第三项在"多来源导入"时常见:同一份文件被导了两次(不同批次),于是索引里有两份。去重应当按"内容指纹"而不是"路径"。
// 三层去重的顺序(先粗后细)
1 内容指纹(文档级):同一文件只留一份
2 片段指纹(chunk 级):完全相同的片段丢弃
3 重叠检测(可选):相似度 > 0.9 的相邻片段合并展示
// 只做第 3 步会让"两份重复文档"仍然占满名额
4. 打散与"排序质量"的冲突
| 情形 | 是否该打散 |
|---|---|
| 查询很具体(精确短语) | 不该(准确优先) |
| 查询较泛("怎么退货") | 该(覆盖优先) |
| 只有一份文档相关 | 打散无意义 |
| 有多份文档相关 | 该 |
第一项的关键:当查询是"精确短语"时,用户要的就是那一段,打散反而让他多翻几页。第一项在"错误码/订单号"这类查询上尤其明显:返回十条不同文档的"包含该编号的片段",全是噪声——用户要的是唯一那一条。
所以"是否打散"应当由查询类型决定,而不是全局开关。
判据可以很简:Top-1 的分数显著高于 Top-2(如高 30% 以上)时,不打散;分数接近时,打散。
// 按分数分布决定是否打散
max1 = 0.92, max2 = 0.91, max3 = 0.90 → 分数接近,打散(可能有多份都相关)
max1 = 0.95, max2 = 0.62, max3 = 0.60 → Top-1 显著领先,不打散
"打散"与"重排"的先后顺序会影响结果:先重排再打散,能让"更相关的文档"获得名额;先打散再重排,则名额被原始分数决定,可能把不相关文档塞进来。
// 两种顺序的差别
先重排后打散:Top50(重排)→ 每文档取 2 → 前 10 ← 推荐
先打散后重排:Top50(原始分)→ 每文档取 2 → 重排 → 前 10
// 后者的问题:名额在"粗排分数"上分配,而粗排分数噪声大
5. 多版本与时效
| 手段 | 效果 |
|---|---|
| 版本号进排序 | 新版优先 |
| 时效衰减 | 旧文档降权 |
| 显式标注版本 | 用户自行判断 |
| 过滤已失效文档 | 最干净 |
第四项的前提是"有失效标记":文档需要 status 字段(active/superseded/expired),而这需要在入库时维护。没有这个字段,"过滤"无从下手。
第四项最彻底:把"已失效"的文档从索引里移出(或加过滤条件),而不是靠排序把它压下去。因为"压下去"仍会偶尔出现。
第三项在"制度类"知识库里很实用:结果里标注"2024 版",让用户知道自己在看哪一版——即使排序有偏差,用户也能判断。
监控上要能看出"垄断",否则这个问题不会自己暴露:统计"前十的独立文档数"与"单文档占比"。健康值通常在 3–8 之间;长期为 1,说明该做去重了。
// 两个观测指标(按查询批次聚合)
distinct_docs_top10:均值 1.2(异常,垄断)→ 3–6(健康)
top1_share:单一文档占前十的比例,> 0.8 需处理
// 用法:按查询类型分组看,而不是看整体均值
6. 排错:五种"结果都是同一份"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 前 10 条同一文档 | 未做文档级去重 | 加 per-doc 上限 |
| 两条结果内容几乎一样 | overlap 过大 | 减小 overlap 或合并 |
| 旧制度排在前面 | 无时效权重 | 加版本/时效 |
| 打散后质量下降 | 对精确查询也打散 | 按分数分布判断 |
| 去重后结果不足 10 条 | 语料太少 | 补内容或放宽 |
第五项提醒一件事:打散不能凭空造出结果。第五项还有一个"诚实"要求:当结果不足时应当说明"仅找到 N 条",而不是把不相关的内容补进来凑数。后者会让用户以为"系统觉得这些也相关",从而对系统整体失去信任。
如果语料里只有 2 份相关文档,去重后就是 4–6 条,此时应当"如实返回"而不是"补无关结果"。
最后一条"用户视角"的边界:多样性对不同用户价值不同。做研究的用户希望"一次看到所有相关来源",而做操作的员工希望"直接给我那一步"。所以同一个知识库,面向两类用户可能需要两套结果——而不是一个折中值。
// 两种模式的默认策略
研究模式:per_doc=3,打散开,展示来源与版本
操作模式:per_doc=1,不打散,直接给"该点哪里"
// 实现方式:按入口(知识库 / 帮助中心)区分,而不是让用户手动切
7. 边界:什么情况不必做打散
| 场景 | 为什么 | 做法 |
|---|---|---|
| 语料只有一份权威文档 | 无多样性可言 | 不去重 |
| 精确检索(编号、错误码) | 要的就是那一条 | 不打散 |
| 结果数量本来就少(< 5) | 打散无意义 | 不处理 |
| 用户明确"看某一文档" | 已限定范围 | 不处理 |
最后一条与"评测"有关的边界:多样性也要被评测,否则"前十条同一文档"永远发现不了。加一个指标"前十的独立文档数"(越大越好,但不必追高),就能持续观察。
判断口径一句话:用户的问题"能被一份文档完整回答"吗。能,多样性就是噪声;不能(需要多个来源拼起来),打散才必要。
8. 自测
- 三种"数量够但多样性不够"里,哪一种最常见?为什么"长文档切分细"会加剧它?
- 四种打散手段里,哪一种最实用、成本最低?MMR 适合什么场景?
- 为什么"按内容指纹去重"比"按路径去重"更可靠?三层去重的顺序为什么是"先粗后细"?
- 什么情况下不该打散?"Top-1 显著领先"这个判据的原理是什么?
- "过滤已失效文档"为什么比"排序压下去"更干净?
- 判断"是否需要打散"的那个问题是什么?