2731 字
约 9 分钟
21
S20-X11 · RAG 的检索质量优化

用户问"退款几天到账",召回的三条全是"如何申请退款"——文档确实相关,但答不了问题。此时最常见的动作是换 embedding 模型,可换模型只改善"语义对齐"这一层;分块切断语义、索引缺元数据、排序把不相关的放前面,这些结构性问题换多少次模型都不会自愈。

顺序是:池子里有没有 → 排得对不对 → 给多少 → 怎么验证。


1. 检索不准的四个层次

层次 典型症状 优先动作
分块 答案跨两段,两段各说半句 调边界与重叠,或改父子块
索引 grep 有、similaritySearch 无 补元数据,标题拼进 embedding 输入
查询表述 用户口语、文档书面语 改写、多查询、HyDE
排序 正确文档稳定在第 8–15 位 加重排,扩首轮 k

判据:先确认候选池里有没有,再管排序。Recall@k 不达标时加重排是白花钱——重排只能在已有候选内重排,池里没有的排不出来。top_k 是最后一环,它放大前面所有环节的错误。


2. 查询侧的三种改造

方法 做什么 副作用 适用
改写 口语转书面检索式,补全指代 丢掉的限定词找不回,偏差不可逆 多轮对话、代词密集
多查询扩展 生成 3–5 个变体,检索后取并集 成本线性增长,并集抬高噪声 表述差异大的领域
HyDE 先写假设答案,用其向量检索 假设本身错会带偏整次检索 查询极短、文档长
from langchain.retrievers.multi_query import MultiQueryRetriever

base = vectorstore.as_retriever(search_kwargs={"k": 8})
mq = MultiQueryRetriever.from_llm(retriever=base, llm=llm)  # 默认 3 个变体,内部并集去重
docs = mq.invoke("退款多久到账")
// Spring AI:改写结果只作检索词
Query rewritten = rewriter.transform(new Query("退款多久到账"));
List<Document> docs = vectorStore.similaritySearch(
        SearchRequest.builder().query(rewritten.text()).topK(10).build());

关键纪律:改写产物是检索词,不是证据。它不能进最终 prompt 的引用列表,否则模型把自己的猜测当文档引用。叠加多查询与 HyDE 时,单次问答的检索成本会翻数倍,延迟从 1 次变成"1 次生成 + N 次检索"。


3. 混合检索的融合:先归一化再谈加权

BM25 分数无上界,向量相似度落在固定区间。直接写 0.7 * bm25 + 0.3 * cosine,等于让 BM25 单方面决定排序。

def minmax(scores):
    lo, hi = min(scores), max(scores)
    return [0.5] * len(scores) if hi - lo < 1e-9 else [(s - lo) / (hi - lo) for s in scores]

fused = [0.4 * a + 0.6 * b for a, b in zip(minmax(bm25_scores), minmax(vector_scores))]
def rrf(rank_lists, k=60):  # 只看名次,不看分数尺度
    acc = {}
    for ranks in rank_lists:
        for r, doc_id in enumerate(ranks, start=1):
            acc[doc_id] = acc.get(doc_id, 0.0) + 1.0 / (k + r)
    return sorted(acc.items(), key=lambda x: -x[1])
融合方式 需调参 对尺度敏感 适用
归一化后线性加权 权重 中 分布稳定、有评测集
RRF 只需 k 否 默认首选
学习排序 训练数据 高 有标注、量大

min-max 的坑:它依赖本次候选集极值,同一文档在不同查询下的归一化分数不可比。要做跨请求阈值判断,改用余弦原值或 RRF。

还有一个常被跳过的前提:两路检索必须真的看到同一批候选。BM25 取前 20、向量取前 20,交集可能只有三五条,此时融合的其实是两份几乎不相交的名单,加权只是在噪声上排序。做法是两路各扩到 k 的 3–5 倍再融合,融合完再截。


4. 重排:位置与 k/N/M 的取舍

重排是 cross-encoder,query 与候选拼接过模型,不能预计算,位置固定在"召回之后、进上下文之前"。

List<Document> candidates = vectorStore.similaritySearch(
        SearchRequest.builder().query(q).topK(40).build());
List<Document> reranked = rerankingModel.rerank(new RerankRequest(q, candidates));
List<Document> context = reranked.subList(0, Math.min(6, reranked.size()));
// API 名按所用 Spring AI 版本核对
参数 作用 给小了 给大了
召回 k 候选池 重排无从发挥 重排费用与耗时上升
重排 N 实际打分条数 等于没用重排 延迟线性增长
上下文 M 最终塞入条数 漏信息 上下文稀释,模型抓不住重点

典型量级:k 数十条、M 取 3–8 条,按重排模型吞吐与 SLA 核对。M 不是越大越好,塞 20 条片段模型更容易挑中噪音。


5. 元数据过滤的时机

