RAG 实战

RAG(Retrieval-Augmented Generation)是一种结合检索和生成的技术,能够提升语言模型在特定领域的表现。本文将通过构建一个知识库 Agent 的实战案例,学习 RAG 的实现方法以及实现中的问题和解决方案。


一、知识库 Agent 的核心目标

知识库 Agent 的核心目标是作为团队知识管理和 AI 应用的基础设施 ,通过自动化流程,将我们日常积累的文档(比如 PDF、Markdown、技术手册、告警处理记录、历史工单等),转化为可被 AI 高效检索的向量资产。


二、RAG 全流程解析

知识库 Agent 本质是就是 RAG 的过程 。RAG(检索增强生成)是构建智能客服、企业知识库、产品问答助手的核心技术。当你需要让 AI 回答特定领域问题(如公司产品手册、内部文档)时,直接将长文本发送给模型,会受限于模型的上下文窗口大小,导致成本高、速度慢、准确率低。RAG 通过先检索相关内容再生成答案的方式,完美解决了这些问题,广泛应用于企业知识管理、智能客服等场景。在对话 Agent 和运维 Agent 里,都会使用到 RAG。

1.1 核心流程

RAG 的核心流程可以分为以下几个步骤:

  • 提问前(数据准备):分片 -> embedding -> 存储
  • 提问时(回答生成):召回 -> 重排 -> 生成

1.2 提问前链路

  1. 分片:将原始文档切割为多个语义完整的片段
  2. 索引:
    • 用 embedding 模型将每个片段转化为向量
    • 将向量存储在向量数据库中(如 Pinecone、Weaviate、Milvus 等)
  3. 完成后,知识库构建完成,等待提问

1.2.1 分片

分片是将完整文档切割成多个语义完整的片段,常用的方法有:

  • 固定长度分片:每个片段包含固定数量的字符或单词
  • 递归分片:优先按段落、句子等语义边界分片,剩余部分再按固定长度分片
  • 语义分片:按语义边界分片

核心原则:确保每个片段的语义完整性

1.2.2 索引

索引是将分片后的文本转化为向量并存储的过程,主要步骤包括:

  1. 使用 embedding 模型(如 OpenAI 的 text-embedding-3-small、text-embedding-3-large)将每个片段转化为向量
  2. 将向量存储在向量数据库中,常用的数据库有 Pinecone、Weaviate、Milvus 等

向量数据库不仅存储向量还保留原始文本,因为最终生成答案需要的是文本内容,向量只是用于相似度计算。

向量数据库核心作用:

  • 存储向量与文本
  • 相似性查询

1.3 提问时链路

  1. 召回:用户问题 -> Embedding -> 向量 -> 向量数据库 -> Top-N 相关文本
  2. 重排:Top-N 相关文本 -> Cross Encoder模型 -> Top-K 最相关文本
  3. 生成:用户问题 + Top-K 相关文本 -> 大模型 -> 最终答案

1.3.1 召回

召回是根据用户问题检索相关文本的过程,主要步骤包括:

  1. 将用户问题转化为向量
  2. 使用向量数据库进行相似度查询,返回 Top-N 相关文本
  3. 特点:速度快、成本低,但准确率有限,适合初步筛选

1.3.2 重排

召回的10个片段可能仍有冗余或相关性不足,需要进一步精筛:

  1. 使用专门计算文本对相似度的模型,逐对计算用户问题与每个召回片段的语义相关性。
  2. 从 N 个片段中选出 Top K(如3个)最相关的片段。

三、实战案例:构建一个知识库 Agent

我们将通过一个实战案例,构建一个知识库 Agent,来演示 RAG 的全流程实现。

3.1 提问前链路实现

流程梳理:

  1. 读取文件
  2. 切分文件
  3. 索引(Embedding 和存储)

3.1.1 读取文件

