RAG 从检索到答案,中间还隔着三道关

索引只是地基。真正决定 RAG 答得准不准的,是用户提问之后的召回、重排与生成——外加一个常被忽略的反馈闭环。这篇把在线链路拆开讲透。

RAG 从检索到答案,中间还隔着三道关

前面两篇讲了 RAG 的”地基”:一篇讲数据清洗(脏数据进索引就救不回来),一篇讲 PDF 怎么切(切不好语义就碎了)。

但很多人搭完索引就以为 RAG 结束了。真实情况是:索引只是把知识摆上架子,用户问一个问题之后,还有一整条链路在决定”答得准不准”。 这条链路我拆成四关:召回、重排、生成、反馈。下面全部用我在医疗文档知识库上落地的代码来讲。

第一关:召回,不是”相似度最高”就完事

向量检索有个天然盲区:它只认语义近,不认”是不是答案”。一个医学问题”二甲双胍的禁忌证有哪些”,向量召回可能把”二甲双胍的用法”和”另一种药的禁忌证”都顶上来,因为字面不完全对应。

我的做法是混合检索 + 融合:向量负责语义,关键词负责精确(药名、术语),有条件再加知识图谱做关系推理。最后用 RRF 把三路结果揉到一起——关键是 RRF 不依赖各路分数量纲,只信”排名”。

// 混合检索:向量 + 关键词,再用 RRF 融合
public RetrievalResult hybridSearch(ProcessedQuery query) {
    var vectorResults = vectorStore.similaritySearch(SearchRequest.builder()
        .query(query.getNormalized()).topK(20)
        .similarityThreshold(0.65).filters(query.getFilters()).build());
    var keywordResults = keywordEngine.search(query.getExpandedQueries(), query.getFilters());

    // RRF:按排名倒数相加,不依赖各自分数量纲
    return RetrievalResult.builder()
        .results(reciprocalRankFusion(vectorResults, keywordResults))
        .build();
}

// RRF 融合公式:score = Σ 1/(k + rank),k 通常取 60
private List<RetrievalResult> reciprocalRankFusion(List<RetrievalResult>... lists) {
    Map<String, Double> score = new HashMap<>();
    for (List<RetrievalResult> list : lists) {
        for (int rank = 0; rank < list.size(); rank++) {
            String id = list.get(rank).getId();
            score.merge(id, 1.0 / (60 + rank), Double::sum);
        }
    }
    return score.entrySet().stream()
        .sorted(comparingByValue().reversed()).limit(20)
        .map(e -> resultMap.get(e.getKey())).toList();
}

两个经验值:topK(20) 先多召一些,留给后面重排筛;similarityThreshold(0.65) 是噪声闸门——低于它的直接不进候选,否则后面的重排会被垃圾淹没。

第二关:重排,把召回的 Top-20 再筛一遍

向量召回的”近”不等于”相关”。比如问”孕妇能用吗”,召回里”用药安全”段落向量很近,但真正回答的是”禁忌人群”那段。这就是为什么召回之后必须重排。

我用两阶段:先用 Cross-Encoder(如 BGE-reranker)对 query↔doc 逐对打分,比向量准得多;再只对 Top-10 用 LLM 精排。LLM 精排贵,所以只喂 Top-10,成本可控。

// 两阶段重排:Cross-Encoder 粗排 → LLM 精排 Top-10
public List<RetrievalResult> rerank(String query, List<RetrievalResult> candidates) {
    if (candidates.size() <= 10) return candidates;

    // 第一阶段:Cross-Encoder 对 query↔doc 打分(比向量召回更准)
    var scored = candidates.parallelStream()
        .map(c -> new ScoredResult(c, crossEncoder.score(query, c.getContent())))
        .sorted(comparing(ScoredResult::score).reversed())
        .limit(20).toList();

    // 第二阶段:只对 Top-10 用 LLM 精排,控制成本
    if (scored.size() > 10) {
        return llmRerank(query, scored.subList(0, 10));
    }
    return scored.stream().map(ScoredResult::result).toList();
}

一句话记忆:召回解决”有没有”,重排解决”对不对”。没有重排的 RAG,答案质量上限基本被向量空间锁死。

第三关:上下文组装,把检索结果变成 LLM 能用的东西

