从找到段落到有证据的答案在 RAG 中

本文有一个短视频:YouTubeTikTok

1. 概述

Retrieval-Augmented Generation (RAG) 通常停留在“找到前 k 个最相似的段落”这一步,然后直接扔进 prompt。这种做法容易产生幻觉,因为向量搜索总是返回某些最相似的东西,即使没有任何段落真正回答了问题。本文探讨了下一步:将候选段落(candidates)转变为可信的证据(evidence),通过混合搜索、reranking 和拒绝回答机制来应对数据不足的情况。读完后,你将能够用 Spring Boot 构建一个小型 pipeline,模拟所有这些步骤,并附带测试来验证行为。

2. 问题:最相似并不意味着正确

假设你有一个聊天机器人,帮助员工查询航班取消时的退款政策。向量搜索总会返回与问题“最相似”的 30 个 passage,即使在数据集中没有任何段落谈论航班取消。如果系统直接把前 3 个段落推送给 LLM,模型会尝试基于关于普通酒店预订的段落“编造”看起来合理的答案。

需要解决的两个问题:

  1. 筛选:在几十个候选项中,哪些段落对问题真正有帮助。
  2. 受控拒绝:当没有足够好的段落时,系统应该说“未找到”而不是猜测。

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)接入其中,以获得一个具有引用、更诚实的助手,而不是一台单纯的“猜字机”。

ragllmspring-bootsearchjava