从找到段落到有证据的答案在 RAG 中
1. 概述
Retrieval-Augmented Generation (RAG) 通常停留在“找到前 k 个最相似的段落”这一步,然后直接扔进 prompt。这种做法容易产生幻觉,因为向量搜索总是返回某些最相似的东西,即使没有任何段落真正回答了问题。本文探讨了下一步:将候选段落(candidates)转变为可信的证据(evidence),通过混合搜索、reranking 和拒绝回答机制来应对数据不足的情况。读完后,你将能够用 Spring Boot 构建一个小型 pipeline,模拟所有这些步骤,并附带测试来验证行为。
2. 问题:最相似并不意味着正确
假设你有一个聊天机器人,帮助员工查询航班取消时的退款政策。向量搜索总会返回与问题“最相似”的 30 个 passage,即使在数据集中没有任何段落谈论航班取消。如果系统直接把前 3 个段落推送给 LLM,模型会尝试基于关于普通酒店预订的段落“编造”看起来合理的答案。
需要解决的两个问题:
- 筛选:在几十个候选项中,哪些段落对问题真正有帮助。
- 受控拒绝:当没有足够好的段落时,系统应该说“未找到”而不是猜测。
3. 整体 Pipeline
问题
→ 向量搜索(语义) ─┐
→ 关键词搜索(BM25/精确匹配) ├─→ 合并(混合) → Rerank → 置信度阈值 → 证据 → LLM
每一步都逐步缩小数量并提升质量:从数十个粗糙候选项,到仅有几个明确有证据的段落,加上引用来源。
4. 基础数据模型
public record Passage(
String id,
String text,
String source,
double score
) {
public Passage withScore(double newScore) {
return new Passage(id, text, source, newScore);
}
}
Passage 代表一个已索引的文本段落,包含 source 用于引用,以及 score 用于比较/排序。withScore 帮助创建副本时更新分数,而不改变原对象。
5. 混合搜索:结合语义和关键词
嵌入在连接含义方面表现良好(例如“航班取消” ↔ “运输中断”),但容易遗漏精确的 token,如政策代码 POL-2024-07 或专有名词。关键词搜索(BM25)弥补了这个弱点。
public interface VectorSearchClient {
List<Passage> search(String query, int topK);
}
public interface KeywordSearchClient {
List<Passage> search(String query, int topK);
}
@Service
public class HybridSearchService {
private final VectorSearchClient vectorClient;
private final KeywordSearchClient keywordClient;
public HybridSearchService(VectorSearchClient vectorClient,
KeywordSearchClient keywordClient) {
this.vectorClient = vectorClient;
this.keywordClient = keywordClient;
}
public List<Passage> search(String query) {
List<Passage> semantic = vectorClient.search(query, 20);
List<Passage> keyword = keywordClient.search(query, 20);
Map<String, Passage> merged = new LinkedHashMap<>();
for (Passage p : semantic) {
merged.put(p.id(), p);
}
for (Passage p : keyword) {
merged.merge(p.id(), p, (existing, incoming) ->
existing.withScore(existing.score() + incoming.score()));
}
return new ArrayList<>(merged.values());
}
}
当一个 passage 同时出现在两个来源中时,merge 会相加分数——这是一个强信号,表明这个段落既在语义上相关,又在关键词上精确匹配。结果是一个合并的候选列表,尚未按最终有用性排序。
6. Reranking:使用成对比较模型过滤
向量搜索独立地比较问题与每个段落(bi-encoder),所以看不到它们之间的深层关系。Reranker(通常是 cross-encoder)同时接收问题和段落,打分更准确但速度更慢——因此只适用于已通过粗筛的小规模候选项。
public interface Reranker {
List<Passage> rerank(String query, List<Passage> candidates, int topN);
}
@Component
public class CrossEncoderReranker implements Reranker {
private final CrossEncoderClient client; // 调用外部 rerank 模型
public CrossEncoderReranker(CrossEncoderClient client) {
this.client = client;
}
@Override
public List<Passage> rerank(String query, List<Passage> candidates, int topN) {
return candidates.stream()
.map(p -> p.withScore(client.relevanceScore(query, p.text())))
.sorted(Comparator.comparingDouble(Passage::score).reversed())
.limit(topN)
.toList();
}
}
client.relevanceScore 返回问题与每个段落之间的实际关联分数(例如 0–1)。降序排序后,仅保留 topN 个最佳段落——通常是 3 到 5 个,足以放入 prompt 而不至于淡化上下文。
注意: 不要对全部数千个 passage 进行 reranking——cross-encoder 比向量搜索慢几十到几百倍。始终在 reranking 前限制候选输入(20–50)。
7. 为 LLM 构建带引用的证据
在 reranking 后,你有几个高质量的段落。下一步是将它们打包成清晰的上下文,有来源标注,使 LLM 在回答时能引用而不是“信口开河”。
@Component
public class EvidenceBuilder {
public String build(List<Passage> topPassages) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < topPassages.size(); i++) {
Passage p = topPassages.get(i);
sb.append("[来源 %d - %s]%n%s%n%n"
.formatted(i + 1, p.source(), p.text()));
}
return sb.toString();
}
}
每个段落都被编号并标记源名称(source)。合并到系统 prompt 时,你要求 LLM 仅基于这些段落回答,并引用相应的源号——这让用户能验证证据而不是盲目相信答案。
[来源 1 - policy-2024-07.pdf]
如果航班因天气原因被取消,员工可获得 100% 的酒店费用报销...
[来源 2 - policy-2024-07.pdf]
适用条件:航班必须被取消,不适用于延误少于 6 小时的情况...
8. 处理无答案的情况
这是最容易被忽视的部分。每个数据集都有“最近邻”——即使没有段落真正回答问题。如果不检查,LLM 会利用无关的上下文推理出一个听起来合理但错误的答案。
简单的解决方案:基于最高 rerank 分数设置置信度阈值(confidence threshold)。
@Service
public class AnswerService {
private static final double CONFIDENCE_THRESHOLD = 0.55;
private final HybridSearchService searchService;
private final Reranker reranker;
private final EvidenceBuilder evidenceBuilder;
private final LlmClient llmClient;
public AnswerService(HybridSearchService searchService,
Reranker reranker,
EvidenceBuilder evidenceBuilder,
LlmClient llmClient) {
this.searchService = searchService;
this.reranker = reranker;
this.evidenceBuilder = evidenceBuilder;
this.llmClient = llmClient;
}
public String answer(String question) {
List<Passage> candidates = searchService.search(question);
List<Passage> topPassages = reranker.rerank(question, candidates, 4);
boolean hasEvidence = !topPassages.isEmpty()
&& topPassages.get(0).score() >= CONFIDENCE_THRESHOLD;
if (!hasEvidence) {
return "我没有找到明确回答这个问题的政策。"
+ "请联系人力资源部门进行确认。";
}
String context = evidenceBuilder.build(topPassages);
return llmClient.generate(question, context);
}
}
如果 reranking 后最佳段落的分数低于 CONFIDENCE_THRESHOLD,系统拒绝回答而不是调用 LLM 生成无依据的答案。具体阈值应该通过实际数据进行调优——太低会造成幻觉,太高会拒绝应该回答的问题。
9. 行为测试
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
class AnswerServiceTest {
@Test
void shouldAbstainWhenBestScoreBelowThreshold() {
HybridSearchService search = mock(HybridSearchService.class);
Reranker reranker = mock(Reranker.class);
EvidenceBuilder builder = new EvidenceBuilder();
LlmClient llm = mock(LlmClient.class);
Passage weakMatch = new Passage("p1", "常规酒店预订规定",
"hotel-faq.pdf", 0.3);
when(search.search(anyString())).thenReturn(List.of(weakMatch));
when(reranker.rerank(anyString(), anyList(), anyInt()))
.thenReturn(List.of(weakMatch));
AnswerService service = new AnswerService(search, reranker, builder, llm);
String result = service.answer("航班取消时如何退款?");
assertThat(result).contains("没有找到政策");
verifyNoInteractions(llm);
}
@Test
void shouldAnswerWhenConfidenceIsHigh() {
HybridSearchService search = mock(HybridSearchService.class);
Reranker reranker = mock(Reranker.class);
EvidenceBuilder builder = new EvidenceBuilder();
LlmClient llm = mock(LlmClient.class);
Passage strongMatch = new Passage("p2", "如果航班被取消,报销 100% 费用",
"policy-2024-07.pdf", 0.82);
when(search.search(anyString())).thenReturn(List.of(strongMatch));
when(reranker.rerank(anyString(), anyList(), anyInt()))
.thenReturn(List.of(strongMatch));
when(llm.generate(anyString(), anyString())).thenReturn("您可以获得 100% 的报销 [来源 1]。");
AnswerService service = new AnswerService(search, reranker, builder, llm);
String result = service.answer("航班取消时如何退款?");
assertThat(result).contains("来源 1");
verify(llm).generate(anyString(), anyString());
}
}
第一个测试检查分数较低时的拒绝行为——llm 根本不被调用,节省成本并避免幻觉。第二个测试确认在有强证据时,系统调用 LLM,答案包含源引用。
10. 常见错误
- 跳过混合搜索,仅使用纯向量搜索:容易遗漏精确的代码、编号、专有名词的匹配——嵌入对这些区分不好。
- 对整个数据集进行 reranking 而非已筛选的子集:导致不必要的速度下降和成本增加。
- 不设置置信度阈值:即使上下文不相关也总是回答,导致幻觉——用户因为答案“听起来”很自信而难以发现。
- 上下文过长:将 30 个原始段落塞入 prompt 而非 3–5 个已 rerank 的段落,稀释信号并增加 token 成本。
注意: 如果你的领域词汇表稳定,关键词特殊性少(例如普通聊天),混合搜索可能多余——纯向量搜索仍然足够且更简单易运维。
11. 总结
RAG 只有在 retrieval 阶段恰到好处地停止时才值得信赖:合并语义和关键词结果,rerank 以选择真正有用的段落,并在证据不足时拒绝回答。本文中的小型 pipeline——HybridSearchService、Reranker、EvidenceBuilder、AnswerService——是一个框架,你可以将真实的模型(嵌入、BM25 索引、cross-encoder)接入其中,以获得一个具有引用、更诚实的助手,而不是一台单纯的“猜字机”。