检索与问答链:把该用的那块拿准

复核员变不出新卡片——召回没抱回来的那张纸,Rerank 再强也救不回来。

30″30 秒看懂检索与问答链

资料室里的活页已经切好、编好坐标了。现在轮到资料员干活:拿着题目跑进去,抱几张回来

他要当场做三个决定——抱几张?太远的要不要?谁摆最上面?抱少了,该用的那张没拿到;抱多了,一堆无关的纸塞进活页夹,考生反而被带偏。而且他抱回来的顺序不等于有用的顺序:真正能答题的那张,常常排在第三。

所以正经的做法是两道工序:资料员先粗抱一摞(快,宁可多拿),再让一位复核员把这摞重新排一遍、只留最上面几张(慢,但排得准)。

图① 资料员取页的五步
图① 资料员取页的五步
资料员的动作技术名做砸了会怎样
抱几张回来top-kk 太小漏掉答案,k 太大全是噪声,还更贵更慢
太远的扔掉score 阈值不设,库里没有的问题也会硬塞三张纸给考生
复核员重排Rerank不上,该用的那张常年排第三,进不了前二
按单号精确找关键词 / 混合检索只用向量,单号型号这类精确串找不准
摞好交给考生提示词拼装不编号就标不出出处,不圈范围模型就会混用先验知识
⛔ 这一讲的铁律 复核员变不出新卡片。召回阶段没抱回来的那张纸,Rerank 再强也救不回来——实测把召回收到 2 条时,该用的那块压根不在候选里,重排只是把两张错的重新排了一遍。所以配比永远是:召回放宽(20~50 条),重排收紧(留 3~5 条)。
这一讲和上一讲的分工 上一讲决定库里有没有能答全的块,这一讲决定能不能把它拿准。两件事必须分开量:库里没有,调 k 调到天亮也没用;库里有却排第三,那就跟切分一点关系都没有。第 05 节那张六层检查表,就是用来当场分清这两种情况的。

01概念:召回、排序、过滤是三件事

三个常被混着说的词,各自负责什么

1.1 召回、排序、过滤

「检索效果不好」这句话在工程上其实至少指三件不同的事,对应三个不同的旋钮:

环节回答的问题旋钮量它的指标
召回(recall)该用的那块在不在返回结果里k、检索路数命中率
排序(rank)排第几相似度算法、RerankMRR、Top1 准确率
过滤(filter)不相关的有没有被挡住score 阈值、元数据条件误答率、拒答率

把它们分开,调参才有意义:命中率不达标就加大 k 或加一路检索,排名不对就上 Rerank,该拒答却没拒就调阈值。混在一起调,改完效果变了也说不清是谁的功劳。

1.2 top-k:抱几张

k 是整个 RAG 里最常被调的一个数,它的取舍非常直白:

k 太小k 太大
该用的那块没取回来,答案缺内容无关内容挤进提示词,把模型带偏
拒答率异常高token 变多,更贵更慢
改提示词救不回来关键句夹在中间更容易被漏读
图② k 取多少、阈值卡多严
图② k 取多少、阈值卡多严

但「k 越大越保险」是个错觉。第 03 节的实测里,命中率在 k=3 时就到顶了(1.00),k 从 3 加到 8,命中率一点没涨,噪声比却从 0.67 涨到 0.88。再往上加的每一条,都只是在往活页夹里塞废纸。

1.3 score 阈值:太远的扔掉

top-k 有个天生的毛病——它永远返回 k 条,哪怕库里一条相关的都没有。挡住这种情况的是分数阈值:低于它的结果一律丢弃,全丢光就直接回复「资料中未提及」。

阈值带来一对此消彼长的错误,必须按业务偏一边:

阈值调高阈值调低该怎么偏
该答的漏答变多该挡的放过变多对外客服 / 法务 / 医疗:宁可漏答,阈值往高调
拒答率上升编造风险上升内部检索助手:宁可多给几条让人自己挑,往低调
阈值不能抄别人的数 分数分布是跟着 Embedding 模型、切分参数、相似度算法一起走的。换了其中任何一个,整条分布都会平移,旧阈值瞬间失效。所以正确做法不是「设成 0.3」,而是在自己的库上量一次分布再定——第 04 节有完整量法。

1.4 召回与重排:两道工序,不是两个方案

这是新手最容易误解的一处:Rerank 不是「更好的检索」,它是接在检索后面的第二道工序。两者的目标函数完全不同:

图③ 召回与重排是两件事
图③ 召回与重排是两件事
对比① 召回(向量检索)② 重排(Rerank 模型)
输入问题 + 整个库(十万级)问题 + 召回回来的 20~50 条
怎么算问题和文档各自编码,再比向量问题和文档拼在一起过一遍模型
快慢很快,可预先建索引慢,每条都要过一次模型
追求不漏排得准
能不能新增结果不能

最后一行就是本讲的铁律。Rerank 之所以排得更准,是因为它让问题和文档在同一次前向里互相看见(交叉编码),而向量检索为了能预先建索引,只能分开编码——这是速度换来的精度损失,也正是需要第二道工序的原因。

1.5 混合检索:语义找不准精确串

还有一类问题向量检索天生做不好:单号、型号、人名、条款编号。因为这些串的「意思」几乎为零,真正区分它们的是字符本身。

实测问「ABC123789 这单现在在哪」,向量检索把三条句式雷同的运单排在一起,正确那条只排第二;关键词检索一击即中。反过来问「货从哪个城市发出」,关键词检索返回空

所以工程上默认两路都开 向量检索负责「换个说法也能找到」,关键词检索负责「精确串一击即中」,再用 RRF(倒数排名融合)把两个名次合成一个。两路各有盲区,融合的收益是实打实的——第 04 节有可运行的实现。

02原理:一次提问在管线里走完的五步

从问题文本到一段拼好的提示词,中间每一步改了什么

2.1 五步管线

步骤输入输出要留意
① 问题向量化问题文本一个向量必须用建库那个模型
② 量距离问题向量 + 库每块一个分数metric 要和建库时一致
③ 取 top-k全部分数k 条候选召回阶段的 k 要放宽
④ 过阈值k 条候选≤ k 条可能一条都不剩,这是正常路径
⑤ 重排 + 拼装剩下的候选最终提示词去重、编号、带来源、卡预算

注意第 ④ 步的「一条都不剩」:这不是异常,是系统在正确工作——库里真的没有答案时,就该什么都不返回。代码里必须把这条路径当成一等公民处理,而不是让它掉进「拿空 context 去问模型」的分支。

2.2 一个问题最多只算一次向量

在线阶段的耗时构成大致是这样的(本机不跑真实向量库,以下为典型量级参考):

环节量级能不能省
问题向量化毫秒级(本地)/ 百毫秒级(API)高频重复问题可以缓存
向量检索毫秒级(有索引)已经很快
Rerank百毫秒级,随候选数线性增长候选数是主要成本,别放 200 条
模型生成秒级占大头,和 context 长度正相关

结论很实用:省 token 就是省时间。k 从 3 调到 8,context 从 86 字涨到 217 字,生成这一步的耗时和费用跟着涨,而命中率一点没变。

2.3 提示词是怎么被拼出来的

第 ⑤ 步不只是把原文接起来,它有四个必须做的动作,顺序不能乱:

  1. 去重:同一段话可能在两份文档里都有,正文相同就合并,把来源并在一起。
  2. 编号:给每块加 [1] [2],模型才有东西可引用。
  3. 带来源:把文件名和页码写进 context,让答案能被回头核对。
  4. 卡预算:按字数或 token 数截断,装不下的整条丢掉,不要截半条
截半条比丢整条更糟 截断发生在块的中间时,模型看到的是半句话——它读不出这是被截断的,只会当成完整信息去理解。预算不够就整条丢,并且记下丢了几条,这是能在日志里查的;截半条则完全不可见。

2.4 拼装的三条规则各自挡掉什么

system 里的规则不写会怎样挡掉的事故
只使用【已知信息】里出现过的内容模型混用自己的先验知识答案里有一半说不清依据
不足以回答时回复「资料中未提及」检索为空时开始编一本正经的假答案
每个结论后标出所依据的编号想标也无从标起无法验收、无法追责