召回+重排拿到一堆块,但还不能一股脑塞给 LLM。要做三件事:去重(同一段被多路召回会重复)、排序(按重排分)、压缩(把每块压到原文的 ~30%,去掉和查询无关的句子)、最后按 token 预算截断。

// 上下文组装:去重 → 排序 → 压缩 → 按 token 预算截断
public Context buildContext(String query, List<RetrievalResult> results, int maxTokens) {
    var sorted = deduplicate(results).stream()
        .sorted(comparing(RetrievalResult::rerankScore).reversed()).toList();

    StringBuilder ctx = new StringBuilder();
    int used = 0;
    for (var chunk : compress(sorted)) {           // 压缩到原文 ~30%
        int t = calculateTokens(chunk.getContent());
        if (used + t > maxTokens) break;            // 超 4000 token 截断
        ctx.append(chunk.getContent()).append("\n\n");
        used += t;
    }
    return Context.builder().content(ctx.toString())
        .sources(formatSources(selected)).build();
}

为什么必须压缩和截断?长上下文不等于好答案——无关文本会稀释 LLM 对关键证据的注意力,这是实测会掉点的。我的预算上限是 4000 token,再往上边际收益骤降。

第四关:生成,以及最容易被忘的来源标注

生成本身不复杂,但有三个底线:低温度(医疗场景我用 0.3,保准确不保创意)、引用标注(让用户能点回原文,这是 RAG 可信度的命根子)、矛盾检测(同一问题不同段落说法冲突时给警告)。

public GeneratedAnswer generate(String query, Context context, PatientInfo patient) {
    String prompt = templateEngine.render(template, Map.of(
        "query", query, "context", context.getContent(), "sources", context.getSources()));

    String answer = chatClient.prompt().user(prompt)
        .options(ChatOptions.builder().temperature(0.3).maxTokens(2000).build())  // 低温度保准确
        .call().content();

    answer = addCitations(answer, context.getSources());   // 标注来源,可点回原文
    return GeneratedAnswer.builder().content(addDisclaimer(answer, query, patient)).build();
}

来源标注看着是小事,实则是 RAG 和”瞎编”的分界线。没有它,模型答错你都无从核对;有了它,用户可以自己翻原文判断。

第五关:反馈闭环,RAG 是系统不是脚本

前面四关是一次性链路,但线上跑起来一定有答错的时候。把”用户点赞/点踩”接进来,按失败原因分流处理,RAG 才真正闭环:

  • retrieval_failure → 调检索策略(阈值、权重)
  • knowledge_gap → 补知识库(这才是知识库该动态生长的方式)
  • generation_error → 记录,用于后续微调

没有这一环,你的 RAG 永远是”上线那天的能力”,不会随使用变好。

一张表记住四个阶段

阶段核心组件关键技术
索引解析 / 分块 / Embedding / 向量库语义分块、BGE·E5、HNSW
检索查询理解 / 混合召回 / 重排实体识别、RRF、Cross-Encoder
生成上下文组装 / Prompt / LLM压缩去重、引用标注、低温度
反馈闭环优化失败归因、知识库更新

配置速查

我把关键参数抽到了 application.yml,调优时不用改代码:

rag:
  indexing:
    chunk: { size: 800, overlap: 100, min-chunk-size: 200 }
    embedding: { model: "intfloat/e5-large-v2", batch-size: 32 }
    vector-store: { type: "pinecone", dimension: 1024, metric: "cosine" }
  retrieval:
    top-k: 20
    similarity-threshold: 0.65
    hybrid: { vector-weight: 0.6, keyword-weight: 0.3, graph-weight: 0.1 }
    rerank: { enable: true, top-k: 10 }
  generation:
    max-tokens: 2000
    temperature: 0.3
    context: { max-tokens: 4000 }

小结

RAG 经常被讲成”切文档 + 塞向量库”,但索引只是地基。真正的检索质量发生在用户提问之后:召回够不够全、重排够不够准、上下文够不够干净、来源标得够不够清楚。前三篇(清洗、切片、本文)拼起来,才是一个能上生产的 RAG。上面这套我在医疗文档上打磨,但召回/重排/引用标注这几个关,在任何领域的 RAG 里都通用。

奇妙感 本文采用署名-非商业性使用-相同方式共享协议,转载请注明出处与作者。

目录