我们直接传入文件路径 path,调用 Files.readString 读取文件内容:

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
// 读取文件
String content = Files.readString(Path

/**
* 索引单个文件
*
* @param path 文件路径
* @throws Exception 索引失败时抛出异常
*/
public void indexSingleFile(String filePath) throws Exception {

// 1. 规范化路径
Path path = Paths.get(filePath).normalize();
File file = path.toFile();

if (!file.exists() || !file.isFile()) {
throw new FileNotFoundException("文件不存在或不是一个有效的文件: " + filePath);
}

// 2. 读取文件
String content = Files.readString(Path.of(filePath));
logger.info("从 {} 中读取文件内容成功,长度:{} 字符", path, content.length());

// 3. 删除该文件的旧数据(如果存在)
deleteExistingData(path.toString());

// 4. 文档分片
List<DocumentChunk> chunks = chunkService.chunkDocument(content, path.toString());
logger.info("文档分片完成,生成了 {} 个片段", chunks.size());

// 5. 为每个分片生成向量并插入 Milvus
for (int i = 0; i < chunks.size(); i++) {
DocumentChunk chunk = chunks.get(i);

try {
// 生成向量
List<Float> vector = embeddingService.generateEmbedding(chunk.getContent());

// 构建元数据(包含文件信息)
Map<String, Object> metadata = buildMetadata(path.toString(), chunk, chunk.size());

// 插入 Milvus
insertToMilvus(chunk.getContent(), vector, metadata, chunk.getChunkIndex());

logger.info("✓ 分片 {}/{} 索引成功", i + 1, chunks.size());
} catch (Exception e) {
logger.error("✗ 分片 {}/{} 索引失败", i + 1, chunks.size(), e);
throw new RuntimeException("分片索引失败: " + e.getMessage(), e);
}
}

logger.info("文件索引完成: {}, 共 {} 个分片", filePath, chunks.size());
}

3.1.2 文件分片

  1. 第一按照 Markdown 的标题 # 切分,将文档按照标题分割成多个章节 Section
  2. 第二层对每个章节进行分配,如果章节小于 MaxSize,则直接将这个章节作为一个分片
  3. 如果章节大于 MaxSize,则对段落边界进行切分
  4. 对于对段落边界进行切分的地方,还会根据 Overlap,实现段落间内容重叠,来保持段落之间的上下文语义连贯
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
// 文档分片
List<DocumentChunk> chunks = chunkService.chunkDocument(content, path.toString());
logger.info("文档分片完成: {} -> {} 个分片", filePath, chunks.size());

// 核心实现
public List<DocumentChunk> chunkDocument(String content, String filePath) {
List<DocumentChunk> chunks = new ArrayList<>();

if (content == null || content.isEmpty()) {
logger.warn("文档内容为空: {}", filePath);
return chunks; // 返回空列表
}

// 1. 首先尝试按标题分割(Markdown格式)
List<Section> sections = splitByHeadings(content);

// 2. 对每个章节进行进一步分片
int globalChunkIndex = 0;
for (Section section : sections) {
List<DocumentChunk> sectionChunks = chunkSection(section, globalChunkIndex);
chunks.addAll(sectionChunks);
globalChunkIndex += sectionChunks.size();

}
logger.info("文档分片完成: {} -> {} 个分片", filePath, chunks.size());

return chunks;
}

// 对单个章节进行分片
private List<DocumentChunk> chunkSection(Section section, int startChunkIndex) {
List<DocumentChunk> chunks = new ArrayList<>();
String content = section.content;
String title = section.title;

// 如果章节内容小于最大尺寸,直接作为一个分片
if (content.length() <= chunkConfig.getMaxSize()) {
DocumentChunk chunk = new DocumentChunk(
content,
section.startIndex,
section.startIndex + content.length(),
startChunkIndex
);
chunk.setTitle(title);
chunks.add(chunk);
return chunks;
}

// 章节内容较长,需要进一步分片
// 优先在段落边界分割
List<String> paragraphs = splitByParagraphs(content);

StringBuilder currentChunk = new StringBuilder();
int currentStartIndex = section.startIndex;
int chunkIndex = startChunkIndex;

for (String paragraph : paragraphs) {
// 如果当前分片加上新段落超过最大尺寸
if (currentChunk.length() > 0 &&
currentChunk.length() + paragraph.length() > chunkConfig.getMaxSize()) {

// 保存当前分片
String chunkContent = currentChunk.toString().trim();
DocumentChunk chunk = new DocumentChunk(
chunkContent,
currentStartIndex,
currentStartIndex + chunkContent.length(),
chunkIndex++
);
chunk.setTitle(title);
chunks.add(chunk);

// 开始新分片,包含重叠部分
String overlap = getOverlapText(chunkContent);
currentChunk = new StringBuilder(overlap);
currentStartIndex = currentStartIndex + chunkContent.length() - overlap.length();
}
}
return chunks;
}

3.1.3 索引

  1. 首先对所有分片进行向量化,获取向量数组
  2. 构造符合 milvus 表记录的结构体。id、content、vector、metadata
  3. 构造完记录后,插入到数据库中
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
// 4. 为每个分片生成向量并插入 Milvus
for (int i = 0; i < chunks.size(); i++) {
DocumentChunk chunk = chunks.get(i);
try {
// 生成向量
List<Float> vector = embeddingService.generateEmbedding(chunk.getContent());
// 构建元数据(包含文件信息)
Map<String, Object> metadata = buildMetadata(path.toString(), chunk, chunks.size());
// 插入到 Milvus
insertToMilvus(chunk.getContent(), vector, metadata, chunk.getChunkIndex());
logger.info("✓ 分片 {}/{} 索引成功", i + 1, chunks.size());
}
}
/**
* 生成向量嵌入
* 调用阿里云 DashScope Text Embedding API
*
* @param content 文本内容
* @return 向量嵌入(浮点数列表)
*/
public List<Float> generateEmbedding(String content) {
try {
// 构建请求参数
TextEmbeddingParam param = TextEmbeddingParam
.builder()
.model(model)
.texts(Collections.singletonList(content))
.build();

// 调用 API
TextEmbeddingResult result = textEmbedding.call(param);

// 检查结果
List<Float> floatEmbedding = getFloats(result);

return floatEmbedding;
}
}

/**
* 插入向量到 Milvus
*/
private void insertToMilvus(String content, List<Float> vector,
Map<String, Object> metadata, int chunkIndex) throws Exception {
try {
// 生成唯一 ID(使用 _source + 分片索引)
String source = (String) metadata.get("_source");
String id = UUID.nameUUIDFromBytes((source + "_" + chunkIndex).getBytes()).toString();

// 构建字段数据
List<InsertParam.Field> fields = new ArrayList<>();

// ID 字段
fields.add(new InsertParam.Field("id", Collections.singletonList(id)));

// content 字段
fields.add(new InsertParam.Field("content", Collections.singletonList(content)));

// vector 字段
fields.add(new InsertParam.Field("vector", Collections.singletonList(vector)));

// metadata 字段(JSON 对象)
com.google.gson.Gson gson = new com.google.gson.Gson();
com.google.gson.JsonObject metadataJson = gson.toJsonTree(metadata).getAsJsonObject();
fields.add(new InsertParam.Field("metadata", Collections.singletonList(metadataJson)));

// 构建插入参数
InsertParam insertParam = InsertParam.newBuilder()
.withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
.withFields(fields)
.build();

// 执行插入
R<MutationResult> insertResponse = milvusClient.insert(insertParam);
if (insertResponse.getStatus() != 0) {
throw new RuntimeException("插入向量失败: " + insertResponse.getMessage());
}

logger.debug("向量插入成功: id={}, source={}, chunk={}", id, source, chunkIndex);
} catch (Exception e) {
logger.error("插入向量到 Milvus 失败", e);
throw e;
}
}

3.2 提问时链路实现

3.2.1 召回

  1. 将查询文本向量化
  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
/**
* 搜索相似文档
*
* @param query 查询文本
* @param topK 返回最相似的K个结果
* @return 搜索结果列表
*/
public List<SearchResult> searchSimilarDocuments(String query, int topK) {
try {
logger.info("开始搜索相似文档, 查询: {}, topK: {}", query, topK);

// 1. 将查询文本向量化
List<Float> queryVector = embeddingService.generateQueryVector(query);

// 2. 构建搜索参数
SearchParam searchParam = SearchParam.newBuilder()
.withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
.withVectorFieldName("vector")
.withVectors(Collections.singletonList(queryVector))
.withTopK(topK)
.withMetricType(io.milvus.param.MetricType.L2)
.withOutFields(List.of("id", "content", "metadata"))
.withParams("{\"nprobe\":10}")
.build();

// 3. 执行搜索
R<SearchResults> searchResponse = milvusClient.search(searchParam);

// 4. 解析搜索结果
SearchResultsWrapper wrapper = new SearchResultsWrapper(searchResponse.getData().getResults());
List<SearchResult> results = new ArrayList<>();

for (int i = 0; i < wrapper.getRowRecords(0).size(); i++) {
SearchResult result = new SearchResult();
result.setId((String) wrapper.getIDScore(0).get(i).get("id"));
result.setContent((String) wrapper.getFieldData("content", 0).get(i));
result.setScore(wrapper.getIDScore(0).get(i).getScore());

// 解析 metadata
Object metadataObj = wrapper.getFieldData("metadata", 0).get(i);
if (metadataObj != null) {
result.setMetadata(metadataObj.toString());
}

results.add(result);
}

return results;
}
}

首先我们对问题进行向量化,按照Spring AI 的sdk要求,拼接请求参数,然后调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 生成向量嵌入
* 调用阿里云 DashScope Text Embedding API
*
* @param content 文本内容
* @return 向量嵌入(浮点数列表)
*/
public List<Float> generateEmbedding(String content) {
try {
// 构建请求参数
TextEmbeddingParam param = TextEmbeddingParam
.builder()
.model(model)
.texts(Collections.singletonList(content))
.build();

// 调用 API
TextEmbeddingResult result = textEmbedding.call(param);

// 检查结果
List<Float> floatEmbedding = getFloats(result);
return floatEmbedding;
}
}

然后我们使用Milvus的sdk,进行相似度,获取相似的向量数据

1
2
3
4
5
6
7
8
9
10
11
12
13
// 2. 构建搜索参数
SearchParam searchParam = SearchParam.newBuilder()
.withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
.withVectorFieldName("vector")
.withVectors(Collections.singletonList(queryVector))
.withTopK(topK)
.withMetricType(io.milvus.param.MetricType.L2)
.withOutFields(List.of("id", "content", "metadata"))
.withParams("{\"nprobe\":10}")
.build();

// 3. 执行搜索
R<SearchResults> searchResponse = milvusClient.search(searchParam);

总结:对问题先进行向量化,然后根据向量,调用数据库的向量查询接口进行查询。


四、RAG 实战中的问题和解决方案

4.1 检索层问题(最核心)

4.1.1 检索不准(Top-K 全是“看起来相关但没用”的内容)

常见原因

  • chunk 切分不合理(太大 / 太碎)
  • embedding 模型语义能力弱
  • query 和文档分布不一致(语言/表达差异)

4.1.2 解决方案

  1. 优化 Chunking(优先级最高)
  • 300–800 tokens
  • overlap 10–20%
  • 按“语义块”切(而不是固定长度)
  1. Query Rewrite / Expansion
1
2
3
4
5
用户问:怎么解决超卖
→ 改写:
- 分布式锁
- Redis Lua
- 乐观锁

👉 实战中通常用 LLM 做:

  • multi-query(生成3~5个query并检索)
  1. Hybrid Search(强烈推荐)
1
向量检索 + BM25关键词检索

👉 解决 embedding 漏掉关键词的问题

  1. 引入 Re-ranker(质变)🔥

流程:

1
Top-K → Cross Encoder → 排序

效果:

  • 精度显著提升
  • 噪声下降

4.2 生成层问题(LLM阶段)

4.2.1 幻觉(Hallucination)

👉 模型开始“编”

解决方案

  1. 强约束 Prompt
1
2
只允许基于提供的Context回答,
如果找不到答案就说“不知道”
  1. 强制引用来源
1
Answer + Sources
  1. 限制上下文
  • Top-K 不要太大(3~5)
  • 去重(MMR)

4.2.2 上下文污染(Context Pollution)

👉 检索回来很多“半相关内容”,干扰模型

解决方案

  • rerank(必须有)
  • chunk 去重(相似度过滤)
  • 按相关性截断

4.3 数据层问题(80%的人忽略)

4.3.1 数据质量差

👉 垃圾数据 → 再好的模型也没用

表现

  • 文档重复
  • 信息过时
  • OCR 错误

解决方案

  • 数据清洗 pipeline
  • 去重(hash / embedding)
  • 标记时间(time-aware RAG)

4.3.2 文档结构丢失

👉 比如:

  • 表格被打散
  • 标题层级消失

解决方案

  • 保留 metadata:
1
2
3
4
5
{
"title": "...",
"section": "...",
"page": 3
}
  • 使用结构化 chunk(标题 + 内容)

4.4 系统层问题(工程化)

4.4.1 延迟高(Latency)

👉 用户体验直接崩

来源

  • embedding
  • 向量检索
  • rerank
  • LLM

解决方案

  • embedding 缓存
  • ANN(近似最近邻,如 FAISS IVF)
  • 限制 Top-K(如 5)
  • 小模型 rerank
  • streaming 输出

五、总结

RAG 全流程解析