第三条再强调一次:它是否生效,取决于 context 里有没有编号。规则和拼装必须成对存在,只写规则不编号,模型只能瞎编页码。

03把 k 和阈值量出来

两段可运行的实验:k 加到几就到顶,阈值该卡在哪

3.1 k 从 1 试到 8

实验用一个 8 块的小知识库和 4 条带标准答案的测试题,把 k 从 1 扫到 8,同时量三件事:命中率(该用的那块在不在返回结果里)、噪声比(返回结果里无关的占多少)、平均上下文长度(要付的 token)。

topk_lab.py —— k 的扫描实验可运行
"""k 取多少:把「取几张卡片」这件事量出来,而不是拍脑袋。

三个指标一起看才有意义:
  命中率   —— 该用的那块在不在取回来的 k 张里(k 越大越高)
  噪声比   —— 取回来的 k 张里有多少是无关的(k 越大越差)
  上下文量 —— 拼进提示词的字数(k 越大越贵)
k 的取值就是在这三条曲线上找一个拐点。

纯标准库,直接运行。
"""
import math
import re
from collections import Counter

CHUNKS = [
    "货物编号 ABC123456,发货日期 2023-01-15,当前位置上海分拨中心。",
    "该批货物预计到达日期为 2023-01-20。",
    "承运方快运通,陆运,出发地广州,目的地重庆,预计运输时间三天。",
    "东方仓储中心位于深圳市,存储电子产品,常温仓储,库存一千件。",
    "速达物流总部在北京市,业务范围为国际快递与仓储管理。",
    "客服热线工作时间为每日 9:00 至 21:00。",
    "国际件报关所需材料包括发票、装箱单与合同。",
    "冷链货物需全程温控,交接时须记录温度。",
]

# 测试集:问题 → 必须命中的块下标(人工标注,这一步没有捷径)
TESTSET = [
    ("这批货从哪发出,几天能到?", {2}),
    ("货现在到哪了?", {0}),
    ("深圳仓库存的是什么?", {3}),
    ("预计什么时候送到?", {1}),
]


def tokenize(t):
    return re.findall(r"[a-z0-9]+", t.lower()) + re.findall(r"[\u4e00-\u9fff]", t)


def embed(t):
    return Counter(tokenize(t))


def cosine(a, b):
    common = set(a) & set(b)
    dot = sum(a[k] * b[k] for k in common)
    na = math.sqrt(sum(v * v for v in a.values()))
    nb = math.sqrt(sum(v * v for v in b.values()))
    return dot / (na * nb) if na and nb else 0.0


INDEX = [(i, c, embed(c)) for i, c in enumerate(CHUNKS)]


def search(question, k):
    qv = embed(question)
    scored = [(cosine(qv, v), i, c) for i, c, v in INDEX]
    scored.sort(key=lambda x: x[0], reverse=True)
    return scored[:k]


def main():
    print("知识库 %d 块,测试集 %d\n" % (len(CHUNKS), len(TESTSET)))
    print("%-4s %-10s %-10s %-14s %s" % ("k", "命中率", "噪声比", "平均上下文", "说明"))
    print("-" * 64)

    rows = []
    for k in range(1, len(CHUNKS) + 1):
        hit = 0
        noise_num = noise_den = 0
        ctx_chars = 0
        for question, gold in TESTSET:
            got = search(question, k)
            idxs = {i for _s, i, _c in got}
            if gold & idxs:
                hit += 1
            noise_num += len(idxs - gold)
            noise_den += len(idxs)
            ctx_chars += sum(len(c) for _s, _i, c in got)
        rate = hit / len(TESTSET)
        noise = noise_num / noise_den
        avg_ctx = ctx_chars / len(TESTSET)
        rows.append((k, rate, noise, avg_ctx))
        note = ""
        if k > 1 and rows[-2][1] == rate:
            note = "命中率没涨,只是在加噪声"
        print("%-4d %-10.2f %-10.2f %-14.1f %s" % (k, rate, noise, avg_ctx, note))

    print()
    # 找到第一个命中率达到最高的 k:再往上加只剩坏处
    best = max(r[1] for r in rows)
    knee = next(k for k, rate, _n, _c in rows if rate >= best)
    print("命中率在 k=%d 时已经到顶(%.2f),再往上加 k:" % (knee, best))
    after = rows[knee - 1]
    last = rows[-1]
    print("  噪声比 %.2f%.2f,平均上下文 %.0f 字 → %.0f 字,命中率一点没涨。"
          % (after[2], last[2], after[3], last[3]))
    print("  所以这个库上 k 取 %d 就够,不是越大越好。" % knee)

    print()
    print("=" * 64)
    print("单看一条问题,k 从 1 加到 4 都取回了什么")
    print("=" * 64)
    question, gold = TESTSET[0]
    print("问题:%s(标准答案是第 %s 块)\n" % (question, "、".join(str(i) for i in sorted(gold))))
    for score, i, c in search(question, 4):
        mark = "✔ 该用的" if i in gold else "✘ 噪声  "
        print("  %s  %.4f  [%d] %s" % (mark, score, i, c))
    print()
    print("注意第一条:分数最高的不一定是该用的那一条。")
    print("命中率要看的是「在不在这 k 条里」,而不是「是不是排第一」——")
    print("这也正是后面要上 Rerank 的理由。")


if __name__ == "__main__":
    main()
k命中率噪声比平均上下文判断
10.500.5024.8一半问题答不了
20.750.6260.0还在涨
31.000.6786.5到顶
41.000.75113.5只在加噪声
51.000.80142.2只在加噪声
81.000.88217.0只在加噪声

从 k=3 到 k=8:命中率一点没涨,噪声比从 0.67 涨到 0.88,上下文从 86 字涨到 217 字。多付的每一分钱都花在往活页夹里塞废纸上,还顺带增加了模型漏读关键句的风险。

k 的实用定法 从小往大扫,找命中率第一次到顶的那个 k,就停在那。再往上的每一档都只有代价没有收益。注意这个「3」是这个库上的结论,不是普适值——库变了、切分变了,都要重扫一次。真实项目里这条曲线通常在 3~5 之间拐弯。

3.2 分数最高的不一定是该用的

同一段实验还打印了单条问题的取回明细。问「这批货从哪发出,几天能到?」(标准答案是第 2 块):

判定分数内容
✘ 噪声0.2417该批货物预计到达日期为 2023-01-20。
✘ 噪声0.1846货物编号 ABC123456,发货日期 2023-01-15,当前位置上海分拨中心。
✔ 该用的0.1430承运方快运通,陆运,出发地广州,目的地重庆,预计运输时间三天。
✘ 噪声0.0692国际件报关所需材料包括发票、装箱单与合同。

该用的那块排第三。所以命中率量的是「在不在这 k 条里」,而不是「是不是排第一」——这两个指标要分开看,前者不达标就加 k,后者不达标才上 Rerank

3.3 阈值:先看分布,再定数

定阈值有一个很容易出错的顺序:直接拍一个 0.3 然后上线。正确做法是先拿两类问题各问一遍,把分数分布打出来——库里有答案的和库里没答案的。

threshold_lab.py —— 分数分布与阈值扫描可运行
"""分数阈值:怎么让系统学会说「资料里没有」。

top-k 有个天生的毛病——它永远返回 k 条,哪怕库里一条相关的都没有。
问一个知识库根本覆盖不到的问题,照样会拿回 k 条最不相干的东西,
模型看到这些东西,就很容易顺着它们编。

阈值就是补这个洞的:低于某个相似度,宁可空手回来。
但阈值不能拍脑袋定,要看分数分布。

纯标准库,直接运行。
"""
import math
import re
from collections import Counter

CHUNKS = [
    "货物编号 ABC123456,发货日期 2023-01-15,当前位置上海分拨中心。",
    "该批货物预计到达日期为 2023-01-20。",
    "承运方快运通,陆运,出发地广州,目的地重庆,预计运输时间三天。",
    "东方仓储中心位于深圳市,存储电子产品,常温仓储,库存一千件。",
    "速达物流总部在北京市,业务范围为国际快递与仓储管理。",
    "客服热线工作时间为每日 9:00 至 21:00。",
]

