用户问"退款几天到账",召回的三条全是"如何申请退款"——文档确实相关,但答不了问题。此时最常见的动作是换 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. 自测
- Recall@k 已经很高但答案仍错,问题最可能在哪一层?
- 为什么 BM25 分数不能直接和余弦相似度加权求和?
- 重排的 k 与 N 分别控制什么,哪个给小了等于没加重排?
- 哪类过滤条件必须在检索前,哪类应下放到重排?
- 答案在长文档中间却总召不回,改分块还是改 k?
- 为什么查询改写的产物不能进最终 prompt 的引用列表?