AI Agent 的知识检索引擎:从向量搜索到智能检索策略的 Java 实战

系列文章

本系列深入拆解 AI Agent 的核心能力模块,从原理到实战,构建完整的 Agent 知识体系:

  1. AI Agent 的记忆系统:从 ChatMemory 到持久化记忆的 Java 实战
  2. AI Agent 的记忆力是怎么实现的——LangChain4j Memory 机制深度解析
  3. MCP 模型上下文协议:AI 的万能接口与 MCP Server 实战
  4. AI Agent 的工具箱:深入理解 Tool Use 与 Spring AI Function Calling 实战
  5. 让 AI 学会说人话:Spring AI 结构化输出实战
  6. AI Agent 的规划大脑:从任务分解到自适应执行策略
  7. AI Agent 的灵魂对话:Prompt Engineering 系统提示词设计的艺术与工程
  8. 理解 AI Agent 的大脑:ReAct 模式从入门到实战
  9. 从零理解 RAG:检索增强生成完整指南
  10. Embedding 向量化的魔法:从文本到向量的数学之旅与 Java 实战
  11. 当 RAG 遇上知识图谱:GraphRAG 原理与 Java 实战
  12. 当 RAG 遇到 Agent:Agentic RAG 的架构设计与 Java 实战
  13. AI Agent 团队协作:多 Agent 系统架构设计与 Java 实战
  14. AI Agent 评估与优化:从基准测试到生产环境的质量守护实战
  15. AI Agent 的知识检索引擎:从向量搜索到智能检索策略的 Java 实战(本文)

前言:检索——Agent 系统的”第二大脑”

在之前的文章中,我们已经了解了 RAG 的基本原理(从零理解 RAG)、向量化的数学基础(Embedding 向量化的魔法),以及 Agent 如何通过 ReAct 循环(理解 AI Agent 的大脑)来使用工具完成任务。

但在实际的企业级场景中,”把文档切成块,向量化,然后做相似度搜索”这种朴素的 RAG 方案,远远不够。

想象一个真实场景:你是一家金融公司的技术负责人,要构建一个合规审查 Agent。用户问:”我们去年Q3发行的那个结构性产品,它的风险披露条款是否符合最新的 SEC 规定?”

这个问题有几个难点:

  1. 指代消解:”那个结构性产品”到底是哪个?Agent 需要从上下文或用户历史中推断出来
  2. 时间推理:”去年Q3”意味着要检索特定时间段的文档
  3. 多文档关联:需要同时找到”产品条款”和”SEC 规定”两份文档,然后做对比
  4. 精确性要求:合规审查不允许”大致正确”,每一句话都要有出处

朴素的向量搜索面对这种场景,表现往往是灾难性的。它可能返回一堆”看起来相关”但实际答非所问的文档片段。

这篇文章要解决的核心问题:如何构建一个真正智能的知识检索引擎,让 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
/**
* HyDE 查询改写器
* 核心思想:用 LLM 生成假想答案,用假想答案做向量检索
*/
@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();
}

/**
* 完整的 HyDE 检索流程
*/
public List<Document> searchWithHyDE(String query,
VectorStore vectorStore,
int topK) {
// 1. 生成假想答案
String hypotheticalDoc = rewriteToHypotheticalDocument(query);

// 2. 用假想答案做向量搜索(而不是原始问题)
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();

// 解析 JSON 数组
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));
}

// 按文档 ID 去重,保留得分最高的
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 中 ChatClientadvisors() 方法有什么 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);
}

/**
* 融合搜索:原始查询 + 多个改写查询,Reciprocal Rank Fusion 排序
*/
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()));
// RRF 公式:score += 1 / (k + rank)
double rrfScore = 1.0 / (60 + i);
docScores.merge(docId, rrfScore, Double::sum);
}
}

// 按 RRF 分数排序,取 Top-K
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
/**
* 混合检索器:向量检索 + BM25 关键词检索
* 使用 Reciprocal Rank Fusion (RRF) 融合两路结果
*/
@Component
public class HybridRetriever {

private final VectorStore vectorStore;
private final BM25Retriever bm25Retriever;
private final double vectorWeight; // 向量检索权重
private final double bm25Weight; // BM25 检索权重

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) {
// 1. 向量检索
List<Document> vectorResults = vectorStore.similaritySearch(
SearchRequest.query(query).withTopK(topK * 2));

// 2. BM25 关键词检索
List<Document> bm25Results = bm25Retriever.search(query, topK * 2);

// 3. RRF 融合
return reciprocalRankFusion(vectorResults, bm25Results, topK);
}

/**
* Reciprocal Rank Fusion 算法
* 核心公式:score(d) = Σ (weight / (k + rank_i(d)))
* k 通常取 60,是一个平滑常数
*/
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; // RRF 平滑常数

// 向量检索结果计分
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));
}