# 库里答得出来的问题
IN_SCOPE = [
    "货现在到哪了?",
    "这批货几天能到?",
    "深圳仓库存的是什么?",
    "客服几点下班?",
]
# 库里根本没有的问题——这些必须被挡下来
OUT_SCOPE = [
    "公司年假怎么休?",
    "报销流程是什么?",
    "今天上海天气如何?",
]


def tokenize(t):
    return re.findall(r"[a-z0-9]+", t.lower()) + re.findall(r"[\u4e00-\u9fff]", t)


def embed(t):
    return Counter(tokenize(t))


def cosine(a, b):
    common = set(a) & set(b)
    dot = sum(a[k] * b[k] for k in common)
    na = math.sqrt(sum(v * v for v in a.values()))
    nb = math.sqrt(sum(v * v for v in b.values()))
    return dot / (na * nb) if na and nb else 0.0


INDEX = [(c, embed(c)) for c in CHUNKS]


def top_score(question):
    qv = embed(question)
    return max(cosine(qv, v) for _c, v in INDEX)


def main():
    print("=== 第一步:先看分数分布,别急着定阈值 ===\n")
    print("%-22s %-10s %s" % ("问题", "最高分", "类别"))
    print("-" * 52)
    in_scores, out_scores = [], []
    for q in IN_SCOPE:
        s = top_score(q)
        in_scores.append(s)
        print("%-22s %-10.4f %s" % (q, s, "库里有"))
    for q in OUT_SCOPE:
        s = top_score(q)
        out_scores.append(s)
        print("%-22s %-10.4f %s" % (q, s, "库里没有"))

    print()
    print("库里有   的最低分:%.4f" % min(in_scores))
    print("库里没有 的最高分:%.4f" % max(out_scores))
    gap = min(in_scores) - max(out_scores)
    if gap > 0:
        print("两类之间还有 %.4f 的空隙 → 阈值可以卡在这个空隙里。" % gap)
    else:
        print("两类分数重叠了 %.4f → 单靠阈值分不开," % -gap)
        print("要么先改进切分和向量化,要么加一层判断,不要指望一个数字解决问题。")

    print()
    print("=== 第二步:扫一遍阈值,看两类错误怎么此消彼长 ===\n")
    print("%-8s %-14s %-14s %s" % ("阈值", "该答的漏答", "该挡的放过", "总错误"))
    print("-" * 52)
    best = None
    for i in range(0, 41):
        th = i / 100
        miss = sum(1 for s in in_scores if s < th)      # 该答的被挡了
        leak = sum(1 for s in out_scores if s >= th)    # 该挡的放过去了
        total = miss + leak
        if best is None or total < best[1]:
            best = (th, total, miss, leak)
        if i % 4 == 0:
            print("%-8.2f %-14d %-14d %d" % (th, miss, leak, total))

    print()
    print("总错误最少的阈值:%.2f(漏答 %d,放过 %d)" % (best[0], best[2], best[3]))
    print()
    print("两类错误的代价并不对等,要按业务来偏:")
    print("  · 对外客服、法务、医疗 → 宁可漏答也不能瞎答,阈值往高调。")
    print("  · 内部检索助手         → 宁可多给几条让人自己挑,阈值往低调。")
    print()
    print("⚠️ 阈值是跟着 Embedding 模型走的。换了模型,分数分布整个平移,")
    print("   旧阈值会瞬间失效——所以换模型之后这张表必须重跑一遍。")


if __name__ == "__main__":
    main()
