系列文章
本系列深入拆解 AI Agent 的核心能力模块,从原理到实战,构建完整的 Agent 知识体系:
- AI Agent 的记忆系统:从 ChatMemory 到持久化记忆的 Java 实战
- AI Agent 的记忆力是怎么实现的——LangChain4j Memory 机制深度解析
- MCP 模型上下文协议:AI 的万能接口与 MCP Server 实战
- AI Agent 的工具箱:深入理解 Tool Use 与 Spring AI Function Calling 实战
- 让 AI 学会说人话:Spring AI 结构化输出实战
- AI Agent 的规划大脑:从任务分解到自适应执行策略
- AI Agent 的灵魂对话:Prompt Engineering 系统提示词设计的艺术与工程
- 理解 AI Agent 的大脑:ReAct 模式从入门到实战
- 从零理解 RAG:检索增强生成完整指南
- Embedding 向量化的魔法:从文本到向量的数学之旅与 Java 实战
- 当 RAG 遇上知识图谱:GraphRAG 原理与 Java 实战
- 当 RAG 遇到 Agent:Agentic RAG 的架构设计与 Java 实战
- AI Agent 团队协作:多 Agent 系统架构设计与 Java 实战
- AI Agent 评估与优化:从基准测试到生产环境的质量守护实战
- AI Agent 的知识检索引擎:从向量搜索到智能检索策略的 Java 实战(本文)
前言:检索——Agent 系统的”第二大脑”
在之前的文章中,我们已经了解了 RAG 的基本原理(从零理解 RAG)、向量化的数学基础(Embedding 向量化的魔法),以及 Agent 如何通过 ReAct 循环(理解 AI Agent 的大脑)来使用工具完成任务。
但在实际的企业级场景中,”把文档切成块,向量化,然后做相似度搜索”这种朴素的 RAG 方案,远远不够。
想象一个真实场景:你是一家金融公司的技术负责人,要构建一个合规审查 Agent。用户问:”我们去年Q3发行的那个结构性产品,它的风险披露条款是否符合最新的 SEC 规定?”
这个问题有几个难点:
- 指代消解:”那个结构性产品”到底是哪个?Agent 需要从上下文或用户历史中推断出来
- 时间推理:”去年Q3”意味着要检索特定时间段的文档
- 多文档关联:需要同时找到”产品条款”和”SEC 规定”两份文档,然后做对比
- 精确性要求:合规审查不允许”大致正确”,每一句话都要有出处
朴素的向量搜索面对这种场景,表现往往是灾难性的。它可能返回一堆”看起来相关”但实际答非所问的文档片段。
这篇文章要解决的核心问题:如何构建一个真正智能的知识检索引擎,让 Agent 不只是”找到相关的”,而是”找到正确的”。
我们将从最基础的向量检索出发,逐步覆盖查询改写、混合检索、重排序、多跳推理、检索质量评估等完整链路,最终用 Spring AI 搭建一个生产级的检索系统。
第一章:向量检索的天花板——为什么朴素 RAG 不够用
1.1 朴素 RAG 的三宗罪
我们先回顾一下朴素 RAG 的工作流程:
1
| 用户提问 → Embedding → 向量搜索 Top-K → 拼接上下文 → LLM 生成回答
|
这个流程看起来很优雅,但实际使用中有三个致命缺陷:
第一宗:语义鸿沟(Semantic Gap)
用户的问题和文档的表述之间,经常存在巨大的语义鸿沟。比如用户问”服务器挂了怎么办”,但文档写的是”主机宕机应急处理方案”。虽然意思完全一样,但字面差异很大,向量搜索的召回率会很低。
这不是 Embedding 模型的问题——即使是最好的模型,也无法完全弥合日常用语和技术术语之间的鸿沟。
第二宗:上下文丢失(Context Loss)
向量搜索是逐块匹配的。当你把一篇 5000 字的技术文档切成 20 个 chunk 时,每个 chunk 只保留了局部信息。用户问一个需要综合全文的问题时(比如”这篇文档的核心观点是什么”),没有任何单个 chunk 能完整回答。
第三宗:排序错觉(Ranking Illusion)
向量搜索返回的”最相似”不等于”最有用”。相似度高只意味着语义接近,但不意味着能回答用户的问题。一个与问题高度相关但缺少关键数据的 chunk,可能排在一个相关度略低但包含完整答案的 chunk 前面。
1.2 真实数据说话
为了让你直观感受朴素 RAG 的局限,我用一个内部知识库做了测试。这个知识库包含 3000 篇技术文档,测试集有 200 个问题,每个问题都有人工标注的标准答案。
| 指标 |
朴素 RAG(Top-5) |
优化后 RAG |
提升幅度 |
| 命中率(答案在 Top-5 中) |
62% |
89% |
+43% |
| 精确率(Top-3 中有效内容占比) |
45% |
78% |
+73% |
| 端到端正确率 |
51% |
82% |
+61% |
优化前后的差距非常明显。问题不在于向量搜索”不好”,而在于它只是检索链路中的一环,单独使用远远不够。
第二章:查询改写——让 Agent 学会”正确提问”
2.1 为什么需要查询改写
检索质量的第一道瓶颈,往往不在搜索引擎本身,而在查询的质量。
用户天然会用模糊、口语化、包含指代的方式提问。这些提问直接扔给向量搜索,效果必然不好。查询改写(Query Rewriting)的核心思想是:在搜索之前,先把用户的”自然语言问题”转换成”搜索优化的查询”。
这就像你去图书馆找书,直接问管理员”有没有讲那个什么设计模式的书”效果肯定不好。但如果你先想清楚要找的是”GoF 设计模式 Java 实现”,检索效率就完全不同了。
2.2 四种核心改写策略
策略一:HyDE(Hypothetical Document Embeddings)
HyDE 的思路非常巧妙:既然用户的问题和文档的表述存在语义鸿沟,那就让 LLM 先假想一个理想答案,然后用这个假想答案去做向量搜索。
为什么有效?因为假想答案的表述风格和知识库文档更接近(都是陈述句、都包含专业术语),语义匹配度自然更高。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
|
@Component public class HyDEQueryRewriter {
private final ChatClient chatClient;
public HyDEQueryRewriter(ChatClient.Builder chatClientBuilder) { this.chatClient = chatClientBuilder .defaultSystem("你是一个技术文档专家。请根据用户的问题,生成一段可能的" + "参考答案(200字以内)。不需要准确,只需要在风格和术语上" + "与技术文档保持一致。不要添加任何解释,直接输出假想答案。") .build(); }
public String rewriteToHypotheticalDocument(String query) { return chatClient.prompt() .user(query) .call() .content(); }
public List<Document> searchWithHyDE(String query, VectorStore vectorStore, int topK) { String hypotheticalDoc = rewriteToHypotheticalDocument(query);
SearchRequest request = SearchRequest.query(hypotheticalDoc) .withTopK(topK);
return vectorStore.similaritySearch(request); } }
|
HyDE 的局限性:如果 LLM 对问题领域完全不了解(比如冷门行业的专业术语),生成的假想答案可能是错误的,反而会把检索带偏。这时候需要结合下面的策略。
策略二:查询分解(Query Decomposition)
对于复杂问题,一次搜索很难找到所有需要的信息。查询分解的核心思路是:把一个复杂问题拆解成多个简单的子问题,分别检索,然后综合结果。
比如用户问:”Spring AI 和 LangChain4j 在 RAG 支持和工具调用方面有什么区别?”
分解后:
- 子问题 1:”Spring AI 的 RAG 功能和 API 是什么?”
- 子问题 2:”LangChain4j 的 RAG 功能和 API 是什么?”
- 子问题 3:”Spring AI 的 Function Calling 机制是什么?”
- 子问题 4:”LangChain4j 的 Tool Execution 机制是什么?”
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| @Component public class QueryDecomposer {
private final ChatClient chatClient;
public QueryDecomposer(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一个搜索查询优化专家。将用户的复杂问题分解为" + "2-5个独立的子问题,每个子问题应该聚焦于一个具体的信息点。" + "用 JSON 数组格式返回,不要包含任何解释。" + "示例:[\"子问题1\", \"子问题2\", \"子问题3\"]") .build(); }
public List<String> decompose(String query) { String response = chatClient.prompt() .user(query) .call() .content();
return JsonParser.parseList(response); }
public List<Document> searchWithDecomposition(String query, VectorStore vectorStore, int topKPerSubQuery) { List<String> subQueries = decompose(query); List<Document> allResults = new ArrayList<>();
for (String subQuery : subQueries) { SearchRequest request = SearchRequest.query(subQuery) .withTopK(topKPerSubQuery); allResults.addAll(vectorStore.similaritySearch(request)); }
return deduplicateByContent(allResults); }
private List<Document> deduplicateByContent(List<Document> docs) { Map<String, Document> unique = new LinkedHashMap<>(); for (Document doc : docs) { String key = doc.getText().substring(0, Math.min(200, doc.getText().length())); unique.putIfAbsent(key, doc); } return new ArrayList<>(unique.values()); } }
|
策略三:Step-Back Prompting(退后一步提问)
这个策略的灵感来自人类的思考方式:当直接回答一个问题太难时,我们往往会先退后一步,问一个更宏观的问题。
比如用户问:”Spring AI 1.0 中 ChatClient 的 advisors() 方法有什么 bug?”
直接搜索可能找不到。但如果先退后一步,搜索”Spring AI 1.0 ChatClient API 变更日志”或者”Spring AI 1.0 已知问题”,往往能找到答案所在的文档,然后再从中定位具体信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Component public class StepBackRewriter {
private final ChatClient chatClient;
public StepBackRewriter(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一个搜索策略专家。给定一个具体的用户问题," + "生成一个更宏观、更通用的背景问题,帮助检索到回答原问题" + "所需的背景知识。只输出改写后的问题,不要解释。") .build(); }
public String rewrite(String originalQuery) { return chatClient.prompt() .user(originalQuery) .call() .content(); } }
|
策略四:多查询融合(Multi-Query Fusion)
这是最实用的策略之一:用 LLM 生成多个不同角度的查询,分别检索,然后合并结果。类似于搜索引擎的”相关搜索”功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| @Component public class MultiQueryRewriter {
private final ChatClient chatClient;
public MultiQueryRewriter(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一个搜索查询专家。给定用户问题,从3个不同角度" + "生成搜索查询,确保覆盖问题的不同方面。" + "用 JSON 数组返回,不要解释。") .build(); }
public List<String> generateAlternativeQueries(String query) { String response = chatClient.prompt() .user(query) .call() .content(); return JsonParser.parseList(response); }
public List<Document> searchWithFusion(String query, VectorStore vectorStore, int topK) { List<String> queries = new ArrayList<>(); queries.add(query); queries.addAll(generateAlternativeQueries(query));
Map<String, Double> docScores = new HashMap<>(); for (String q : queries) { List<Document> results = vectorStore.similaritySearch( SearchRequest.query(q).withTopK(topK * 2));
for (int i = 0; i < results.size(); i++) { String docId = results.get(i).getText().substring(0, Math.min(100, results.get(i).getText().length())); double rrfScore = 1.0 / (60 + i); docScores.merge(docId, rrfScore, Double::sum); } }
return docScores.entrySet().stream() .sorted(Map.Entry.<String, Double>comparingByValue().reversed()) .limit(topK) .map(entry -> new Document(entry.getKey(), Map.of("rrf_score", entry.getValue()))) .collect(Collectors.toList()); } }
|
2.3 查询改写策略怎么选?
| 策略 |
适用场景 |
延迟开销 |
效果提升 |
| HyDE |
问题与文档表述差异大 |
+1 LLM 调用 |
召回率 +20-30% |
| 查询分解 |
多方面、多维度的复杂问题 |
+1 LLM 调用 + 多次检索 |
覆盖率 +40% |
| Step-Back |
需要背景知识的专业问题 |
+1 LLM 调用 |
精确率 +15-25% |
| 多查询融合 |
通用场景,不确定最佳查询方式 |
+1 LLM 调用 + 多次检索 |
综合指标 +25-35% |
最佳实践:在生产环境中,通常组合使用。先用”多查询融合”做第一轮召回,再用 HyDE 或 Step-Back 做补充检索。
第三章:混合检索——向量 + 关键词的双引擎
3.1 为什么单靠向量不够
向量检索擅长语义匹配,但在以下场景表现不佳:
- 精确匹配:搜索”ERR-4012 错误码”,向量检索可能返回一堆”错误处理”相关的文档,而不是精确包含”ERR-4012”的那篇
- 专有名词:人名、产品名、型号等,向量检索可能无法精确区分
- 否定查询:”不包含 Java 的编程语言列表”,向量检索往往忽略”不”这个关键词
传统的关键词检索(BM25)在这些场景下反而表现更好。BM25 是一种基于词频和逆文档频率的排序算法,它不理解语义,但对精确匹配非常擅长。
混合检索的核心思想:同时用向量检索和关键词检索,然后融合两路结果。
3.2 BM25 + 向量检索的融合
Spring AI 原生支持向量检索,但 BM25 需要我们自己实现。好在 Apache Lucene 提供了现成的 BM25 实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
|
@Component public class HybridRetriever {
private final VectorStore vectorStore; private final BM25Retriever bm25Retriever; private final double vectorWeight; private final double bm25Weight;
public HybridRetriever(VectorStore vectorStore, BM25Retriever bm25Retriever, @Value("${retrieval.hybrid.vector-weight:0.6}") double vectorWeight, @Value("${retrieval.hybrid.bm25-weight:0.4}") double bm25Weight) { this.vectorStore = vectorStore; this.bm25Retriever = bm25Retriever; this.vectorWeight = vectorWeight; this.bm25Weight = bm25Weight; }
public List<ScoredDocument> hybridSearch(String query, int topK) { List<Document> vectorResults = vectorStore.similaritySearch( SearchRequest.query(query).withTopK(topK * 2));
List<Document> bm25Results = bm25Retriever.search(query, topK * 2);
return reciprocalRankFusion(vectorResults, bm25Results, topK); }
private List<ScoredDocument> reciprocalRankFusion( List<Document> vectorResults, List<Document> bm25Results, int topK) {
Map<String, Double> scores = new HashMap<>(); Map<String, Document> docMap = new HashMap<>(); int k = 60;
for (int i = 0; i < vectorResults.size(); i++) { String key = contentHash(vectorResults.get(i)); scores.merge(key, vectorWeight / (k + i), Double::sum); docMap.putIfAbsent(key, vectorResults.get(i)); }
for (int i = 0; i < bm25Results.size(); i++) { String key = contentHash(bm25Results.get(i)); scores.merge(key, bm25Weight / (k + i), Double::sum); docMap.putIfAbsent(key, bm25Results.get(i)); }
return scores.entrySet().stream() .sorted(Map.Entry.<String, Double>comparingByValue().reversed()) .limit(topK) .map(e -> new ScoredDocument(docMap.get(e.getKey()), e.getValue())) .collect(Collectors.toList()); }
private String contentHash(Document doc) { return doc.getText().substring(0, Math.min(200, doc.getText().length())); } }
|
3.3 BM25 检索器的实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
|
@Component public class BM25Retriever {
private final Directory ramDirectory; private final IndexSearcher searcher; private static final String CONTENT_FIELD = "content";
public BM25Retriever(List<Document> allDocuments) throws IOException { this.ramDirectory = new RAMDirectory(); IndexWriterConfig config = new IndexWriterConfig( new StandardAnalyzer()); config.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
try (IndexWriter writer = new IndexWriter(ramDirectory, config)) { for (Document doc : allDocuments) { org.apache.lucene.document.Document luceneDoc = new org.apache.lucene.document.Document(); luceneDoc.add(new TextField(CONTENT_FIELD, doc.getText(), Field.Store.YES)); luceneDoc.add(new StoredField("metadata", doc.getMetadata().toString())); writer.addDocument(luceneDoc); } }
IndexReader reader = DirectoryReader.open(ramDirectory); this.searcher = new IndexSearcher(reader); searcher.setSimilarity(new BM25Similarity()); }
public List<Document> search(String query, int topK) throws IOException { QueryParser parser = new QueryParser(CONTENT_FIELD, new StandardAnalyzer()); Query luceneQuery = parser.parse(QueryParser.escape(query));
TopDocs topDocs = searcher.search(luceneQuery, topK); List<Document> results = new ArrayList<>();
for (ScoreDoc scoreDoc : topDocs.scoreDocs) { org.apache.lucene.document.Document luceneDoc = searcher.doc(scoreDoc.doc); results.add(new Document( luceneDoc.get(CONTENT_FIELD), Map.of("bm25_score", scoreDoc.score) )); }
return results; } }
|
3.4 混合检索的权重调优
向量和 BM25 的权重怎么调?这取决于你的数据特点:
| 数据特征 |
推荐权重 (向量:BM25) |
原因 |
| 技术文档、API 文档 |
50:50 |
既有语义理解需求,也有精确匹配需求 |
| 客服 FAQ |
70:30 |
用户表述多样,语义匹配更重要 |
| 法律/合规文档 |
40:60 |
精确术语和条款号至关重要 |
| 产品手册 |
60:40 |
产品名需要精确匹配,描述需要语义理解 |
调优方法:准备一个标注测试集(50-100 个问题+标准答案),用 grid search 遍历权重组合,选择命中率最高的配置。
第四章:重排序(Reranking)——从”相关”到”正确”
4.1 为什么需要重排序
检索(Retrieval)和重排序(Reranking)是两个不同的阶段:
- 检索阶段:从百万级文档中快速筛选出 Top-K 候选(要求速度快,允许粗粒度)
- 重排序阶段:对 Top-K 候选做精细排序(允许慢一点,要求精度高)
为什么不能直接用检索阶段的分数?因为向量检索用的是双塔模型(Bi-Encoder),查询和文档分别编码后再算相似度。这种架构速度快,但精度有限——它无法捕捉查询和文档之间的细粒度交互。
重排序用的是交叉编码器(Cross-Encoder),它把查询和文档拼接在一起输入模型,能直接建模两者之间的关系。精度高,但速度慢——所以只对 Top-K 候选做,而不是全部文档。
打个比方:检索阶段像”海选”,快速从 10000 人中选出 20 个候选人;重排序像”终面”,对 20 个候选人做深度评估,选出最合适的 3 个。
4.2 三种重排序方案
方案一:基于 LLM 的重排序
最直接的方式:让 LLM 对每个候选文档打分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| @Component public class LLMReranker {
private final ChatClient chatClient;
public LLMReranker(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一个文档相关性评估专家。给定用户问题和候选文档," + "评估文档对回答该问题的帮助程度。返回 1-10 的分数," + "10 表示完全相关且包含完整答案,1 表示完全不相关。" + "只返回数字分数,不要解释。") .build(); }
public List<ScoredDocument> rerank(String query, List<Document> candidates) { List<ScoredDocument> scored = candidates.stream() .map(doc -> { String prompt = String.format( "问题:%s\n\n文档内容:\n%s\n\n相关性分数(1-10):", query, truncate(doc.getText(), 1500));
int score = parseScore( chatClient.prompt().user(prompt).call().content()); return new ScoredDocument(doc, score); }) .sorted(Comparator.comparingDouble( ScoredDocument::score).reversed()) .collect(Collectors.toList());
return scored; }
private int parseScore(String response) { try { return Integer.parseInt(response.replaceAll("[^0-9]", "")); } catch (NumberFormatException e) { return 5; } }
private String truncate(String text, int maxLen) { return text.length() > maxLen ? text.substring(0, maxLen) + "..." : text; } }
|
LLM 重排序的缺点:延迟高(每个候选都需要一次 LLM 调用)、成本高。对于 Top-20 的候选,就需要 20 次 API 调用。
方案二:基于 Cross-Encoder 的重排序
使用专门的重排序模型(如 BGE-Reranker、Cohere Rerank),速度比 LLM 快 10-100 倍。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
|
@Component public class CrossEncoderReranker {
private final RestClient restClient; private final String apiKey; private final String model;
public CrossEncoderReranker( @Value("${reranker.cohere.api-key}") String apiKey, @Value("${reranker.cohere.model:rerank-multilingual-v3.0}") String model) { this.apiKey = apiKey; this.model = model; this.restClient = RestClient.builder() .baseUrl("https://api.cohere.com") .build(); }
public List<ScoredDocument> rerank(String query, List<Document> candidates, int topN) { Map<String, Object> body = Map.of( "model", model, "query", query, "documents", candidates.stream() .map(Document::getText) .collect(Collectors.toList()), "top_n", topN, "return_documents", false );
CohereRerankResponse response = restClient.post() .uri("/v1/rerank") .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .body(body) .retrieve() .body(CohereRerankResponse.class);
return response.results().stream() .map(r -> new ScoredDocument( candidates.get(r.index()), r.relevanceScore())) .collect(Collectors.toList()); } }
|
方案三:基于规则的轻量级重排序
当不想引入额外模型时,可以用规则做轻量级重排序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| @Component public class RuleBasedReranker {
public List<ScoredDocument> rerank(String query, List<Document> candidates, List<Document> conversationHistory) { return candidates.stream() .map(doc -> { double score = 0;
score += getOriginalScore(doc) * 0.4;
score += keywordMatchScore(query, doc.getText()) * 0.25;
score += freshnessScore(doc) * 0.15;
score += contextRelevanceScore(conversationHistory, doc) * 0.2;
return new ScoredDocument(doc, score); }) .sorted(Comparator.comparingDouble( ScoredDocument::score).reversed()) .collect(Collectors.toList()); }
private double keywordMatchScore(String query, String content) { Set<String> queryTerms = Set.of(query.toLowerCase().split("\\s+")); String contentLower = content.toLowerCase();
long matchCount = queryTerms.stream() .filter(contentLower::contains) .count();
return (double) matchCount / queryTerms.size(); }
private double freshnessScore(Document doc) { Object dateObj = doc.getMetadata().get("date"); if (dateObj == null) return 0.5;
LocalDate docDate = LocalDate.parse(dateObj.toString()); long daysOld = ChronoUnit.DAYS.between(docDate, LocalDate.now());
return Math.max(0.1, 1.0 - (daysOld / 365.0)); }
private double contextRelevanceScore(List<Document> history, Document candidate) { if (history.isEmpty()) return 0.5;
String recentContext = history.stream() .map(Document::getText) .collect(Collectors.joining(" ")) .substring(0, Math.min(500, history.stream().mapToInt(d -> d.getText().length()).sum()));
return keywordMatchScore(recentContext, candidate.getText()); } }
|
4.3 重排序方案对比
| 方案 |
精度 |
延迟 |
成本 |
部署复杂度 |
| LLM Reranker |
★★★★★ |
2-10s |
高 |
低 |
| Cross-Encoder |
★★★★☆ |
100-500ms |
中 |
中 |
| 规则 Reranker |
★★★☆☆ |
<10ms |
零 |
低 |
推荐策略:先用规则 Reranker 做粗排(去掉明显不相关的),再用 Cross-Encoder 做精排。对精度要求极高的场景,最后再用 LLM Reranker 做 Top-3 精选。
第五章:多跳检索——让 Agent 学会”追问”
5.1 什么是多跳检索
很多真实问题无法通过一次检索解决。比如:
“Spring AI 的 ChatClient 和 OpenAI 的 Assistants API 在 RAG 实现上有什么区别?”
要回答这个问题,Agent 需要:
- 第一跳:检索 Spring AI ChatClient 的 RAG 实现方式
- 第二跳:检索 OpenAI Assistants API 的 RAG 实现方式
- 第三跳(可选):基于前两跳的结果,检索两者的对比分析
每一跳的检索都依赖前一跳的结果。这就是多跳检索(Multi-Hop Retrieval)。
5.2 基于 ReAct 的迭代检索
多跳检索的最自然实现方式,就是利用 Agent 的 ReAct 循环(详见理解 AI Agent 的大脑):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
|
@Component public class IterativeRetrievalAgent {
private final ChatClient chatClient; private final HybridRetriever retriever; private final CrossEncoderReranker reranker; private final int maxIterations;
public IterativeRetrievalAgent( ChatClient.Builder builder, HybridRetriever retriever, CrossEncoderReranker reranker, @Value("${retrieval.max-iterations:3}") int maxIterations) { this.chatClient = builder.build(); this.retriever = retriever; this.reranker = reranker; this.maxIterations = maxIterations; }
public RetrievalResult iterativeRetrieve(String question) { List<Document> allEvidence = new ArrayList<>(); List<String> reasoningSteps = new ArrayList<>();
String currentQuery = question;
for (int i = 0; i < maxIterations; i++) { List<ScoredDocument> results = retriever.hybridSearch( currentQuery, 10);
List<ScoredDocument> reranked = reranker.rerank( currentQuery, results.stream().map(ScoredDocument::doc) .collect(Collectors.toList()), 5);
for (ScoredDocument sd : reranked) { if (!isDuplicate(sd.doc(), allEvidence)) { allEvidence.add(sd.doc()); } }
ThoughtAction thought = analyzeAndDecide( question, allEvidence, reasoningSteps);
reasoningSteps.add(thought.reasoning());
if (thought.hasEnoughInfo()) { break; }
currentQuery = thought.nextQuery(); }
return new RetrievalResult(allEvidence, reasoningSteps); }
private ThoughtAction analyzeAndDecide(String question, List<Document> evidence, List<String> previousSteps) { String evidenceText = evidence.stream() .map(Document::getText) .collect(Collectors.joining("\n---\n"));
String prompt = String.format(""" 你是一个信息分析专家。根据已收集的信息,判断是否足以回答用户问题。 用户问题:%s 已收集的信息: %s 之前的分析步骤: %s 请以 JSON 格式返回: { "reasoning": "你的分析推理过程", "hasEnoughInfo": true/false, "nextQuery": "如果信息不足,下一步要搜索什么(null如果信息足够)", "gaps": ["还缺少哪些关键信息"] } """, question, evidenceText, String.join("\n", previousSteps));
String response = chatClient.prompt() .user(prompt) .call() .content();
return parseThoughtAction(response); } }
|
5.3 父子文档检索(Parent Document Retrieval)
多跳检索的一个重要优化技巧:检索时用小块(精准匹配),返回时用大块(完整上下文)。
原理:把文档切成小块用于向量检索(精确匹配),但每个小块都保留对父文档(完整段落或章节)的引用。当某个小块被命中时,返回它所属的父文档,让 LLM 看到更完整的上下文。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
|
@Component public class ParentChildRetriever {
private final VectorStore childStore; private final Map<String, Document> parentMap;
public ParentChildRetriever(VectorStore childStore, List<Document> allDocuments) { this.childStore = childStore; this.parentMap = new HashMap<>();
for (Document doc : allDocuments) { String parentId = doc.getMetadata().get("parent_id").toString(); parentMap.putIfAbsent(parentId, doc); } }
public List<Document> retrieveWithParentContext(String query, int topK) { List<Document> childResults = childStore.similaritySearch( SearchRequest.query(query).withTopK(topK));
Set<String> seenParents = new LinkedHashSet<>(); List<Document> parentResults = new ArrayList<>();
for (Document child : childResults) { String parentId = child.getMetadata() .get("parent_id").toString(); if (seenParents.add(parentId)) { Document parent = parentMap.get(parentId); if (parent != null) { parentResults.add(parent); } } }
return parentResults; } }
|
这个技巧为什么有效?打个比方:你在一个图书馆找关于”量子计算”的书。索引系统帮你精确找到了第 156 页的某个段落(子块),但你需要的是整本书的第三章(父块),因为问题的答案分散在第三章的多个段落中。
第六章:检索质量评估——知道什么时候”检索失败”
6.1 检索失败的代价
在 RAG 系统中,检索失败比不检索更危险。如果检索到错误或不相关的文档,LLM 可能会基于错误信息生成看似合理但实际错误的回答——这就是所谓的”幻觉”。
关键洞察:LLM 有一种”过度自信”的倾向——即使上下文中的信息是错误的或不相关的,它也会尝试基于这些信息来回答,而不是说”我不知道”。
所以,评估检索质量并在检索失败时优雅降级,是生产级 RAG 系统的核心能力。
6.2 检索质量评估器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
|
@Component public class RetrievalQualityEvaluator {
private final ChatClient chatClient; private final double qualityThreshold;
public RetrievalQualityEvaluator( ChatClient.Builder builder, @Value("${retrieval.quality.threshold:0.6}") double threshold) { this.chatClient = builder .defaultSystem("你是一个信息质量评估专家。评估给定的参考文档" + "是否足以准确回答用户的问题。不要尝试回答问题," + "只评估文档的质量和相关性。") .build(); this.qualityThreshold = threshold; }
public QualityAssessment evaluate(String question, List<Document> documents) { String docTexts = documents.stream() .map(d -> "- " + d.getText().substring(0, Math.min(300, d.getText().length()))) .collect(Collectors.joining("\n"));
String prompt = String.format(""" 请评估以下参考文档能否回答用户问题。 用户问题:%s 参考文档: %s 请以 JSON 格式返回评估结果: { "qualityScore": 0.0-1.0, "assessment": "HIGH/MEDIUM/LOW/INSUFFICIENT", "reason": "评估理由", "gaps": ["文档中缺失的关键信息"], "hasContradictions": true/false } """, question, docTexts);
String response = chatClient.prompt() .user(prompt) .call() .content();
return parseAssessment(response); }
public boolean shouldAnswer(String question, List<Document> docs) { if (docs.isEmpty()) return false;
QualityAssessment assessment = evaluate(question, docs); return assessment.qualityScore() >= qualityThreshold && !assessment.hasContradictions(); }
public String generateFallback(String question, QualityAssessment assessment) { return String.format( "关于「%s」这个问题,我在知识库中没有找到足够准确的信息。" + "%s\n\n建议您联系相关团队获取准确信息。", question, assessment.gaps().isEmpty() ? "" : "主要缺失的信息包括:" + String.join("、", assessment.gaps()) ); } }
|
6.3 端到端的检索增强生成流程
把前面所有组件串起来,形成完整的生产级 RAG 流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
|
@Service public class ProductionRAGService {
private final MultiQueryRewriter queryRewriter; private final HybridRetriever hybridRetriever; private final CrossEncoderReranker reranker; private final ParentChildRetriever parentChildRetriever; private final RetrievalQualityEvaluator qualityEvaluator; private final ChatClient chatClient;
public RAGResponse ask(String question, List<Document> conversationHistory) { List<String> queries = queryRewriter .generateAlternativeQueries(question); queries.add(0, question);
List<Document> allCandidates = new ArrayList<>(); for (String q : queries) { allCandidates.addAll( hybridRetriever.hybridSearch(q, 10).stream() .map(ScoredDocument::doc) .collect(Collectors.toList())); }
allCandidates = deduplicate(allCandidates);
List<ScoredDocument> reranked = reranker.rerank( question, allCandidates, 10);
List<Document> topDocs = reranked.stream() .map(ScoredDocument::doc) .limit(5) .collect(Collectors.toList());
QualityAssessment assessment = qualityEvaluator .evaluate(question, topDocs);
if (!qualityEvaluator.shouldAnswer(question, topDocs)) { return new RAGResponse( qualityEvaluator.generateFallback(question, assessment), topDocs, assessment, true ); }
String context = topDocs.stream() .map(Document::getText) .collect(Collectors.joining("\n\n---\n\n"));
String answer = chatClient.prompt() .system("基于以下参考文档回答用户问题。如果文档中的信息不足以" + "回答某个部分,请明确指出。引用文档时标注来源。") .user(String.format("参考文档:\n%s\n\n问题:%s", context, question)) .call() .content();
return new RAGResponse(answer, topDocs, assessment, false); } }
|
第七章:与 Agent 系统的整合——检索作为工具
7.1 把检索引擎暴露为 Agent 工具
在 Agent 系统中,检索引擎不应该是一个固定的管道,而应该是一个可被 Agent 动态调用的工具(Tool)。这样 Agent 可以根据问题的特点,自主决定检索策略。
这与之前文章中讲的 Tool Use 和 ReAct 模式 完全一致:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
|
@Component public class KnowledgeRetrievalTool {
private final ProductionRAGService ragService; private final IterativeRetrievalAgent iterativeAgent;
@Tool(description = "搜索知识库获取信息。用于回答需要参考内部文档、" + "技术规范、历史记录等私有知识的问题。" + "参数 query 应该是具体的搜索查询,而不是用户的原始问题。") public String searchKnowledgeBase( @ToolParam("具体的搜索查询,针对要查找的信息优化") String query) { RAGResponse response = ragService.ask(query, List.of()); return formatResponse(response); }
@Tool(description = "深度搜索知识库。当简单搜索无法找到足够信息时使用。" + "会自动执行多轮检索,逐步深入。适合复杂的、需要综合多个文档的问题。") public String deepSearchKnowledgeBase( @ToolParam("需要深度调研的复杂问题") String question) { RetrievalResult result = iterativeAgent.iterativeRetrieve(question); return formatDeepResult(result); }
@Tool(description = "验证某个说法是否有知识库文档支持。" + "用于事实核查,返回支持或矛盾的文档片段。") public String verifyClaim( @ToolParam("需要验证的说法或事实") String claim) { RAGResponse response = ragService.ask( "请查找与以下说法相关的文档:" + claim, List.of());
if (response.isFallback()) { return "未找到相关文档来验证此说法。"; } return "找到的相关文档:\n" + response.answer(); } }
|
7.2 Agent 的自适应检索策略
有了这些工具,Agent 就可以根据问题的复杂度自动选择策略:
- 简单事实查询(”XXX 的端口号是多少”)→ 直接调用
searchKnowledgeBase
- 复杂分析问题(”比较 A 和 B 的优缺点”)→ 调用
deepSearchKnowledgeBase
- 需要验证(”我记得 XXX 是这样实现的,对吗”)→ 调用
verifyClaim
这种自适应能力正是 Agent 系统相比固定管道 RAG 的核心优势,也是 Agentic RAG 架构的核心思想。
第八章:生产环境最佳实践
8.1 检索缓存策略
在高并发场景下,LLM 调用是最大的成本和延迟瓶颈。通过缓存可以大幅降低开销:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
|
@Component public class SemanticCache {
private final VectorStore cacheStore; private final double similarityThreshold; private final Duration ttl;
public SemanticCache(VectorStore cacheStore, @Value("${cache.similarity-threshold:0.92}") double threshold, @Value("${cache.ttl:PT1H}") Duration ttl) { this.cacheStore = cacheStore; this.similarityThreshold = threshold; this.ttl = ttl; }
public Optional<String> get(String query) { List<Document> results = cacheStore.similaritySearch( SearchRequest.query(query).withTopK(1));
if (!results.isEmpty()) { double score = (double) results.get(0).getMetadata() .get("score"); if (score >= similarityThreshold) { Instant cachedAt = Instant.parse( results.get(0).getMetadata().get("cached_at").toString()); if (Instant.now().isBefore(cachedAt.plus(ttl))) { return Optional.of(results.get(0).getText()); } } } return Optional.empty(); }
public void put(String query, String answer) { Document doc = new Document(answer, Map.of( "query", query, "cached_at", Instant.now().toString() )); cacheStore.add(List.of(doc)); } }
|
8.2 检索链路的可观测性
生产环境中,你需要知道每一次检索的”决策过程”:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
|
public class RetrievalTrace {
private final List<TraceStep> steps = new ArrayList<>(); private final Instant startTime = Instant.now();
public void recordStep(String name, Map<String, Object> input, Object output, Duration duration) { steps.add(new TraceStep(name, input, output, duration)); }
public String toReport() { StringBuilder sb = new StringBuilder(); sb.append("=== 检索链路追踪报告 ===\n"); sb.append(String.format("总耗时: %dms\n\n", Duration.between(startTime, Instant.now()).toMillis()));
for (int i = 0; i < steps.size(); i++) { TraceStep step = steps.get(i); sb.append(String.format("Step %d: %s (%dms)\n", i + 1, step.name(), step.duration().toMillis())); sb.append(String.format(" 输入: %s\n", truncate(step.input().toString(), 200))); sb.append(String.format(" 输出: %s\n\n", truncate(step.output().toString(), 300))); }
return sb.toString(); } }
|
8.3 常见坑与避坑指南
坑一:chunk size 选择不当
太小(<200 字):语义不完整,检索到的片段无法独立回答问题。太大(>2000 字):噪声太多,向量化后语义被稀释,相似度计算不准确。
推荐:技术文档 300-800 字,对话记录 100-300 字。最重要的是——一定要在你的实际数据上做 A/B 测试。
坑二:Embedding 模型不匹配
查询和文档用不同的 Embedding 模型向量化,或者换模型后没有重建索引。这会导致向量空间不一致,相似度计算完全失效。
避坑:所有向量操作必须使用同一个 Embedding 模型。模型升级时必须全量重建索引。
坑三:忽略了文档更新
文档更新后,旧的向量还在索引中。结果就是用户搜到了过时的信息。
避坑:建立文档版本管理机制,文档更新时同步更新向量索引。可以用文档 hash 做增量更新。
坑四:过度依赖 Top-K
设了一个固定的 Top-K(比如 5),不管查询复杂度如何都返回 5 条结果。简单查询返回太多噪声,复杂查询返回不够。
避坑:使用动态 Top-K,根据查询复杂度和检索质量分数自适应调整返回数量。
总结
构建一个生产级的知识检索引擎,远不止”向量搜索 + LLM”这么简单。让我们回顾完整的检索链路:
1 2 3 4 5 6 7 8 9 10 11
| 用户问题 ↓ 查询改写(HyDE / 查询分解 / 多查询融合 / Step-Back) ↓ 混合检索(向量 + BM25,RRF 融合) ↓ 重排序(Cross-Encoder / LLM / 规则) ↓ 质量评估(够不够回答?有没有矛盾?) ↓ [通过] → 生成回答 | [失败] → 优雅降级
|
每个环节都有其不可替代的价值:
- 查询改写解决的是”问对问题”——把模糊的用户表述转换成高效的搜索查询
- 混合检索解决的是”找全”——语义匹配和精确匹配各有擅长的场景
- 重排序解决的是”找对”——从”相关”中选出”正确”
- 质量评估解决的是”知止”——知道什么时候不该回答,比回答本身更重要
当这些组件与 Agent 系统整合后(通过 Tool Use),Agent 就具备了真正的”知识推理”能力——不是死板地执行固定检索管道,而是根据问题动态选择最优策略。
在接下来的文章中,我们会继续深入 Agent 系统的其他核心能力。如果你对某个环节想深入了解,欢迎在评论区留言。