知识库AgentRAG 检索增强RAG 检索增强召回、重排与上下文构造召回、重排与上下文构造
02 · RAG 检索增强RAG 检索增强
Roadmap 02核心Markdown76 min

召回、重排与上下文构造召回、重排与上下文构造

以权限安全和证据预算为约束,深入理解 BM25、向量召回、RRF、重排、上下文装配与降级。以权限安全和证据预算为约束,深入理解 BM25、向量召回、RRF、重排、上下文装配与降级。

#RetrievalRetrieval#BM25BM25#RRFRRF#RerankRerank更新于 2026-08-16

专题导读

检索阶段的目标不是“尽可能多地找相似文本”,而是在权限、延迟、成本和上下文窗口约束下,选择足以回答当前问题的证据集合。生产链路通常不是一次向量查询,而是身份与过滤条件解析、查询规范化、多路召回、融合、重排、去重、parent 扩展、预算装配和引用映射的组合。

Java 后端面试中,既要说清 BM25、向量相似度、RRF 和 cross-encoder 的机制,也要说清安全边界:ACL 必须在无权正文暴露给 reranker、LLM、日志或缓存之前生效;检索到的文档是低信任数据,不能因为被召回就获得指令权或工具权限。

知识地图

TEXT
用户问题 + 可信身份
  └─ 查询规范化:语言、术语、时间、受控过滤
      ├─ BM25:精确词项、错误码、类名、版本号
      └─ 向量 ANN:同义表达、语义近邻
          └─ 候选融合:RRF / 校准分数融合
              └─ 重排:cross-encoder / 规则 / 业务特征
                  └─ 证据装配:去重、parent 扩展、多样性、token 预算
                      └─ 生成:来源绑定、拒答/澄清、可观测 trace

每一步都可能丢失正确证据。应记录候选在各阶段的名次与淘汰原因,而不是只保留最终 prompt。

面试题

Q1:一条生产级 RAG 检索链路应该如何分层?

核心回答: 推荐分为安全上下文、查询理解、候选召回、融合、重排、证据装配和生成交付七层。每层输入输出稳定、版本可记录、可以独立评估与降级。

  1. 安全上下文:从登录态或服务身份得到 tenantId、主体、角色/策略与允许的数据域,绝不从用户自然语言推断权限。
  2. 查询理解:规范化文本,可选地做改写、分解或生成受控过滤条件。
  3. 候选召回:在权限硬过滤下执行 BM25、向量或领域检索器。
  4. 融合:按稳定 chunkId 合并多路候选,常用 RRF 避免直接比较异构分数。
  5. 重排:在有限 top-N 上使用更昂贵的相关性模型或规则。
  6. 证据装配:去重、parent/相邻扩展、来源多样性和 token 预算控制。
  7. 生成交付:只把最终证据交给模型,服务端绑定引用;证据不足时拒答或澄清。

这不是固定流水线。错误码查询可能跳过改写并提高 BM25 权重;多跳问题可能分成多个子查询;重排超时可降级为融合排序。关键是所有分支在评测集上有收益且可观测。

面试追问:为什么不能直接把 top-20 向量结果交给模型?

单路向量会漏精确标识符,top-20 可能重复、越权、过期或超预算;相似也不代表足以回答。模型不能替代检索端鉴权与证据选择。

常见错误回答: “向量检索 top-K,然后拼 prompt。”这只能描述 demo,无法回答权限、融合、成本、引用和失败处理。

Q2:BM25 的核心机制是什么?它为什么适合技术文档?

核心回答: BM25 是基于词项匹配的概率相关性排序方法。它综合逆文档频率、词频饱和和文档长度归一化:稀有词通常更有区分度;一个词在文档中从出现 1 次变 2 次有帮助,但不会无限线性加分;较长文档因更容易偶然包含查询词而受到归一化。

常见形式可写为:

score(D,Q) = Σ IDF(qᵢ) × [f(qᵢ,D) × (k₁ + 1)] / [f(qᵢ,D) + k₁ × (1 - b + b × |D|/avgdl)]