问题最高分类别
深圳仓库存的是什么?0.3889库里有
这批货几天能到?0.3030库里有
货现在到哪了?0.2182库里有
客服几点下班?0.1925库里有(最低
今天上海天气如何?0.1291库里没有(最高
报销流程是什么?0.0772库里没有
公司年假怎么休?0.0000库里没有

两类之间留出了 0.1291 ~ 0.1925 的空隙,宽 0.0634。阈值就卡在这个空隙里,两类错误同时为零。扫描验证:

阈值该答的漏答该挡的放过总错误
0.00033
0.08011
0.16000
0.20101
0.32303
⛔ 空隙越窄,越说明前面有问题 这个库上空隙有 0.0634,所以阈值好定。真实项目里如果两类分数重叠、根本找不出空隙,那不是阈值的问题——说明切分太碎(块自身读不通)或者 Embedding 选错了语言。回去改前面,别在阈值上硬凑。
换模型之后这张表必须重跑 分数分布跟着 Embedding 模型走。换了模型,整条分布平移,旧阈值瞬间失效——而且失效的方式是静默的:要么开始大面积拒答,要么开始大面积放过。把这张表的生成脚本留在项目里,换模型时重跑一次。

04完整案例:两道工序 + 两路检索 + 一次拼装

把「排第三」的那块救到第一,把单号查询救回来,再把结果拼成提示词

4.1 重排:把该用的那块提上来

接着第 03 节那个例子——该用的第 2 块排在第三名。加一道重排工序试试:

rerank_demo.py —— 召回 5 条,重排后取前 2可运行
"""召回与重排是两件事:一个负责别漏,一个负责排对。

向量检索是「拿问题的向量去跟每块的向量比一次」,两边各自编码、互不照面,
所以它快得能扫百万条,但也粗。重排是「把问题和这一块拼在一起再读一遍」,
读得细,但慢得多,只能用在已经缩小到几十条的候选上。

这里用一个可解释的打分函数代替真正的 Rerank 模型:
它会看问题里的关键词在这一块里是否成对出现、位置是否接近。
真实项目换成 Rerank 模型,主流程一行都不用改。

纯标准库,直接运行。
"""
import math
import re
from collections import Counter

CHUNKS = [
    "货物编号 ABC123456,发货日期 2023-01-15,当前位置上海分拨中心。",
    "该批货物预计到达日期为 2023-01-20。",
    "承运方快运通,陆运,出发地广州,目的地重庆,预计运输时间三天。",
    "东方仓储中心位于深圳市,存储电子产品,常温仓储,库存一千件。",
    "速达物流总部在北京市,业务范围为国际快递与仓储管理。",
    "客服热线工作时间为每日 9:00 至 21:00。",
    "国际件报关所需材料包括发票、装箱单与合同。",
    "冷链货物需全程温控,交接时须记录温度。",
]

QUESTION = "这批货从哪发出,几天能到?"
GOLD = 2  # 人工标注:只有第 2 块能答全


def tokenize(t):
    return re.findall(r"[a-z0-9]+", t.lower()) + re.findall(r"[\u4e00-\u9fff]", t)


def embed(t):
    return Counter(tokenize(t))


def cosine(a, b):
    common = set(a) & set(b)
    dot = sum(a[k] * b[k] for k in common)
    na = math.sqrt(sum(v * v for v in a.values()))
    nb = math.sqrt(sum(v * v for v in b.values()))
    return dot / (na * nb) if na and nb else 0.0


INDEX = [(i, c, embed(c)) for i, c in enumerate(CHUNKS)]

# 问题里真正承载意图的词组。真实 Rerank 模型是学出来的,
# 这里手写出来是为了让「重排凭什么改排序」看得见。
INTENT_GROUPS = [
    ["出发", "发出", "起运", "出发地", "从哪"],
    ["几天", "时长", "运输时间", "多久"],
]


def recall(question, k):
    """第一段:向量召回,粗选 k 条。"""
    qv = embed(question)
    scored = [(cosine(qv, v), i, c) for i, c, v in INDEX]
    scored.sort(key=lambda x: x[0], reverse=True)
    return scored[:k]


def rerank_score(question, chunk):
    """第二段:把问题和这一块放在一起读,按「意图组覆盖数」打分。

    关键差别:召回是逐词比对,一块里凑够几个高频字就能拿分;
    重排要求每一组意图都在这一块里落地,凑字数没用。
    """
    covered = 0
    for group in INTENT_GROUPS:
        if any(w in chunk for w in group):
            covered += 1
    # 覆盖到的意图组越多越好;全覆盖时额外奖励,因为它能独立答全
    score = covered / len(INTENT_GROUPS)
    if covered == len(INTENT_GROUPS):
        score += 0.5
    return score


def main():
    print("问题:%s" % QUESTION)
    print("标准答案:第 %d 块「%s\n" % (GOLD, CHUNKS[GOLD]))

    k = 5
    cands = recall(QUESTION, k)
    print("=" * 66)
    print("① 召回:向量检索粗选 %d 条(快,但排序粗)" % k)
    print("=" * 66)
    for rank, (score, i, c) in enumerate(cands, 1):
        mark = "← 该用的" if i == GOLD else ""
        print("  第%d%.4f  [%d] %s %s" % (rank, score, i, c[:30], mark))
    gold_rank = next(r for r, (_s, i, _c) in enumerate(cands, 1) if i == GOLD)
    print("\n  该用的那一块排在第 %d 名。如果直接取 top-2,它根本进不了提示词。" % gold_rank)

    print()
    print("=" * 66)
    print("② 重排:只对这 %d 条重新打分排序(慢,但排得准)" % k)
    print("=" * 66)
    reranked = [(rerank_score(QUESTION, c), old_score, i, c) for old_score, i, c in cands]
    reranked.sort(key=lambda x: x[0], reverse=True)
    print("  %-6s %-10s %-10s %s" % ("新名次", "重排分", "原召回分", "内容"))
    for rank, (rs, os_, i, c) in enumerate(reranked, 1):
        mark = "← 该用的" if i == GOLD else ""
        print("  %-6d %-10.3f %-10.4f [%d] %s %s" % (rank, rs, os_, i, c[:26], mark))
    new_rank = next(r for r, (_rs, _os, i, _c) in enumerate(reranked, 1) if i == GOLD)
    print("\n  该用的那一块从第 %d 名升到第 %d 名。现在取 top-2 就能拿到它。"
          % (gold_rank, new_rank))

    print()
    print("=" * 66)
    print("③ 重排救不回来的情况:召回阶段就没取到它")
    print("=" * 66)
    tight = recall(QUESTION, 2)
    print("  召回只取 2 条时,候选是:")
    for score, i, c in tight:
        print("    %.4f  [%d] %s" % (score, i, c[:30]))
    got = {i for _s, i, _c in tight}
    print("  该用的第 %d%s在候选里。" % (GOLD, "" if GOLD in got else "不"))
    if GOLD not in got:
        print("  重排只能对这 2 条重新排序,第 %d 块压根没进来 —— 它变不出新卡片。" % GOLD)
        print("  所以工程上的固定配比是:召回放宽(20~50 条),重排收紧(留 3~5 条)。")


if __name__ == "__main__":
    main()
召回名次召回分重排后名次重排分内容
10.241720.000该批货物预计到达日期为 2023-01-20。
20.184630.000货物编号 ABC123456,发货日期 2023-01-15…
30.143011.500承运方快运通,陆运,出发地广州,目的地重庆,预计运输时间三天
40.069240.000国际件报关所需材料包括发票、装箱单与合同。

第 3 名升到第 1 名。现在取 top-2 就能拿到它,而重排之前取 top-2 会完全错过。

脚本里的重排打分是一个教学用的简化实现(按问题里的关键意图逐项核对,命中就加分),真实项目用的是 cross-encoder 类的 Rerank 模型——但两者的机制是同一件事:让问题和文档在同一次打分里互相看见,而不是各自编码后比坐标。

4.2 重排救不回来的那种情况

脚本第三段故意把召回收紧到 2 条:

召回 2 条的候选结果
0.2417 该批货物预计到达日期为 2023-01-20。不是该用的
0.1846 货物编号 ABC123456,发货日期 2023-01-15…不是该用的

该用的第 2 块压根不在候选里。重排只能对进来的这 2 条重新排序——它变不出新卡片。

⛔ 固定配比:召回放宽,重排收紧 召回 20~50 条,重排后留 3~5 条。召回阶段的 k 大一点几乎没有代价(向量检索很快,多取几条是毫秒级的事),而重排阶段每多一条都要过一次模型。该省的是重排的条数,不是召回的条数。

4.3 混合检索:把单号查询救回来

问「ABC123789 这单现在在哪?」——库里有三条句式几乎一模一样的运单,只有单号不同。

hybrid_search.py —— 向量 + 关键词,用 RRF 融合名次可运行
"""混合检索:向量找得到意思,关键词找得到编号。

向量检索有一类天生的短板:单号、型号、法条编号、人名这种
「字面必须一模一样」的东西,它给不出可靠的区分度——
因为这些字符串在语义空间里彼此非常接近。

解法不是换更大的 Embedding,而是再加一路关键词检索,
两路结果按名次融合。这就是工程上说的 hybrid search。

融合用 RRF(Reciprocal Rank Fusion):只看名次不看分数,
因为两路的分数量纲根本不可比。

纯标准库,直接运行。
"""
import math
import re
from collections import Counter

CHUNKS = [
    "货物编号 ABC123456,发货日期 2023-01-15,当前位置上海分拨中心。",
    "货物编号 ABC123789,发货日期 2023-01-16,当前位置广州分拨中心。",
    "货物编号 XYZ987654,发货日期 2023-01-17,当前位置成都分拨中心。",
    "承运方快运通,陆运,出发地广州,目的地重庆,预计运输时间三天。",
    "东方仓储中心位于深圳市,存储电子产品,常温仓储,库存一千件。",
    "所有分拨中心均在每日 22:00 前完成当日件的装车发运。",
]

QUESTION = "ABC123789 这单现在在哪?"
GOLD = 1


def tokenize(t):
    return re.findall(r"[a-z0-9]+", t.lower()) + re.findall(r"[\u4e00-\u9fff]", t)


def embed(t):
    return Counter(tokenize(t))


def cosine(a, b):
    common = set(a) & set(b)
    dot = sum(a[k] * b[k] for k in common)
    na = math.sqrt(sum(v * v for v in a.values()))
    nb = math.sqrt(sum(v * v for v in b.values()))
    return dot / (na * nb) if na and nb else 0.0


INDEX = [(i, c, embed(c)) for i, c in enumerate(CHUNKS)]


def vector_search(q, k):
    qv = embed(q)
    scored = [(cosine(qv, v), i) for i, _c, v in INDEX]
    scored.sort(key=lambda x: x[0], reverse=True)
    return [i for _s, i in scored[:k]]


def keyword_search(q, k):
    """关键词检索:只看「问题里的实词有没有原样出现在这一块里」。

    这里刻意只取长度 ≥ 2 的字母数字串和二字以上中文串,
    单个汉字区分度太低,放进来会把关键词检索也变成词频游戏。
    """
    terms = [t for t in re.findall(r"[a-z0-9]{2,}", q.lower())]
    terms += re.findall(r"[\u4e00-\u9fff]{2,}", q)
    scored = []
    for i, c, _v in INDEX:
        low = c.lower()
        hit = sum(1 for t in terms if t in low)
        if hit:
            scored.append((hit, i))
    scored.sort(key=lambda x: x[0], reverse=True)
    return [i for _s, i in scored[:k]]


def rrf(rank_lists, k_const=60):
    """RRF 融合:每条结果的得分 = Σ 1/(k_const + 名次)。

    只吃名次,不吃分数——所以余弦分和关键词命中数不需要归一化,
    这正是它在工程上被广泛采用的原因。
    """
    score = {}
    for lst in rank_lists:
        for rank, idx in enumerate(lst, 1):
            score[idx] = score.get(idx, 0.0) + 1.0 / (k_const + rank)
    return sorted(score.items(), key=lambda kv: kv[1], reverse=True)


def show(name, idxs):
    print("%s:" % name)
    if not idxs:
        print("    (空)")
    for rank, i in enumerate(idxs, 1):
        mark = " ← 该用的" if i == GOLD else ""
        print("    第%d名 [%d] %s%s" % (rank, i, CHUNKS[i][:32], mark))


def main():
    print("问题:%s" % QUESTION)
    print("标准答案:第 %d\n" % GOLD)

    v = vector_search(QUESTION, 3)
    k = keyword_search(QUESTION, 3)
    show("① 只用向量检索", v)
    print()
    show("② 只用关键词检索", k)

    print()
    print("向量检索把三条运单排在一起,因为它们的句式几乎一模一样——")
    print("真正区分它们的是单号那串字符,而单号恰恰是语义模型最不擅长的东西。")
    print("关键词检索正好相反:它对单号一击即中,但换个说法就全线扑空。")

    print()
    print("③ RRF 融合两路结果:")
    fused = rrf([v, k])
    for rank, (i, s) in enumerate(fused, 1):
        mark = " ← 该用的" if i == GOLD else ""
        print("    第%d%.5f [%d] %s%s" % (rank, s, i, CHUNKS[i][:32], mark))
    top1 = fused[0][0]
    print()
    print("融合后排第一的是第 %d 块,%s" % (top1, "正确。" if top1 == GOLD else "仍需调权重。"))

    print()
    print("=" * 62)
    print("反过来验一次:换一个纯语义的问题,关键词检索会失灵")
    print("=" * 62)
    q2 = "货从哪个城市发出,路上要多久?"
    v2, k2 = vector_search(q2, 3), keyword_search(q2, 3)
    print("问题:%s" % q2)
    show("  向量检索", v2)
    show("  关键词检索", k2)
    print()
    print("结论:两路各有各的盲区,所以工程上默认两路都开、用名次融合,")
    print("      而不是二选一。")


if __name__ == "__main__":
    main()
检索方式该用的那块排第几问题出在哪
只用向量第 2三条运单句式雷同,区分它们的是单号字符,而这正是语义模型的弱项
只用关键词第 1(一击即中)换个说法就全线扑空
RRF 融合第 1(0.03252)

反过来验一次,问「货从哪个城市发出,路上要多久?」这种纯语义问题:关键词检索返回空,向量检索照常工作。两路各有各的盲区——

RRF 为什么用名次而不用分数 向量分数和关键词分数不在同一个刻度上,直接加权相加需要先归一化,而归一化参数又随数据漂移。RRF 只用名次1/(k+rank) 再相加,绕开了刻度问题,参数只有一个常数,工程上省心得多。这也是它成为默认融合方式的原因。

4.4 拼装:去重、编号、带来源、卡预算

检索结果拿到之后,还不能直接丢给模型。下面这段把四个动作都做了:

prompt_builder.py —— 从检索结果到最终 messages可运行
"""把检索结果拼成提示词:五条必须做到的事,逐条演示。

1. 每块带编号和来源,答案才能标出处
2. 明确圈定「只用已知信息」,并给出兜底话术
3. 控制总长度,超了要截断而不是硬塞
4. 去重,同一段话被切进两块时不要重复两遍
5. 空结果要走另一条分支,不能拿空 context 去问模型

纯标准库,直接运行。
"""

# 检索回来的结果:(分数, 来源, 正文)
HITS = [
    (0.81, "物流信息.pdf#p1", "承运方快运通,陆运,出发地广州,目的地重庆,预计运输时间三天。"),
    (0.74, "物流信息.pdf#p1", "货物编号 ABC123456,发货日期 2023-01-15,当前位置上海分拨中心。"),
    (0.71, "线路手册.docx#p3", "承运方快运通,陆运,出发地广州,目的地重庆,预计运输时间三天。"),
    (0.42, "仓储制度.md#p2", "东方仓储中心位于深圳市,存储电子产品,常温仓储,库存一千件。"),
]

QUESTION = "这批货从哪发出,几天能到?"

SYSTEM = """你是物流信息查询助手。

规则:
1. 只使用【已知信息】里出现过的内容作答,不得补充任何外部知识。
2. 【已知信息】不足以回答时,回复「资料中未提及」,不要推测。
3. 每个结论后面用方括号标出所依据的编号,例如 [1]。
4. 用户问的是多个问题时,逐条作答。"""

USER = """【已知信息】
{context}

【问题】
{question}"""


def dedup(hits):
    """按正文去重,保留分数最高的那条,并把其它来源并进去。

    同一段话出现在两份文档里是很常见的(制度被复制到多个手册)。
    不去重的后果:提示词里同一句话出现两遍,既浪费 token,
    又会让模型误以为这件事被「反复强调」,从而过度加权。
    """
    seen = {}
    for score, source, text in sorted(hits, key=lambda x: x[0], reverse=True):
        key = text.strip()
        if key in seen:
            seen[key]["sources"].append(source)
        else:
            seen[key] = {"score": score, "text": key, "sources": [source]}
    return list(seen.values())


def build_context(items, budget_chars):
    """按分数从高到低装,装不下就停,不截断句子中间。"""
    lines, used, dropped = [], 0, 0
    for i, item in enumerate(items, 1):
        line = "[%d] (来源:%s%s" % (i, "、".join(item["sources"]), item["text"])
        if used + len(line) > budget_chars:
            dropped += 1
            continue
        lines.append(line)
        used += len(line)
    return "\n".join(lines), used, dropped


def build_messages(question, hits, budget_chars=200):
    items = dedup(hits)
    context, used, dropped = build_context(items, budget_chars)
    if not context:
        # 空结果必须走另一条分支:拿空 context 去问模型,等于请它自由发挥
        return None, {"reason": "no_hit", "used": 0, "dropped": dropped}
    messages = [
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": USER.format(context=context, question=question)},
    ]
    return messages, {"reason": "ok", "used": used, "dropped": dropped,
                      "kept": len(items) - dropped}


def main():
    print("=== 原始检索结果 %d 条 ===" % len(HITS))
    for score, source, text in HITS:
        print("  %.2f  %-20s %s" % (score, source, text))

    print()
    print("=== 去重后 ===")
    items = dedup(HITS)
    for i, it in enumerate(items, 1):
        print("  [%d] %.2f  来源 %s" % (i, it["score"], "、".join(it["sources"])))
    print("  %d 条 → %d 条,第 1 条和第 3 条正文完全相同,来源已合并。"
          % (len(HITS), len(items)))

    print()
    print("=== 预算充足时拼出来的提示词 ===")
    messages, info = build_messages(QUESTION, HITS, budget_chars=500)
    for m in messages:
        print("-" * 60)
        print("role = %s" % m["role"])
        print(m["content"])
    print("-" * 60)
    print("context 用掉 %d 字,丢弃 %d 条" % (info["used"], info["dropped"]))

    print()
    print("=== 预算很紧时会发生什么 ===")
    for budget in (500, 120, 60, 10):
        _msgs, info = build_messages(QUESTION, HITS, budget_chars=budget)
        print("  预算 %3d 字 → 用掉 %3d 字,丢弃 %d 条,结果:%s"
              % (budget, info["used"], info["dropped"], info["reason"]))
    print("  预算被压到装不下任何一条时,返回的是 no_hit 而不是一个空 context —— ")
    print("  这两种情况在代码里必须分开处理。")

    print()
    print("=== 检索什么都没找到时 ===")
    msgs, info = build_messages("公司年假怎么休?", [], budget_chars=500)
    print("  messages = %s,reason = %s" % (msgs, info["reason"]))
    print("  这一步直接回复用户「资料中未提及」,连模型都不用调:")
    print("  省一次调用,也断掉了空 context 触发编造的那条路。")


if __name__ == "__main__":
    main()

输入的 4 条里有两条正文完全相同(来自两份不同文档),去重后变成 3 条,来源合并成「物流信息.pdf#p1、线路手册.docx#p3」。拼出来的 user 消息是:

拼好的【已知信息】
[1] (来源:物流信息.pdf#p1、线路手册.docx#p3)承运方快运通,陆运,出发地广州,目的地重庆,预计运输时间三天。
[2] (来源:物流信息.pdf#p1)货物编号 ABC123456,发货日期 2023-01-15,当前位置上海分拨中心。
[3] (来源:仓储制度.md#p2)东方仓储中心位于深圳市,存储电子产品,常温仓储,库存一千件。

预算被压低时的行为也测了一遍,注意最后一行:

预算实际用掉丢弃条数返回
500 字1750ok
120 字1131ok
60 字492ok
10 字03no_hit
「没检索到」和「检索到了但装不下」要分开处理 预算压到装不下任何一条时,返回的是 no_hit不是一个空 context。这两种情况在代码里必须分开:前者直接回复用户「资料中未提及」,连模型都不用调——省一次调用,也断掉了空 context 触发编造的那条路;后者说明预算配错了,该去查配置而不是去查资料。

05骨架模板与六层排查表

一份可改造的问答链骨架,一张能当场定位问题的检查表

5.1 问答链骨架

下面这份把在线阶段的五步拆成五个互不纠缠的函数,每个可变点都留了 TODO:

qa_chain_skeleton.py —— 检索问答链骨架,八处 TODO模板
"""问答链骨架:召回 → 阈值 → 重排 → 拼词 → 生成 → 带出处返回。

把 TODO 填掉就是一条可用的问答链。六个环节各自独立,
每个都能单独打开日志、单独换实现,不互相纠缠。

用法
    python3 qa_chain_skeleton.py "你的问题"
"""
import math
import os
import re
import sys
from collections import Counter

# ----------------------------------------------------------- 可调参数
RECALL_K = 20      # 召回放宽:宁可多拿,别漏
RERANK_K = 4       # 重排收紧:真正进提示词的条数
SCORE_MIN = 0.10   # TODO: 先跑一遍分数分布再定这个值
CTX_BUDGET = 1500  # context 的字数预算


# ----------------------------------------------------------- ① 索引
class Store:
    """TODO: 换成真正的向量库,保持 add / search 两个方法签名不变。"""

    def __init__(self):
        self.rows = []

    def add(self, text, source):
        self.rows.append({"text": text, "source": source, "vec": embed(text)})

    def search(self, query, k):
        qv = embed(query)
        scored = [(cosine(qv, r["vec"]), r) for r in self.rows]
        scored.sort(key=lambda x: x[0], reverse=True)
        return scored[:k]


def embed(text):
    """TODO: 换成 Embedding 模型。建库与提问必须用同一个。"""
    return Counter(re.findall(r"[a-z0-9]+", text.lower())
                   + re.findall(r"[\u4e00-\u9fff]", text))


def cosine(a, b):
    common = set(a) & set(b)
    dot = sum(a[k] * b[k] for k in common)
    na = math.sqrt(sum(v * v for v in a.values()))
    nb = math.sqrt(sum(v * v for v in b.values()))
    return dot / (na * nb) if na and nb else 0.0


# ----------------------------------------------------------- ② 阈值
def apply_threshold(hits, score_min=SCORE_MIN):
    """低于阈值的一律丢掉,宁可空手回来也不要把噪声塞给模型。"""
    return [(s, r) for s, r in hits if s >= score_min]


# ----------------------------------------------------------- ③ 重排
def rerank(question, hits, keep=RERANK_K):
    """TODO: 换成 Rerank 模型;它只重排,不新增候选。

    没有 Rerank 模型时,原样返回前 keep 条即可,
    这一层是可插拔的,不接也能跑。
    """
    return hits[:keep]


# ----------------------------------------------------------- ④ 拼词
SYSTEM = """你是知识库问答助手。

规则:
1. 只使用【已知信息】里的内容作答,不得补充外部知识。
2. 信息不足时回复「资料中未提及」,不要推测。
3. 每个结论后用方括号标出依据的编号。"""

USER = "【已知信息】\n{context}\n\n【问题】\n{question}"


def build_messages(question, hits, budget=CTX_BUDGET):
    seen, items = set(), []
    for _s, r in hits:                       # 去重:同一段话只留一份
        if r["text"] in seen:
            continue
        seen.add(r["text"])
        items.append(r)

    lines, used = [], 0
    for i, r in enumerate(items, 1):
        line = "[%d] (来源:%s%s" % (i, r["source"], r["text"])
        if used + len(line) > budget:
            break
        lines.append(line)
        used += len(line)

    if not lines:
        return None, []
    return ([{"role": "system", "content": SYSTEM},
             {"role": "user", "content": USER.format(context="\n".join(lines),
                                                     question=question)}],
            items[:len(lines)])


# ----------------------------------------------------------- ⑤ 生成
def call_llm(messages):
    """TODO: 换成真实模型调用,密钥走环境变量:

        api_key = os.environ.get("LLM_API_KEY")
    """
    if os.environ.get("LLM_API_KEY"):
        return "[TODO: 在这里发起真实请求]"
    return "[未接入模型] 本应发出的 messages 共 %d 条" % len(messages)


# ----------------------------------------------------------- ⑥ 出口
def answer(store, question):
    hits = store.search(question, RECALL_K)
    hits = apply_threshold(hits)
    if not hits:
        return {"answer": "资料中未提及。", "sources": [], "stage": "threshold"}

    hits = rerank(question, hits)
    messages, used = build_messages(question, hits)
    if messages is None:
        return {"answer": "资料中未提及。", "sources": [], "stage": "budget"}

    text = call_llm(messages)
    return {"answer": text,
            "sources": [r["source"] for r in used],
            "stage": "ok"}


DEMO_DOCS = [
    ("承运方快运通,陆运,出发地广州,目的地重庆,预计运输时间三天。", "线路手册#p3"),
    ("货物编号 ABC123456,当前位置上海分拨中心。", "运单#p1"),
    ("东方仓储中心位于深圳市,存储电子产品。", "仓储制度#p2"),
]


def main():
    question = sys.argv[1] if len(sys.argv) > 1 else "这批货从哪发出,几天能到?"
    store = Store()
    for text, source in DEMO_DOCS:      # TODO: 换成自己的建库流程
        store.add(text, source)

    result = answer(store, question)
    print("问题:%s" % question)
    print("阶段:%s" % result["stage"])
    print("出处:%s" % ("、".join(result["sources"]) or "无"))
    print("答案:%s" % result["answer"])


if __name__ == "__main__":
    main()
TODO默认值怎么定
RECALL_K20召回放宽;向量检索快,多取几条几乎没代价
FINAL_K4重排后留 3~5 条,这是真正进提示词的数量
SCORE_MIN0.15必须在自己的库上量,见第 03 节的分布法
CTX_BUDGET2000按模型窗口和成本定;装不下整条丢,不截半条
USE_RERANKFalse先不开,量出排名确实靠后再开
USE_KEYWORDTrue库里有单号/型号就开着,几乎没有副作用
embed() / search()词袋 / 线性扫描换真实模型与向量库;两处要成对改
call_llm()打印密钥走 os.environ.get(),不写进源码
为什么把 USE_RERANK 默认关掉 Rerank 要多一次模型调用、多几百毫秒,而它只解决排名问题。先量再开:如果命中率不达标,该加的是 k,不是 Rerank;只有「在 top-k 里但排得靠后」这一种情况,开它才有收益。默认开着会让人误以为效果的提升来自它。

5.2 六层排查表:答不对时按顺序往下查

这是本讲最实用的一张表。RAG 答不对时,问题可能落在六层里的任意一层,每一层有自己该动的旋钮,而且顺序不能乱——因为每层都以下一层成立为前提。

recall_debug.py —— 六层逐层检查,卡在哪层就给哪个动作可运行
"""召回不准怎么查:按固定顺序逐层排除,不要一上来就改提示词。

排查顺序是有讲究的——每一层都建立在下一层成立的前提上。
顺序颠倒的典型后果:切分根本没把答案留在任何一块里,
却花两天时间在调提示词措辞,怎么调都没用。

这个脚本把排查过程做成可执行的检查表:给它一个问题和期望答案,
它会逐层告诉你问题出在哪一层、下一步该动哪个旋钮。

纯标准库,直接运行。
"""
import math
import re
from collections import Counter

CHUNKS = [
    "货物编号 ABC123456,发货日期 2023-01-15,当前位置上海分拨中心。",
    "该批货物预计到达日期为 2023-01-20。",
    "承运方快运通,陆运,出发地广州,目的地重庆,预计运输时间三天。",
    "东方仓储中心位于深圳市,存储电子产品,常温仓储,库存一千件。",
    "速达物流总部在北京市,业务范围为国际快递与仓储管理。",
]


def tokenize(t):
    return re.findall(r"[a-z0-9]+", t.lower()) + re.findall(r"[\u4e00-\u9fff]", t)


def embed(t):
    return Counter(tokenize(t))


def cosine(a, b):
    common = set(a) & set(b)
    dot = sum(a[k] * b[k] for k in common)
    na = math.sqrt(sum(v * v for v in a.values()))
    nb = math.sqrt(sum(v * v for v in b.values()))
    return dot / (na * nb) if na and nb else 0.0


INDEX = [(i, c, embed(c)) for i, c in enumerate(CHUNKS)]


def search(q, k, score_min=0.0):
    qv = embed(q)
    scored = [(cosine(qv, v), i, c) for i, c, v in INDEX]
    scored.sort(key=lambda x: x[0], reverse=True)
    return [(s, i, c) for s, i, c in scored[:k] if s >= score_min]


def diagnose(question, must_words, k=3, score_min=0.0):
    """按固定顺序逐层检查,返回 (出问题的层, 该动哪个旋钮)。"""
    steps = []

    # 第 1 层:库里到底有没有这句话
    in_corpus = [i for i, c in enumerate(CHUNKS) if all(w in c for w in must_words)]
    steps.append(("① 资料里有没有", bool(in_corpus),
                  "资料本身缺这条 → 去补文档,别调任何参数"))
    if not in_corpus:
        return steps

    # 第 2 层:这句话是不是完整地落在某一块里
    steps.append(("② 有没有落在同一块里", True,
                  "被切散了 → 调大 chunk_size 或改分隔符"))

    # 第 3 层:这一块在不在 top-k 里
    got = search(question, k, 0.0)
    idxs = [i for _s, i, _c in got]
    in_topk = any(i in idxs for i in in_corpus)
    steps.append(("③ 在不在 top-%d 里" % k, in_topk,
                  "召回没取到 → 调大 k、开混合检索、或换 Embedding 模型"))
    if not in_topk:
        return steps

    # 第 4 层:会不会被阈值挡掉
    kept = search(question, k, score_min)
    kept_idx = [i for _s, i, _c in kept]
    pass_th = any(i in kept_idx for i in in_corpus)
    steps.append(("④ 过不过阈值 %.2f" % score_min, pass_th,
                  "被阈值挡了 → 调低阈值,或先确认分数分布再定"))
    if not pass_th:
        return steps

    # 第 5 层:排名靠不靠前
    rank = next(r for r, (_s, i, _c) in enumerate(kept, 1) if i in in_corpus)
    steps.append(("⑤ 排名第 %d" % rank, rank <= 2,
                  "排太后 → 上 Rerank,这一层才轮得到重排出场"))
    if rank > 2:
        return steps

    steps.append(("⑥ 前五层都过了", True,
                  "到这里还答不对,才轮到改提示词模板"))
    return steps


CASES = [
    ("这批货从哪发出,几天能到?", ["出发地", "运输时间"]),
    ("货物现在到哪了?", ["当前位置"]),
    ("公司年假怎么休?", ["年假"]),
]


def main():
    for question, must in CASES:
        print("=" * 66)
        print("问题:%s" % question)
        print("期望命中含有这些字样的块:%s" % "、".join(must))
        print("=" * 66)
        steps = diagnose(question, must, k=3, score_min=0.10)
        for name, ok, advice in steps:
            flag = "通过" if ok else "卡住"
            print("  %-22s %s" % (name, flag))
            if not ok:
                print("      → %s" % advice)
        if all(ok for _n, ok, _a in steps):
            print("      → %s" % steps[-1][2])
        print()

    print("=" * 66)
    print("这张检查表的意义:每一层都有自己该动的旋钮,且顺序不能乱。")
    print("常见的错误做法是跳过 ①②,直接在 ③④⑤ 上反复调参——")
    print("资料里压根没有的东西,调到天亮也召不回来。")


if __name__ == "__main__":
    main()
检查什么卡在这层时该动什么
资料里有没有去补文档,别调任何参数
有没有落在同一块里改切分(chunk_size / overlap / 分隔符)
在不在 top-k 里加大 k,或加一路关键词检索
过不过阈值重量一次分数分布,调阈值
排名靠不靠前这一层才轮得到 Rerank 出场
前五层都过了才轮到改提示词模板

脚本对三个问题各跑了一遍,结论分别落在不同层:

问题卡在第几层给出的动作
这批货从哪发出,几天能到?⑤ 排名第 3排太后 → 上 Rerank
货物现在到哪了?⑥ 六层全过还答不对才改提示词模板
公司年假怎么休?① 资料里就没有去补文档,别调参数
⛔ 常见的错误做法:跳过 ①② 直接在 ③④⑤ 上反复调参,资料里压根没有的东西,调到天亮也召不回来。这张表的全部价值就是逼着你先回答前两个问题——而它们恰恰是最容易被跳过的,因为「调参」看起来像在干活,「去补文档」看起来像在推责任。
把这张表做成脚本留在项目里 准备 20~50 条带标准答案的测试题,每次改完切分、换完模型、调完参数就跑一遍,输出「卡在第几层」的分布。有了这个分布,「这次改动到底有没有用」就变成一个数字,而不是一句感觉。

06易错点汇总

按「k / 阈值 / 重排 / 检索方式 / 拼装 / 验收」六类归并

⚠️ 一、top-k

  • 以为 k 越大越保险。 实测命中率在 k=3 就到顶(1.00),k 从 3 加到 8 命中率一点没涨,噪声比从 0.67 涨到 0.88、上下文从 86 字涨到 217 字。多付的每一分钱都花在塞废纸上,还增加了模型漏读关键句的风险。
  • 抄别人的 k。 「k=3 就够」是这个库上的结论。库变了、切分变了都要重扫一次,真实项目的拐点通常在 3~5 之间。
  • 把命中率和 Top1 准确率混为一谈。 命中率量的是「在不在这 k 条里」。实测有一条问题,该用的那块分数 0.1430 排第三,而噪声排第一(0.2417)——命中率是 1,Top1 却是错的。前者不达标加 k,后者不达标才上 Rerank。
  • 召回阶段就把 k 收得很紧。 召回的 k 大几乎没有代价(毫秒级),重排的条数才是成本。该省的是重排的条数。

⚠️ 二、阈值

  • 不设阈值。 top-k 永远返回 k 条,库里一条相关的都没有时照样返回。不设阈值,系统就永远没有说「资料中未提及」的能力。
  • 直接拍一个 0.3。 正确顺序是先量分布:拿「库里有答案」和「库里没答案」两类问题各问一遍。实测两类之间空隙是 0.1291~0.1925,阈值卡在 0.16 时两类错误同时为零。
  • 两类分数重叠还在硬调阈值。 找不出空隙说明前面出了问题——切分太碎导致块自身读不通,或者 Embedding 选错了语言。回去改前面。
  • 换了 Embedding 模型不重量阈值。 分布整条平移,旧阈值静默失效:要么大面积拒答,要么大面积放过。把量分布的脚本留在项目里。
  • 不分业务地追求「总错误最少」。 两类错误代价不对等:对外客服、法务、医疗宁可漏答也不能瞎答,阈值往高调;内部检索助手往低调。
  • L2 距离的阈值方向写反。 余弦越大越相似,L2 越小越相似。写反会把最相关的全过滤掉,只留最不相关的,而且不报错。

⚠️ 三、重排

  • 指望 Rerank 救回召回阶段漏掉的块。 实测把召回收到 2 条时,该用的那块压根不在候选里,重排只是把两张错的重新排了一遍。复核员变不出新卡片。
  • 默认就把 Rerank 打开。 它多一次模型调用、多几百毫秒,且只解决排名问题。先量再开——否则会误以为效果提升来自它。
  • 重排的候选给太多。 每条都要过一次模型,耗时随候选数线性增长。固定配比:召回 20~50 条,重排后留 3~5 条。
  • 把 Rerank 当成「更好的检索」。 它是第二道工序,不是替代品。向量检索为了能预先建索引只能把问题和文档分开编码,Rerank 让两者在同一次打分里互相看见——这是速度换精度的必然补偿。

⚠️ 四、检索方式

  • 只用向量检索查单号、型号、条款编号。 实测问「ABC123789 这单在哪」,三条句式雷同的运单被排在一起,正确那条只排第二;关键词检索一击即中。这些串的「意思」几乎为零,区分它们的是字符本身。
  • 反过来只用关键词检索。 同一个库上问「货从哪个城市发出」,关键词检索返回空。两路各有盲区,所以默认两路都开。
  • 融合时把两路分数加权相加。 向量分和关键词分不在同一刻度,归一化参数还随数据漂移。用 RRF 只按名次算 1/(k+rank),绕开刻度问题。
  • 忘了元数据过滤。 「只查 2025 年之后的制度」这类条件,靠语义是筛不掉的,必须在向量库里按字段过滤——前提是入库时存了这些字段。

⚠️ 五、拼装

  • 不去重。 同一段话在两份文档里都有时,会占掉两个位置,等于白白浪费一条 k。正文相同就合并,来源并在一起。
  • 不给每块编号。 然后在提示词里要求模型标出处——它只能瞎编页码。规则和编号必须成对存在。
  • 预算不够时截半条。 模型读不出这是被截断的,只会当成完整信息理解。装不下就整条丢,并记下丢了几条,这样日志里查得到。
  • 把「没检索到」和「检索到但装不下」混成一种。 前者直接回复用户,连模型都不用调;后者是预算配错了,该去查配置。实测预算压到 10 字时返回的是 no_hit 而不是空 context,这两条路径在代码里必须分开。
  • 拿空 context 去调模型。 这是编造最高发的场景,而且每次都要付钱。

⚠️ 六、排查与验收

  • 跳过前两层直接调参。 六层检查表里,① 资料里有没有、② 有没有落在同一块里,是最容易被跳过的两层——因为「调参」看起来像在干活。资料里压根没有的东西,调到天亮也召不回来。
  • k 和阈值一起调。 效果变了说不清是谁的功劳。固定一个调另一个。
  • 没有测试集。 改完问两句觉得「好像好点了」就上线。准备 20~50 条带标准答案的题,把「卡在第几层」的分布打出来,改动有没有用就是一个数字。
  • 只看最终答案,不看检索回了什么。 界面上不显示命中的原文,出错时没人能判断是检索错了还是模型答错了,排查只能靠猜。

07自测题

点击题目展开答案;这 12 题答得上,检索侧的调参就不用靠猜了

一、k 与阈值
k 越大越保险吗?用实测数字说明。

不是。实测命中率在 k=3 时就到顶(1.00),k 从 3 加到 8:命中率一点没涨,噪声比 0.67 → 0.88,平均上下文 86 字 → 217 字。多出来的每一条都只是往活页夹里塞废纸,还增加模型漏读关键句的风险。定法:从小往大扫,找命中率第一次到顶的那个 k 就停。

命中率和 Top1 准确率有什么区别?各自不达标时该动什么?

命中率量「该用的那块在不在这 k 条里」,Top1 量「它是不是排第一」。实测有一条问题,该用的块分数 0.1430 排第三,噪声排第一(0.2417)——命中率是 1,Top1 却错。命中率不达标 → 加 k 或加一路检索;Top1 不达标 → 上 Rerank。

阈值该怎么定?为什么不能直接拍 0.3?

先量分布:拿「库里有答案」和「库里没答案」两类问题各问一遍。实测库里有的最低 0.1925,库里没有的最高 0.1291,中间空隙 0.0634——阈值卡进这个空隙(如 0.16),两类错误同时为零。分数分布跟着模型、切分、metric 一起走,别人的数在你的库上没有意义。

两类问题的分数完全重叠、找不出空隙,说明什么?

说明问题不在阈值这一层——多半是切分太碎(块自身读不通)或 Embedding 选错了语言。回去改前面,别在阈值上硬凑;硬凑的结果只能是在「大面积漏答」和「大面积放过」之间二选一。

对外客服和内部检索助手,阈值该往哪边偏?

两类错误代价不对等。对外客服、法务、医疗:宁可漏答也不能瞎答,阈值往高调;内部检索助手:宁可多给几条让人自己挑,往低调。所以「总错误最少」不一定是最优点,要按业务偏。

二、重排与混合检索
Rerank 能救回召回阶段漏掉的块吗?

不能,复核员变不出新卡片。实测把召回收到 2 条时,该用的那块压根不在候选里,重排只是把两张错的重新排了一遍。所以固定配比是:召回放宽 20~50 条,重排收紧留 3~5 条。

Rerank 为什么比向量检索排得准?代价是什么?

向量检索为了能预先建索引,必须把问题和文档各自编码后再比坐标;Rerank 把两者拼在一起过一遍模型,让它们在同一次打分里互相看见。代价是慢——每条候选都要过一次模型,耗时随候选数线性增长,所以只能用在小批候选上。

实测里,重排把该用的那块从第几名提到了第几名?

第 3 名提到第 1 名(重排分 1.500,其余为 0.000)。重排前取 top-2 会完全错过它,重排后取 top-2 就能拿到。

为什么查单号要加一路关键词检索?

单号、型号、条款编号的「意思」几乎为零,真正区分它们的是字符本身,而这正是语义模型的弱项。实测问「ABC123789 这单在哪」,向量检索把三条句式雷同的运单排在一起、正确那条排第二;关键词检索一击即中。反过来问「货从哪个城市发出」,关键词检索返回空——两路各有盲区,所以默认两路都开。

融合两路结果时,为什么用 RRF 而不是把分数加权相加?

向量分和关键词分不在同一个刻度上,加权相加要先归一化,而归一化参数会随数据漂移。RRF 只用名次1/(k+rank) 再相加,绕开刻度问题,参数只有一个常数。

三、拼装与排查
context 预算不够时,该截半条还是丢整条?为什么?

丢整条,并记下丢了几条。截半条时模型读不出这是被截断的,只会当成完整信息去理解,而且这个错误在日志里完全不可见。另外要区分两种结果:预算压到装不下任何一条时返回 no_hit,而不是一个空 context——前者直接回复用户,后者说明预算配错了。

RAG 答不对时的六层排查顺序是什么?最容易被跳过的是哪两层?

① 资料里有没有 → ② 有没有落在同一块里 → ③ 在不在 top-k 里 → ④ 过不过阈值 → ⑤ 排名靠不靠前 → ⑥ 才是提示词。最容易被跳过的是 ① 和 ②,因为「调参」看起来像在干活,「去补文档」看起来像在推责任。资料里压根没有的东西,调到天亮也召不回来。

术语表

术语含义
召回(recall)检索阶段把可能相关的块取回来;追求「不漏」,对应指标是命中率
命中率该用的那块出现在返回的 k 条里的比例;量的是「在不在」,不是「排第几」
MRRMean Reciprocal Rank,正确块名次倒数的平均值;排第 1 记 1,第 2 记 0.5,越接近 1 越好
噪声比返回结果里无关块所占的比例;k 越大它越高
top-k取相似度最高的 k 条;召回阶段放宽、最终进提示词时收紧
score 阈值低于该分数的结果一律丢弃,让系统有能力回答「资料中未提及」
Rerank重排;把召回的少量候选重新打分排序,只排序不新增
cross-encoder把问题和文档拼在一起过一遍模型的打分方式,准但慢,Rerank 模型的典型形态
关键词检索按字面匹配,对单号、型号这类精确串很准,换个说法就失效
混合检索向量与关键词两路同时检索,再融合结果
RRFReciprocal Rank Fusion,按名次算 1/(k+rank) 相加来融合多路结果,绕开分数刻度不一致的问题
元数据过滤按部门、日期、版本等字段先筛再检索;前提是入库时存了这些字段
context 预算允许拼进提示词的最大长度;装不下的整条丢弃,不截半条
拒答检索结果为空或全部低于阈值时,直接回复「资料中未提及」,不调模型
✅ 一句话收束本讲 召回决定能不能拿到,重排决定拿没拿准,阈值决定拿不到时说什么。这三件事各有各的旋钮和各自的指标,分开量才调得动——下一讲把前三讲的东西合起来,落到一个真实的物流问答系统上。