// BM25 结果计分
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));
}

// 排序并返回 Top-K
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
/**
* 基于 Lucene 的 BM25 检索器
*/
@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();
}

/**
* LLM 重排序:对每个候选文档独立打分,然后排序
*/
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
/**
* 基于 Cohere Rerank API 的重排序器
* 也可以替换为本地部署的 BGE-Reranker 模型
*/
@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
);

// 调用 Cohere Rerank API
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;

// 因子1:原始相似度分数(权重 40%)
score += getOriginalScore(doc) * 0.4;

// 因子2:关键词匹配度(权重 25%)
score += keywordMatchScore(query, doc.getText()) * 0.25;

// 因子3:文档新鲜度(权重 15%)
score += freshnessScore(doc) * 0.15;

// 因子4:与对话上下文的关联度(权重 20%)
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());

// 30 天内的文档满分,超过 365 天降到 0.1
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 需要:

  1. 第一跳:检索 Spring AI ChatClient 的 RAG 实现方式
  2. 第二跳:检索 OpenAI Assistants API 的 RAG 实现方式
  3. 第三跳(可选):基于前两跳的结果,检索两者的对比分析

每一跳的检索都依赖前一跳的结果。这就是多跳检索(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
/**
* 迭代检索 Agent:基于 ReAct 循环的多跳检索
*
* 核心思想:Agent 不是一次性检索完就生成答案,
* 而是根据已有信息判断是否需要继续检索,
* 每次检索的查询都基于前几次的结果动态生成。
*/
@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++) {
// 1. 检索
List<ScoredDocument> results = retriever.hybridSearch(
currentQuery, 10);

// 2. 重排序
List<ScoredDocument> reranked = reranker.rerank(
currentQuery,
results.stream().map(ScoredDocument::doc)
.collect(Collectors.toList()), 5);

// 3. 添加新证据
for (ScoredDocument sd : reranked) {
if (!isDuplicate(sd.doc(), allEvidence)) {
allEvidence.add(sd.doc());
}
}

// 4. 判断是否需要继续检索
ThoughtAction thought = analyzeAndDecide(
question, allEvidence, reasoningSteps);

reasoningSteps.add(thought.reasoning());

if (thought.hasEnoughInfo()) {
break; // 信息足够,可以生成答案了
}

// 5. 生成下一轮查询
currentQuery = thought.nextQuery();
}

return new RetrievalResult(allEvidence, reasoningSteps);
}

/**
* ReAct 风格的分析和决策
*/
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) {
// 1. 用子块做向量检索
List<Document> childResults = childStore.similaritySearch(
SearchRequest.query(query).withTopK(topK));

// 2. 获取对应的父块(去重)
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
/**
* 检索质量评估器
* 在 LLM 生成答案之前,先评估检索到的文档是否足够回答问题
*/
@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
/**
* 生产级 RAG 服务
* 完整链路:查询改写 → 混合检索 → 重排序 → 质量评估 → 生成/降级
*/
@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;

// ... 构造函数注入 ...

/**
* 完整的 RAG 流程
*/
public RAGResponse ask(String question,
List<Document> conversationHistory) {
// Step 1: 查询改写
List<String> queries = queryRewriter
.generateAlternativeQueries(question);
queries.add(0, question); // 原始查询排第一

// Step 2: 多路混合检索
List<Document> allCandidates = new ArrayList<>();
for (String q : queries) {
allCandidates.addAll(
hybridRetriever.hybridSearch(q, 10).stream()
.map(ScoredDocument::doc)
.collect(Collectors.toList()));
}

// Step 3: 去重
allCandidates = deduplicate(allCandidates);

// Step 4: 重排序
List<ScoredDocument> reranked = reranker.rerank(
question, allCandidates, 10);

List<Document> topDocs = reranked.stream()
.map(ScoredDocument::doc)
.limit(5)
.collect(Collectors.toList());

// Step 5: 质量评估
QualityAssessment assessment = qualityEvaluator
.evaluate(question, topDocs);

if (!qualityEvaluator.shouldAnswer(question, topDocs)) {
// 检索失败,优雅降级
return new RAGResponse(
qualityEvaluator.generateFallback(question, assessment),
topDocs,
assessment,
true // 标记为降级回答
);
}

// Step 6: 生成答案
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 UseReAct 模式 完全一致:

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
/**
* 检索工具:将知识检索引擎暴露为 Agent 可调用的 Tool
*/
@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
/**
* 语义缓存:相似的问题直接返回缓存结果
*
* 不是简单的字符串匹配,而是向量相似度匹配:
* "Spring Boot 怎么配置数据库" 和 "如何在 Spring Boot 中设置数据库连接"
* 语义相同,应该命中同一个缓存
*/
@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 系统的其他核心能力。如果你对某个环节想深入了解,欢迎在评论区留言。