其中 f(qᵢ,D) 是词项频次,|D|/avgdl 表示相对长度,k₁ 控制词频饱和,b 控制长度归一化。具体 IDF 形式与默认参数以所用搜索引擎实现为准,不能假定所有后端完全一致。

技术文档包含 NullPointerExceptionORA-01555、Maven 坐标、配置键、Java 类名等精确字符串,BM25 往往比纯语义向量更稳定。索引 schema 应给这些字段保留不分词或专用 analyzer,查询侧也要避免把错误码改写掉。

面试追问:BM25 分数能跨查询比较吗?

通常不应把不同查询的原始分数当统一置信度。分数依赖查询词、语料统计、字段与索引实现,应通过标注数据和查询内排序使用。

常见错误回答: “BM25 就是 TF-IDF。”BM25 与 TF-IDF 相关,但增加了词频饱和和文档长度归一化,公式与行为不能简单等同。

Q3:向量检索如何工作?余弦、点积与 ANN 有什么边界?

核心回答: 查询与 chunk 经同一 embedding 契约映射到向量空间,再按余弦、点积或欧氏距离找近邻。高维数据上通常用 ANN(近似最近邻)索引,以少量召回损失换取可接受的延迟和内存。

余弦相似度只比较方向:cos(q,d) = q·d / (||q|| ||d||)。若查询和文档向量都做 L2 归一化,点积与余弦排序一致;未归一化时不等价。距离度量必须与模型和建索引配置一致。

ANN 参数形成速度—召回—内存三角:构建更密或查询探测更多候选通常提升 ANN recall,但增加内存或延迟。这里的 ANN recall 是“相对精确向量近邻是否找回”,不等同于业务相关证据 Recall@K;向量空间本身若不适合业务,ANN 找得再准也无用。

面试追问:向量相似度 0.8 是否表示 80% 正确?

不是。它只是特定模型、归一化和语料下的几何相似度,不是事实概率,也不能跨模型直接比较。阈值必须用标注样本校准。

常见错误回答: “向量检索能理解语义,所以不需要关键词。”它可能漏掉罕见标识符、否定词、版本差异和精确数值。

Q4:查询期 ACL 过滤应该放在哪里?后过滤有什么问题?

核心回答: 权限条件必须在无权正文被返回、记录或发送到任何模型之前生效。优先把 tenantId、资源域、文档状态和 ACL 条件下推到搜索后端,使候选生成本身就在授权集合内。

如果后端无法表达复杂策略,可以先在可信服务中求出允许的资源集合或策略 token,再作为过滤条件下推。不得让无权 chunk 进入 cross-encoder 或 LLM 后再删除,因为这已经构成数据暴露。应用层后过滤还会产生 top-K 饥饿:全局 top-100 过滤后可能只剩 2 条,相关的授权文档因没进全局候选而永远丢失。

确实只能后过滤时,应将其视为受限降级:候选正文留在可信边界内、分批 over-fetch、设置扫描上限,并在任何外部模型调用前过滤;同时推动索引模型支持可下推权限。over-fetch 改善候选不足,却不能把不安全设计变安全。

面试追问:ACL 高频变化如何避免缓存脏读?

缓存键加入权限上下文摘要或 aclVersion,权限收紧时主动失效;缓存值也要带文档 ACL 版本并在返回前复核。跨租户共享检索结果通常不可接受。

常见错误回答: “把无权文档放进 prompt,再告诉模型不要引用。”提示词不是访问控制。

Q5:Query 改写、扩展与分解分别解决什么问题?

核心回答: 改写用于补全指代或规范术语;扩展增加同义词、缩写或领域别名;分解把多约束、多跳问题拆成可分别检索的子问题。三者都可能提升召回,也可能引入语义漂移、延迟和额外模型成本。

