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 里都通用。
关注我