时机 做法 优点 代价
先过滤 ANN 阶段带条件 快、候选池小 条件错就排除正确答案;元数据缺失文档永不召回
后过滤 先检索再筛 保召回 可能筛完不足 k 条
SearchRequest req = SearchRequest.builder()
        .query(q).topK(20)
        .filterExpression("tenant_id == 't_1024' && lang == 'zh'")
        .build();

分界线:会导致看到不该看的内容的,必须 pre-filter(租户、权限);只影响排序偏好的,放重排特征(时效性、来源可信度)。把"最近三个月"写进硬过滤是召回骤降的常见原因——旧文档里常有唯一正确的答案。还要确认向量库是"ANN 内过滤"还是"取 top-k 再筛",后者在窄条件下会返回空。
判断方法很直接:同一条件分别查宽条件与窄条件,若窄条件返回条数远小于期望而全库确有数据,就是"先取后筛"。此时要么换支持过滤遍历的索引类型,要么把条件放宽、用重排补。


6. 召回数该给多少

纯拍数字容易两头错。做法是画 Recall@k 曲线:k 取 3、5、10、20、40 跑同一批 query,看曲线在哪变平,再在变平点右侧留一倍余量给重排。曲线一直线性上升说明分块或索引有结构问题,加 k 是在给缺陷打补丁。

另一条约束是成本与延迟:候选数直接决定重排耗时的量级。所以 k 的最终取值是"召回曲线拐点"与"重排 SLA"的交点;两者都不可让时,优先改分块与索引,而不是继续加 k。

top_k 现象 判断
过小 模型答"未找到" 查 Recall@k 拐点
合适 正确片段稳定靠前 拐点右侧留余量
过大 多条近似重复,模型复述 看重复率与 M 的边际收益

7. 失败模式与对应改法

失败模式 根因 改法
问句找不到答案 查询与文档表述不一致 改写 + 多查询 + 同义词词典
答案跨文档拼 分块把一条逻辑切成两块 父子块(小块检索、大块喂入)、加大重叠
答案是表格里的 表格被线性化成乱序文本 按行转 markdown 或"字段:值"句,表格单独索引
答案在长文档中间 整篇 embedding 把中间语义抹平 按小节切,标题路径做元数据
召回够但排序不对 双塔精度不足 重排 + 扩 k
换模型仍不准 问题不在语义层 回第 1 节重新分层

8. 评测闭环

最小闭环:一批 query,每条标注 1–3 个必须命中的 doc_id,跑 Recall@k 与首次命中位置。

def recall_at_k(retrieved, gold, k):
    return sum(1 for r, g in zip(retrieved, gold) if g & set(r[:k])) / len(gold)

def first_hit_rank(retrieved, gold):
    for i, doc_id in enumerate(retrieved, start=1):
        if doc_id in gold:
            return i
    return -1   # 未命中

三条纪律:同一套标注跑所有版本;一次只改一个因素;报告带失败样本原文,否则分不清是标注错还是检索错。


9. 常见坑

坑 正确做法
只调 top_k 不看分块 先看命中片段是否完整语义单元
加了重排但召回集太小 k 至少是 M 的 3–5 倍
过滤条件排除了正确答案 硬约束只留权限类,软偏好下放
用户问题原样当检索词 改写后再检索,原文只作兜底
相似度阈值一刀切 阈值随分布漂移,只做兜底
引用里混入改写/假设文本 只有原始 doc_id 可进引用

10. 排错

现象 先查 处理
有文档却召回为空 过滤表达式、索引是否写入 去掉 filter 跑对照
加 rerank 反而更差 重排输入是否含完整正文 传完整片段并控长度
多查询后答案变散 变体是否偏离原意 限变体数、约束实体不变
混合检索不如纯向量 是否做了归一化 改 RRF 或先归一化
中文专有名词总漏 分词器与词典 自定义词典 + keyword 字段
换 embedding 后报维度错误 索引 dimension 新建索引全量重灌

11. 边界

场景 更合适的做法 理由
语料只有几十条 全量塞进上下文 检索误差大于收益
精确计数/聚合 SQL 或结构化查询 向量检索不做聚合
严格权限隔离 检索前做行级权限或分库 后过滤先泄漏再筛
多跳推理 迭代检索(检索→子问题→再检索) 单轮 top_k 跨不了跳
语料每日全量更新 增量索引 + 版本字段 重灌窗口期不可用

12. 自测

  1. Recall@k 已经很高但答案仍错,问题最可能在哪一层?
  2. 为什么 BM25 分数不能直接和余弦相似度加权求和?
  3. 重排的 k 与 N 分别控制什么,哪个给小了等于没加重排?
  4. 哪类过滤条件必须在检索前,哪类应下放到重排?
  5. 答案在长文档中间却总召不回,改分块还是改 k?
  6. 为什么查询改写的产物不能进最终 prompt 的引用列表?
S20-X11 · RAG 的检索质量优化
https://www.clxhxhhr.top/posts/5889/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。