应保留原始 query,并让原始与改写 query 并行参与召回,避免错误改写完全覆盖用户意图。精确标识符、引号内容、版本号和否定约束应被保护。模型生成的过滤条件只能映射到预定义字段、操作符和枚举,禁止直接拼接 SQL、Lucene 查询串或搜索 DSL。

例如“JDK 21 的 G1 和 ZGC 哪个更适合 20 GB 堆、50 ms P99?”可拆为 G1 特性、ZGC 特性和特定版本约束,再在证据层合并。若“适合”的业务目标不明确,应追问吞吐、停顿和资源约束,而不是让改写模型擅自补充。

面试追问:何时不应调用 LLM 改写?

查询已有强精确标识符、低延迟路径、改写收益未被评测证明,或高风险条件不允许臆测时,应跳过或使用确定性规则。

常见错误回答: “所有 query 先让大模型优化一下。”这会无条件增加 P95、成本和不可复现性。

Q6:为什么使用混合召回?候选预算如何分配?

核心回答: 混合召回用 BM25 保住精确词项,用向量检索覆盖语义改写。候选预算要按查询类型、各路边际召回收益、融合后重复率、rerank 容量和延迟共同确定。

固定地让两路各取 100 是可用基线,不是最终方案。包含异常栈、类名或错误码的查询可增加关键词候选;自然语言解释型问题可增加向量候选。还可以增加标题、FAQ、实体或图检索器,但每增加一路都要证明增量价值,记录独立召回与融合贡献。

候选数越大通常提高上限,却增加 ANN/BM25 查询负载、融合去重和 rerank 成本。真正应调的是“在 rerank 输入上限 N 内,相关证据进入候选的概率”,而不是单独最大化某一路 K。

面试追问:多路并行后总延迟怎么算?

理想情况下接近最慢分支加融合开销,但还受线程池排队、连接池、超时和尾延迟影响。应给每路独立 deadline,必要时用已返回分支降级,而非无限等待。

常见错误回答: “K 越大召回越高,所以尽量调大。”忽略了尾延迟、重排成本、重复率与上下文噪声。

Q7:RRF 的公式、优势和局限是什么?

核心回答: Reciprocal Rank Fusion(RRF)只使用各检索器中的名次。对文档 d

RRF(d) = Σᵣ 1 / (c + rankᵣ(d))

其中 rankᵣ(d)d 在第 r 路结果中的 1-based 名次,未出现则不贡献;c 是平滑常数。它避免直接相加 BM25、余弦等不同量纲的原始分数,并奖励被多路共同排在前面的候选。

RRF 前必须统一身份:同一 chunk 在两路中的 chunkId 相同;近重复 chunk 则要另做内容或 parent 级去重。c、每路 K 和最终截断 N 是联动参数,应在评测集上调。原论文常被实现以某个默认常数引用,但生产配置应以所用引擎文档和本地评测为准,不要把默认值当定律。

局限是它忽略分数差距:第 1 与第 2 名的真实差距可能很大,RRF 看不到;各路质量不同却默认近似同权。可加入路权重形成加权 RRF,或在有可靠校准数据时做分数融合。

面试追问:RRF 后还需要 rerank 吗?

常常需要。RRF 提供稳健候选融合,但只利用名次,不建模 query 与文档的细粒度交互。昂贵 reranker 可在较小候选集上进一步判断相关性。

常见错误回答: “RRF 会把两个分数归一化再相加。”RRF 使用排名倒数,不需要原始分数归一化。

Q8:Rerank 有哪些实现方式?为什么只处理较小候选集?

核心回答: 常见方式包括 cross-encoder、LLM 打分/比较、轻量学习排序以及业务规则融合。cross-encoder 把 query 与候选文本一起编码,能建模细粒度交互,通常比独立向量相似度精确,但需要对每个候选做更昂贵推理。

候选 top-N 由离线召回上限、模型吞吐和延迟决定。若初始候选没有相关证据,reranker 无法创造证据;若 N 过大,推理成本和 P95 会爆炸。应批量推理,设置独立超时与并发隔离,超时后回退到 RRF 排序,并记录是否降级。

LLM rerank 适合复杂准则或快速验证,但输出稳定性、成本、位置偏差和提示注入面更难控制。文档正文是不可信数据,reranker 提示必须把它作为待评分数据;无论模型输出什么,都不能改变 ACL 或调用工具。

业务特征如文档状态、时效、权威等级可参与最终排序,但必须明确是相关性、质量还是业务策略,避免让“最新”无条件压过真正相关证据。

面试追问:重排分数可否直接作为拒答阈值?

可以作为特征,但需校准。模型分数不天然是概率,还要结合证据覆盖、冲突、查询类型和无答案样本评测。

常见错误回答: “用更大的 LLM 对全部文档排序,效果最好。”全库 cross-encoding 在计算上不可行,也没有先召回的成本优势。

Q9:如何做去重、parent 扩展与来源多样性控制?

核心回答: 去重分三层:相同 chunkId 的精确去重、相同 contentHash 的内容去重、以及高重叠/同 parent 的近重复抑制。随后根据命中位置扩展 parent 或相邻 chunk,补足上下文,再次去重并计算预算。

扩展顺序不能反过来:若先把每个命中都扩成整章,重复和成本会迅速膨胀。parent 聚合时可保留该 parent 下最高分及命中数量作为特征,同时限制每个文档/来源最多占用的槽位,避免十个高度相似 chunk 挤掉其他权威来源。

多样性不是平均分配。一个唯一权威规范可以占主导;多个博客内容相似也不等于独立证据。应区分来源域、文档、章节和内容相似度,并按任务风险制定规则。

面试追问:相邻扩展会不会引入越权内容?

会,如果权限粒度小于 parent。扩展后的每个块都要满足同一授权上下文,不能假定 child 有权就代表整个 parent 有权。

常见错误回答: “按 URL 去重即可。”同一内容可有多个 URL,同一 URL 也可包含多个不同版本和章节。

Q10:上下文装配如何在 token 预算内选证据?

核心回答: 把装配建模为受约束选择:在固定证据 token 预算内,综合相关性、必要证据覆盖、来源权威性、时效、冗余和冲突,选择最终片段。不能简单地按分数从高到低塞到窗口满。

预算应预留系统指令、用户问题、输出和工具 schema;证据只是其中一部分。片段应带 sourceId、标题、版本和清晰边界,文档内容必须被标注为不可信数据。超预算时优先去重复、移除低边际价值片段或缩小 parent 扩展,避免从中间截断 JSON、Java 代码、表格行或否定条件。

引用应由服务端根据实际进入 prompt 的 chunk生成。若召回了文档但预算阶段丢弃,就不能把它列为答案依据。还要记录 selectedReasondroppedReason 和 token 估算,才能诊断“召回正确但没装进去”。

面试追问:摘要压缩是否能无限扩容?

不能。摘要会丢数值、限定条件和出处,也可能产生新断言。高风险事实应保留原文,摘要要可回溯并单独评估忠实度。

常见错误回答: “上下文窗口有 128K,就把 top-100 全放进去。”长上下文增加成本与干扰,并不能保证模型利用所有证据。

Q11:如何处理无答案、证据冲突和低置信查询?

核心回答: 拒答、澄清和呈现冲突都是正常产品结果。系统应在评测集中显式包含这些类别,而不是把“总能生成答案”当成功。

无答案判定不能只依赖固定向量阈值。可组合候选覆盖、rerank 分数、权威来源、必要实体/时间约束是否匹配、证据间一致性和问题类型。证据冲突时应保留双方来源与版本,按权威性和生效时间排序;若无法消解,回答应明确冲突,不得静默选择更符合模型先验的一方。

用户问题缺少关键条件时,澄清通常比多次隐式改写更可靠。例如“如何调线程池”缺少任务类型、阻塞比、下游容量和延迟目标,系统可以先问约束。

面试追问:怎样评估拒答策略?

构建有答案与无答案样本,计算应答/拒答混淆矩阵、无依据回答率与有答案误拒率,并按风险赋予不同代价。

常见错误回答: “没有结果就让模型用常识补全。”这违背 RAG 的证据约束,尤其不适合内部政策、版本文档和高风险业务。

Q12:检索链路如何做缓存、可观测性与超时降级?

核心回答: 缓存键必须反映查询语义与安全上下文,trace 必须记录每阶段版本、候选变化和耗时,降级必须有预先评测的质量边界。

安全的结果缓存键通常包含规范化 query、tenantId、权限摘要/aclVersionindexVersion、检索配置版本和语言。权限收紧、索引切换或文档删除时要失效。不要缓存包含无权正文的跨租户中间候选。

一次检索 trace 至少记录:原 query 与改写版本、过滤条件摘要、各路 top-K 的 chunkId/rank/score、RRF 分数、rerank 名次、淘汰原因、最终 sourceIds、token 预算、各阶段 P50/P95/P99、超时和降级标记。敏感正文不应无条件进入日志。

总体 deadline 可分配给改写、并行召回、rerank 和生成。向量分支超时可只用 BM25,rerank 超时可用 RRF,改写超时可用原 query;但 ACL 服务失败时应 fail closed,不能绕过权限继续查询。

面试追问:如何避免慢分支拖垮线程池?

使用独立 bulkhead、连接池和并发上限,传播 deadline 并主动取消无用任务;Java 虚拟线程能降低阻塞线程成本,但不能消除下游容量、超时和背压问题。

常见错误回答: “加 Redis 缓存即可降低延迟。”若键遗漏 ACL 或版本,缓存会制造安全与一致性事故。

Java 21 完整示例:带稳定去重的 RRF 融合

下面示例只用 Java 21 标准库,搜索后端和 reranker 用接口抽象。代码实现加权 RRF、稳定去重、每路名次校验和最终 top-N。它只融合已经通过 ACL 过滤的候选;如果调用方把无权候选传入,RRF 不会替你完成鉴权。

JAVA
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

public final class HybridRetrievalExample {

    public record SearchRequest(
            String query,
            String tenantId,
            String aclVersion,
            String indexVersion,
            int topK) {
        public SearchRequest {
            Objects.requireNonNull(query);
            Objects.requireNonNull(tenantId);
            Objects.requireNonNull(aclVersion);
            Objects.requireNonNull(indexVersion);
            if (topK <= 0) throw new IllegalArgumentException("topK must be positive");
        }
    }

    public record Hit(
            String chunkId,
            String sourceId,
            String text,
            int rank,
            double rawScore) {
        public Hit {
            Objects.requireNonNull(chunkId);
            Objects.requireNonNull(sourceId);
            Objects.requireNonNull(text);
            if (rank <= 0) throw new IllegalArgumentException("rank is 1-based");
        }
    }

    public record RankedChunk(
            String chunkId,
            String sourceId,
            String text,
            double rrfScore,
            Set<String> matchedRetrievers) {
    }

    public interface Retriever {
        /** 实现必须在后端执行 tenant、ACL、状态与 indexVersion 过滤。 */
        List<Hit> search(SearchRequest request);
    }

    public record RetrievalList(String name, double weight, List<Hit> hits) {
        public RetrievalList {
            Objects.requireNonNull(name);
            Objects.requireNonNull(hits);
            if (!(weight > 0.0)) throw new IllegalArgumentException("weight > 0");
        }
    }

    private static final class Accumulator {
        private final Hit representative;
        private double score;
        private final Set<String> retrievers = new HashSet<>();

        private Accumulator(Hit representative) {
            this.representative = representative;
        }
    }

    public static List<RankedChunk> weightedRrf(
            List<RetrievalList> lists,
            int rankConstant,
            int limit) {

        if (rankConstant < 0 || limit <= 0) {
            throw new IllegalArgumentException("rankConstant >= 0 and limit > 0");
        }

        Map<String, Accumulator> byChunkId = new HashMap<>();
        for (RetrievalList list : lists) {
            Set<String> seenInThisList = new HashSet<>();
            int expectedRank = 1;
            for (Hit hit : list.hits()) {
                if (hit.rank() != expectedRank++) {
                    throw new IllegalArgumentException(
                            "ranks must be unique, contiguous and match list order");
                }
                if (!seenInThisList.add(hit.chunkId())) {
                    continue; // 同一路中的重复项不能重复贡献分数
                }
                Accumulator acc = byChunkId.computeIfAbsent(
                        hit.chunkId(), ignored -> new Accumulator(hit));
                acc.score += list.weight() / (rankConstant + hit.rank());
                acc.retrievers.add(list.name());
            }
        }

        Comparator<Accumulator> order = Comparator
                .comparingDouble((Accumulator a) -> a.score).reversed()
                .thenComparing(a -> a.representative.chunkId());

        return byChunkId.values().stream()
                .sorted(order)
                .limit(limit)
                .map(a -> new RankedChunk(
                        a.representative.chunkId(),
                        a.representative.sourceId(),
                        a.representative.text(),
                        a.score,
                        Set.copyOf(a.retrievers)))
                .toList();
    }

    public static List<RankedChunk> retrieve(
            SearchRequest request,
            Retriever bm25,
            Retriever vector,
            int rankConstant,
            int rerankInputLimit) {

        // 生产代码可并行执行,但必须传播同一个 deadline 和安全上下文。
        List<Hit> lexicalHits = bm25.search(request);
        List<Hit> vectorHits = vector.search(request);

        return weightedRrf(List.of(
                new RetrievalList("bm25", 1.0, lexicalHits),
                new RetrievalList("vector", 1.0, vectorHits)),
                rankConstant,
                rerankInputLimit);
    }

    public static void main(String[] args) {
        Retriever bm25 = request -> List.of(
                new Hit("c1", "java-doc", "CompletableFuture timeout", 1, 8.2),
                new Hit("c2", "runbook", "线程池隔离", 2, 6.1));
        Retriever vector = request -> List.of(
                new Hit("c2", "runbook", "线程池隔离", 1, 0.88),
                new Hit("c3", "wiki", "异步任务超时治理", 2, 0.84));

        SearchRequest request = new SearchRequest(
                "Java 异步调用如何设置超时",
                "tenant-a", "acl-17", "index-v8", 20);

        retrieve(request, bm25, vector, 60, 10).forEach(System.out::println);
    }
}

设共有 R 路结果,每路最多 K 条,去重后有 U 个 chunk,最终取 N 条:

  • 累积分数平均时间复杂度为 O(RK),哈希表空间复杂度为 O(U)
  • 示例对全部去重候选排序,时间复杂度为 O(U log U);若 U 很大,可用大小为 N 的最小堆降为 O(U log N)
  • RRF 不读取 rawScore,这正是它能融合异构检索器的原因,也是它丢失分数间距信息的局限;
  • rankConstant 越大,头部名次差异被压平;每路权重、K、常数和 rerank top-N 应联合调参;
  • 外部检索延迟不在算法复杂度内,生产实现应并行调用、限制并发、传播 deadline,并保证两路使用相同 tenantId/aclVersion/indexVersion

场景设计题

场景:设计一个面向企业 Java 故障知识库的低延迟混合检索服务

要求: 支持异常栈、错误码和自然语言问题;多租户强隔离;P95 检索阶段不超过 300 ms;高峰 3000 QPS;reranker 偶尔超时;回答必须带实际证据引用。

推荐回答框架:

  1. API 契约:请求包含 query 与认证上下文,服务端解析租户/ACL;响应返回证据列表、来源、版本、降级标记,不让客户端传任意过滤 DSL。
  2. 查询路由:识别错误码、全限定类名、版本号等确定性特征;BM25 与向量默认并行,保护精确 token;复杂多跳才启用改写/分解。
  3. 安全过滤:过滤条件下推两路检索;ACL 服务不可用时 fail closed;缓存键包含租户、权限摘要、索引与检索配置版本。
  4. 候选预算:例如两路各取 50,经 RRF 去重后 top-40 给 reranker,最终装配 6~10 个证据。数字只是初始假设,必须以 Recall@K、P95 和成本曲线调参。
  5. 隔离与降级:BM25、向量、rerank 使用独立连接池/bulkhead;每路 deadline;向量超时用 BM25,rerank 超时用 RRF;权限检查绝不降级绕过。
  6. 上下文构造:按 chunkId/contentHash/parentId 去重,受控扩展相邻块,限制单文档占比,服务端从最终入选 chunk 生成引用。
  7. 容量估算:3000 QPS、两路各一次即至少 6000 次后端查询/秒,另加改写与 rerank。用峰值并发近似 QPS × P95 秒数 只能做粗估;还要以延迟分布、重试和下游限流压测。若 30% 请求进入 rerank、每次 40 对,则每秒约 3000 × 0.3 × 40 = 36000 个 query-document 对,需要批处理或进一步路由。
  8. 观测与评估:逐阶段记录候选名次、ACL 过滤、RRF、rerank、最终装配与 token;离线比较 Recall@K/MRR/nDCG,线上看延迟、拒答、引用点击和审计样本。

面试时要明确:容量数字不能凭空承诺,必须由具体搜索引擎、embedding/rerank 模型、硬件、副本和压测结果校准。

排障路径

遇到“资料存在但回答没有引用”时按以下顺序定位:

  1. 身份与版本:请求的租户、ACL、时间范围和 indexVersion 是否正确;缓存是否命中旧权限或旧索引?
  2. 原始查询:规范化是否破坏类名、错误码、否定词或版本;改写是否漂移;保留原 query 做对照。
  3. 分路召回:分别查看 BM25 和向量 top-K。两路都没有,回到解析/chunk/embedding;只有一路有,检查候选预算与 analyzer/ANN 参数。
  4. ACL 与状态过滤:正确证据是否因策略、状态、生效时间被过滤;策略正确时不要为提高召回绕过。
  5. 融合chunkId 是否一致;RRF rank 是否从 1 开始;是否把同一路重复结果多次计分;每路 K 是否截断太早?
  6. 重排:正确证据进入 top-N 了吗;reranker 输入是否截断;模型/提示版本是否变化;是否超时降级?
  7. 扩展与去重:contentHash 或 parent 去重是否误杀;相邻扩展是否带来大量重复并挤占预算?
  8. 预算装配:证据是否因 token 估算、来源配额或冲突规则被丢弃;检查 droppedReason
  9. 生成与引用:最终 prompt 是否真实包含 chunk;服务端 sourceId 映射是否成功;不要相信模型自行输出的 URL。

若问题只在线上出现,应使用脱敏 trace 在固定 indexVersion/retrievalVersion/rerankerVersion 下重放,避免拿当前版本复现历史请求得出错误结论。

速记总结

  • 检索目标是在权限、延迟与 token 预算内找到足够证据,不是最大化相似文本数量。
  • BM25 利用 IDF、词频饱和和长度归一化,适合错误码、类名和精确术语。
  • 向量召回覆盖语义近邻;余弦分数不是正确概率,ANN recall 也不等于业务 Recall@K。
  • ACL 应下推到候选生成;无权正文不能进入 reranker、LLM、日志或共享缓存。
  • Query 改写保留原 query,保护精确 token;模型过滤条件只能映射到白名单 schema。
  • RRF 公式是 Σ 1/(c+rank),优势是不比较异构原始分数,局限是忽略分数间距。
  • Reranker 只在小候选集上运行;候选没有正确证据,重排无法补救。
  • 先去重再 parent/相邻扩展,扩展后重新鉴权、去重和计算 token。
  • 引用只绑定实际进入 prompt 的 chunk;证据不足、冲突或问题歧义时允许拒答/澄清。
  • 缓存键包含权限与版本;ACL 失败必须 fail closed,质量组件可以有受测降级路径。

参考资料