一. RAG 简介
RAG(Retrieval-Augmented Generation,检索增强生成)是让大模型在回答问题前,先从外部知识库检索相关资料,再结合资料生成答案的技术。
核心思想:
用户提问 → 检索资料 → 注入上下文 → 大模型生成答案
简单理解就是:
检索 + 生成
让模型"查完资料再回答",减少仅依赖训练数据产生的错误。
RAG 解决的问题
大模型存在以下局限:
- 幻觉:生成的内容语言合理,但事实可能错误
- 知识滞后:无法知道训练完成后的新知识
- 知识局限:缺少企业内部或专业领域的私有数据
- 专业性不足:对法律、医疗、金融等领域理解有限
RAG 可以为模型提供实时、专业、私有的外部知识。
适用场景
- 企业知识库问答
- 智能客服
- 产品说明书查询
- 企业制度和内部文档问答
- 安全日志与威胁情报分析
- CVE 漏洞解读
- 法律、医疗、金融等专业问答
- 最新政策、法规和业务数据查询
RAG 主要流程
RAG 以用户是否开始提问为分界,可以分为两个阶段:
离线阶段:构建索引
在线阶段:检索生成
构建索引
在用户提问前,将原始资料处理成可检索的知识库:
加载文档
↓
文档清洗
↓
文档分片 Chunk
↓
Embedding 向量化
↓
写入向量数据库
主要步骤:
- 加载 PDF、Word、网页或数据库内容
- 清洗无效、重复和错误数据
- 将长文档切分成多个文本块
- 使用 Embedding 模型将文本转换成向量
- 将文本、向量和元数据写入向量数据库
构建索引的核心原则:
Garbage In, Garbage Out
垃圾进,垃圾出
原始文档和分片质量会直接影响最终回答效果。
检索生成
用户提问后,实时检索并生成回答:
用户问题
↓
问题向量化
↓
向量相似度检索
↓
获得相关文本块
↓
文本块注入 Prompt
↓
大模型生成答案
大模型收到的内容类似:
请根据以下参考资料回答问题。
参考资料:
{检索到的文本块}
用户问题:
{用户问题}
核心组件
Document Loader 加载文档
Text Splitter 文档分片
Embedding Model 文本向量化
Vector Store 存储和检索向量
Retriever 召回相关文本
Chat Model 根据资料生成答案
重点总结
- RAG 不会重新训练大模型,而是在调用时补充外部知识。
- 构建索引是离线过程,检索生成是在线过程。
- 文档质量、分片策略和检索效果会直接影响最终回答。
- 向量数据库保存的不只是向量,通常还包括原文和元数据。
- RAG 能减少幻觉,但不能保证答案绝对正确。
- 对高风险领域的回答仍需来源引用、权限控制和人工校验。
二. RAG 索引构建
索引构建是把原始文档转换成可进行语义检索的知识库索引。
开始:原始文档
结束:向量数据库中的索引
整体流程:
文档加载
↓
文档预处理
↓
文档分片
↓
Embedding 向量化
↓
存储向量、原文和元数据
↓
生成索引
文档预处理
文档预处理是将不同类型的原始文档转换成统一的 Document 格式。
主要操作:
- 加载 PDF、Word、网页等不同类型的文档
- 提取文档中的有效文本
- 删除多余空格和换行
- 去除特殊符号及重复内容
- 统一编码、大小写和文本格式
- 保留文件名、页码等元数据
目标是让分片和向量化在干净、统一的数据上进行。
文档分片
文档分片是将长文档切分成多个较小的文本块:
Document → Chunk 1、Chunk 2、Chunk 3...
分片的原因:
- 大模型存在上下文窗口限制
- 整篇文档会消耗大量 Token
- 长文档包含大量与问题无关的内容
- 检索文本块比检索整篇文档更精准
常见分片方式:
- 固定大小分片
- 按段落、换行和标点递归分片
- 按文档结构分片
- 语义分片
- 使用模型进行智能分片
Chunk 太长
- 一个文本块包含多个主题
- 相关内容容易被无关信息淹没
- 检索相关性降低
- 占用过多模型上下文
Chunk 太短
- 句子或语义可能被切断
- 单个文本块缺少完整上下文
- 模型容易误解文本含义
- 文本块数量和检索成本增加
通常可以设置适当的重叠区域:
Chunk 1:AAAA BBBB CCCC
Chunk 2: CCCC DDDD EEEE
通过 Chunk Overlap 保留相邻文本块之间的上下文。
Embedding 向量化
向量化是使用 Embedding 模型将文本块转换成高维数值数组:
文本块
↓ Embedding Model
[-0.321, 0.113, -0.232, ...]
向量中的每个维度代表文本的某种语义特征。
常见维度:
384维、768维、1024维、1536维
语义越相似的文本,其向量在高维空间中的位置通常越接近。
需要保证:
构建索引和查询时使用同一个 Embedding 模型
否则向量维度或语义空间可能不同,无法正确计算相似度。
向量相似度
余弦相似度
主要比较两个向量的方向:
范围:[-1, 1]
- 越接近
1:语义越相似 - 接近
0:语义相关性较低 - 越接近
-1:方向相反
余弦相似度不关注向量长度,常用于文本语义检索。
欧几里得距离
计算高维空间中两个点的直线距离:
距离越小 → 越相似
常用于具有明显几何意义的数据。
点积
计算两个向量在相同方向上的重叠程度:
值越大 → 越相似
计算简单、效率较高,Transformer 的注意力机制也使用点积计算相关性。
向量数据库
向量数据库用于保存和检索高维向量。
常见产品:
- Pgvector
- Milvus
- Pinecone
- FAISS
- Elasticsearch
- Redis Vector
向量数据库不是进行精确文本匹配,而是通过向量距离完成语义检索。
例如:
查询:小明爱吃什么?
小明爱吃西瓜 → 相似度高
小明喜欢打篮球 → 相似度较低
今天天气真好 → 基本不相关
索引中存储的内容
一条知识数据通常包含:
embedding_id
embedding
text
metadata
Embedding
文本对应的高维向量,用于相似度查询。
Text
原始文本块,检索后会放进 Prompt,提供给大模型作为参考资料。
大模型使用的是原始文本,不是向量本身。
Metadata
文本块的附加信息,例如:
{
"fileName": "员工手册.pdf",
"page": 12,
"category": "请假制度",
"createTime": "2026-08-29"
}
元数据可以用于:
- 按文件过滤
- 按时间过滤
- 按知识分类过滤
- 展示答案来源
- 权限控制
- 文档追溯
索引构建结果

Chunk
├── 原始文本
├── Embedding 向量
└── Metadata
↓
向量数据库
↓
建立向量索引
索引构建完成后,用户问题也会被转换成向量,再从数据库中查询最相似的文本块。
重点总结
- 索引构建始于文档,结束于向量索引。
- 文档预处理决定原始数据质量。
- Chunk 大小需要在语义完整性和检索精度之间平衡。
- Chunk Overlap 可以减少语义被切断的问题。
- Embedding 将文本转换成可计算的语义向量。
- 文本检索最常使用余弦相似度。
- 向量数据库通常同时存储向量、原文和元数据。
- 向量用于检索,原始文本用于增强模型回答。
- 构建索引和查询必须使用相同的 Embedding 模型。
三种方式对比:
三种方法的值范围如下:
| 方法 | 取值范围 | 越相似时 |
|---|---|---|
| 余弦相似度 | [-1, 1] |
越接近 1 |
| 欧几里得距离 | [0, +∞) |
越接近 0 |
| 点积 | (-∞, +∞) |
通常越大越匹配 |
余弦相似度
范围:[-1, 1]
-
1:方向完全相同 -
0:方向垂直 -
-1:方向完全相反越大越相似
欧几里得距离
范围:[0, +∞)
-
0:两个向量完全相同 -
数值越大:两个点距离越远
-
没有固定上限
越小越相似
如果两个向量都已归一化,欧氏距离范围会缩小为:
[0, 2]
0:同方向√2:垂直2:反方向
点积
范围:(-∞, +∞)
-
正数:方向总体相近
-
0:方向垂直,或者其中一个是零向量 -
负数:方向总体相反
-
没有固定上下限
通常越大,匹配分数越高
如果两个向量都已归一化:
点积范围:[-1, 1]
此时:
点积 = 余弦相似度
一句话记忆:
余弦:-1 到 1,越大越相似
欧氏:0 到无穷,越小越相似
点积:负无穷到正无穷,通常越大越匹配
三. RAG 检索生成
检索生成发生在用户提问时,属于实时处理流程。
用户提问
↓
内容召回
↓
重排序
↓
上下文融合
↓
大模型生成答案
用户提问
用户输入问题,例如:
2024年诺贝尔物理学奖得主是谁?
该问题既用于知识检索,也会作为最终提示词的一部分。
内容召回
内容召回是从知识库中查找与用户问题相关的文本块。
召回质量会直接影响最终答案:
没有召回正确资料
↓
大模型无法根据正确知识回答
常见召回方式包括向量检索和混合检索。
向量相似度检索
首先将用户问题转换成向量:
用户问题
↓ Embedding Model
Query Vector
然后在向量数据库中进行相似度查询:
Query Vector
↓
余弦相似度、点积或欧氏距离
↓
返回 Top-K 个相关 Chunk
例如:
Top-1:相似度 0.92
Top-2:相似度 0.87
Top-3:相似度 0.81
Top-K 表示取相似度最高的 K 个结果,通常可以取 3~5 个,但需要根据文档长度和模型上下文进行调整。
向量检索的优点:
- 能理解语义
- 不要求问题和文档使用相同关键词
- 适合自然语言查询
缺点:
- 对编号、缩写、人名和专业术语可能不够精确
- 语义相似不代表事实相关
关键词检索
关键词检索通常使用:
- BM25
- Elasticsearch
- 倒排索引
它主要进行词语的精确匹配。
例如查询:
CVE-2026-12345
关键词检索通常比纯向量检索更准确,因为漏洞编号需要精确匹配。
优点:
- 精确匹配能力强
- 适合编号、名称和专业术语
- 查询速度快
缺点:
- 难以理解同义词和自然语言语义
- 查询词和文档用词不一致时容易漏召回
混合检索
混合检索同时使用关键词检索和向量检索:
用户问题
├── BM25 关键词检索
└── 向量语义检索
↓
合并结果
两种检索方式相互补充:
关键词检索 → 保证精确匹配
向量检索 → 捕捉语义相似
例如用户查询:
Spring AI 如何保存聊天记录?
向量检索可能找到:
Spring AI 对话记忆
关键词检索可能找到:
ChatMemory、MessageChatMemoryAdvisor
合并后既能查得广,也能查得准。
重排序
初次召回属于粗筛选,可能返回较多相关性一般的文本块。
向量或混合检索
↓
召回 20 个候选 Chunk
↓
Reranker 重新打分
↓
选出最相关的 3~5 个
重排序模型会同时分析:
用户问题 + 候选文本块
然后重新计算相关性分数。
常见模型:
Cross-Encoder
Rerank Model
对比:
召回模型:速度快,负责从大量数据中粗筛
重排模型:速度较慢,负责对少量候选结果精排
上下文融合
将最终检索出的文本块和用户问题组合成增强提示词:
请根据以下参考资料回答问题。
如果资料中没有答案,请明确说明不知道,不要编造。
参考资料:
[资料1] ...
[资料2] ...
[资料3] ...
用户问题:
2024年诺贝尔物理学奖得主是谁?
上下文融合需要注意:
- 明确要求模型基于资料回答
- 限制模型不要编造
- 保留资料之间的边界
- 控制总 Token 数量
- 可以携带文件名、页码和链接
内容生成
最后将增强后的 Prompt 发送给大模型:
增强 Prompt
↓
Chat Model
↓
基于知识库生成答案
此时模型不只依赖训练数据,还可以使用检索到的实时或私有知识。
元数据的作用
每个文本块可以携带元数据:
{
"fileName": "诺贝尔奖名单.pdf",
"page": 5,
"url": "https://example.com/source",
"category": "物理学奖"
}
元数据可以用于:
- 检索过滤
- 权限控制
- 展示文件来源
- 添加引用链接
- 追溯答案依据
最终答案可以附带来源:
答案:......
来源:
1. 《诺贝尔奖名单.pdf》第5页
2. https://example.com/source
完整流程
用户问题
↓
问题向量化
↓
向量检索 + 关键词检索
↓
合并候选结果
↓
Reranker 重排序
↓
选择 Top-K 文本块
↓
拼接问题、文本块和元数据
↓
生成增强 Prompt
↓
调用大模型
↓
返回答案及引用来源
重点总结
- 内容召回决定能否找到正确资料。
- 向量检索擅长语义匹配。
- 关键词检索擅长精确匹配。
- 混合检索兼顾查得广和查得准。
- 重排序是在粗召回结果上进行精筛选。
- 上下文融合决定模型如何使用检索结果。
- 元数据可以用于过滤、权限控制和来源引用。
- RAG 能减少幻觉,但错误资料仍可能产生错误答案。
四. 使用 LlamaIndex 构建简单 RAG
LlamaIndex 是面向大模型的数据连接和检索框架,封装了 RAG 的完整流程:
加载文档 → 文档分片 → 向量化 → 建立索引
↓
用户提问 → 相似度检索 → 拼接上下文 → LLM生成答案
本例使用:
- LlamaIndex:实现 RAG 流程
- OpenAI 兼容接口:调用聊天模型
- Hugging Face:在本地运行 Embedding 模型
- 内存向量库:保存文本向量
初始化项目
cd D:\wl\hollis\ai_workspace
uv init --no-package llamaindex_rag
cd llamaindex_rag
uv venv
.\.venv\Scripts\Activate.ps1
添加依赖
uv add llama-index llama-index-llms-openai
uv add llama-index-embeddings-huggingface sentence-transformers
创建测试文档
New-Item -ItemType Directory -Force data
@'
《斗破苍穹》是天蚕土豆创作的玄幻小说。
小说以斗气大陆为背景,讲述萧炎从天才变成废柴,
随后重新修炼并逐渐成长为斗帝的故事。
作品中包含异火、炼药师和斗气修炼等设定。
'@ | Set-Content -Encoding UTF8 data\test.txt
创建 main.py
java
@'
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
API_KEY = "123456"
BASE_URL = "http://127.0.0.1:8317/v1"
# 1. 加载 data 目录中的文档
documents = SimpleDirectoryReader("data").load_data()
# 2. 使用 OpenAI 兼容接口调用聊天模型
llm = OpenAI(
model="gpt-5.6-sol",
api_key=API_KEY,
api_base=BASE_URL,
temperature=0.1,
)
# 3. 使用本地 Hugging Face Embedding 模型生成向量
embed_model = HuggingFaceEmbedding(
model_name="BAAI/bge-small-zh-v1.5"
)
# 4. 文档分片、向量化并构建内存向量索引
index = VectorStoreIndex.from_documents(
documents,
embed_model=embed_model,
)
# 5. 创建查询引擎
query_engine = index.as_query_engine(
llm=llm,
similarity_top_k=3,
)
# 6. 执行 RAG 查询
response = query_engine.query(
"请总结这份文档的主要内容。"
)
print(response)
'@ | Set-Content -Encoding UTF8 main.py
运行程序
uv run main.py
第一次运行时需要从 Hugging Face 下载:
BAAI/bge-small-zh-v1.5
出现下面的内容只是警告,不是程序错误:
Warning: You are sending unauthenticated requests to the HF Hub
不配置 Hugging Face Token 也能下载,但速度和请求次数可能受限。
核心代码说明
SimpleDirectoryReader
documents = SimpleDirectoryReader("data").load_data()
作用:
- 读取
data目录中的文件 - 解析 TXT、PDF 等文档
- 转换成 LlamaIndex 的
Document对象
HuggingFaceEmbedding
embed_model = HuggingFaceEmbedding(
model_name="BAAI/bge-small-zh-v1.5"
)
作用:
- 在本地将文本转换为向量
- 不需要 OpenAI Embedding API
bge-small-zh-v1.5对中文检索效果较好
本地代理没有提供 /v1/embeddings 接口,因此不能使用:
OpenAIEmbedding(
model="text-embedding-3-small"
)
否则会返回:
404 Not Found
OpenAI
llm = OpenAI(
model="gpt-5.6-sol",
api_key="123456",
api_base="http://127.0.0.1:8317/v1",
)
这里使用的是 OpenAI 兼容协议,并不要求服务一定是 OpenAI 官方接口。
参数说明:
model:代理服务提供的模型名称api_key:访问本地代理的认证密码api_base:OpenAI 兼容接口地址temperature:控制回答随机性
123456 只是本地代理的访问密码,不是 OpenAI 官方 API Key。
VectorStoreIndex
index = VectorStoreIndex.from_documents(
documents,
embed_model=embed_model,
)
内部会自动完成:
Document
↓
文档分片
↓
生成文本向量
↓
保存原文和向量
↓
构建内存索引
默认使用内存向量存储,程序结束后数据不会持久化。
as_query_engine
query_engine = index.as_query_engine(
llm=llm,
similarity_top_k=3,
)
作用:
- 将用户问题转换为向量。
- 从索引中检索最相似的文本块。
- 取相关度最高的三个文本块。
- 将文本块与问题拼接成提示词。
- 调用聊天模型生成最终答案。
当前模型调用问题
当前代理接口可以访问:
http://127.0.0.1:8317/v1
但是模型调用返回:
auth_unavailable: no auth available
说明:
本地代理服务:正常
本地API Key:认证通过
本地Embedding:正常
上游Codex认证:尚未配置
聊天模型调用:暂时不可用
需要先在 CLI Proxy API 中配置 Codex/OpenAI 上游账号。
查看代理提供的模型:
curl.exe `
-H "Authorization: Bearer 123456" `
"http://127.0.0.1:8317/v1/models"
正常情况下应该返回:
{
"object": "list",
"data": [
{
"id": "gpt-5.6-sol"
}
]
}
如果返回:
{
"data": [],
"object": "list"
}
说明代理没有配置任何可用的上游模型。
本例完整流程
data/test.txt
↓
SimpleDirectoryReader 加载文档
↓
LlamaIndex 自动进行文档分片
↓
bge-small-zh-v1.5 在本地生成向量
↓
VectorStoreIndex 保存向量和原文
↓
用户输入问题
↓
使用同一个 Embedding 模型生成问题向量
↓
检索 Top-3 相似文本块
↓
将问题和文本块拼接成增强提示词
↓
通过 OpenAI 兼容接口调用 gpt-5.6-sol
↓
生成基于文档内容的答案
重点总结
- LLM 和 Embedding 是两个不同组件。
- LLM 负责理解上下文并生成答案。
- Embedding 负责将文本转换成向量。
- 建库和查询必须使用同一个 Embedding 模型。
- 当前 Embedding 在本地运行,不需要 OpenAI API。
- 本地
8317接口只负责聊天模型调用。 123456是本地代理密码,不是上游模型凭证。- 代理必须配置上游账号,否则会出现
auth_unavailable。 - 当前向量数据保存在内存中,程序退出后不会保留。
优化方向
尽管以上代码实现了一个简单的RAG,但是如果想要在生产或高要求场景下使用,还是有很多东西可以优化和增强的,因为如果RAG只是需要这么20-30行代码的话,我们也没必要讲这个课程了。
可以优化的方向至少有以下这些,这些内容我们在本章节后续都会讲(所以这里看不懂也没关系,只是想说明,RAG并不是这么简单的。),但是会以Java作为主语言讲解。
- 文档分块(Chunking)策略优化
- 使用持久化向量数据库
- 查询优化
- 文档检索优化
- 对话记忆
- 重排序
- 混合检索
- 多模态
- RAG效果测评
五. RAG 的范式演进
RAG 主要经历了三个阶段:
Naive RAG → Advanced RAG → Modular RAG
基础可用 优化效果 模块化扩展
Naive RAG
Naive RAG(基础 RAG)是最标准、最简单的 RAG 实现。
基本流程:
用户提问
↓
向量检索
↓
召回相关文档
↓
拼接问题和上下文
↓
大模型生成答案
特点:
- 流程简单、容易实现
- 完整体现"检索 + 生成"
- 适合学习、验证和简单场景
- 缺少检索、重排和上下文优化
- 复杂场景下准确率和稳定性有限
Advanced RAG
Advanced RAG(进阶型 RAG)是在基础 RAG 上增加多种优化策略,是当前生产环境中常见的实现方式。
常见优化手段:
- 混合检索:结合关键词检索与向量检索
- 重排序:对初步召回结果重新打分和排序
- 上下文压缩:提取长文档中的关键信息
- 多轮记忆:结合对话历史理解用户问题
- 问题重写:将模糊问题改写成更适合检索的问题
典型流程:
用户问题
↓
问题重写
↓
关键词检索 + 向量检索
↓
结果融合
↓
重排序
↓
上下文压缩
↓
大模型生成答案
Advanced RAG 的目标是让 RAG 从"能用"变成"好用"。
Modular RAG
Modular RAG(模块化 RAG)将 RAG 的不同处理环节拆分成可独立配置、替换和组合的模块。
核心模块:
- 查询转换模块:问题改写、扩展和分解
- 检索模块:向量检索、关键词检索和混合检索
- 重排序模块:对候选文档重新评分和排序
- 上下文构建模块:筛选、压缩和拼接上下文
- 生成模块:调用大模型生成答案
- 后处理模块:事实校验、格式转换和引用标注
- 路由模块:根据问题类型选择不同处理流程
模块化流程:
┌→ 向量检索 ─┐
用户问题 → 查询转换 → 路由 → 重排序 → 上下文构建 → 生成 → 后处理
└→ 关键词检索 ┘
不同场景可以组合不同模块:
- 安全情报:增加时效性筛选
- 法律和医疗:增加事实校验与引用验证
- 长文档问答:增加上下文压缩
- 多数据源问答:增加动态路由和数据源选择
- 复杂问题:增加问题分解和多路检索
主要优势:
- 模块可以独立开发、替换和升级
- 职责清晰,便于多人协作
- 支持多种数据源和检索策略
- 支持动态路由和复杂流程编排
- 可以根据业务场景自由组合
Modular RAG 的目标是让 RAG 从"好用"变成"可扩展"。
三种范式对比
| 范式 | 核心特点 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Naive RAG | 单一检索生成流程 | 简单、开发速度快 | 准确率和稳定性有限 | 学习、原型验证 |
| Advanced RAG | 加入检索和生成优化 | 效果更好、更加稳定 | 流程更复杂 | 企业生产应用 |
| Modular RAG | 各处理环节模块化 | 灵活、可扩展、可维护 | 架构与工程成本更高 | 复杂企业级系统 |
核心总结
Naive RAG:
解决"如何实现检索增强生成"。
Advanced RAG:
解决"如何提高检索和回答质量"。
Modular RAG:
解决"如何灵活组合、扩展和维护RAG系统"。
六. 文档预处理
文档预处理是将不同格式的原始文档加载、解析并转换为统一的 Document 对象,同时清理无效内容。
核心流程:
原始文件 → 文档读取 → Document → 数据清洗 → 分片 → 向量化
主要包括:
- 文档读取
- 数据清洗
本文代码基于 Spring AI 1.1.0。
Document 对象
Spring AI 使用 Document 统一表示不同格式的文档:
java
org.springframework.ai.document.Document
主要包含:
text:文档文本内容metadata:文件名、页码、来源、时间等元数据
统一转换成 Document 后,才能继续进行:
- 文档清洗
- 文档分片
- Embedding 向量化
- 向量数据库存储
- RAG 检索
文档读取策略
不同格式需要使用不同的读取器。为了避免大量 if-else,可以使用策略模式统一管理。
策略接口
java
public interface DocumentReaderStrategy {
/**
* 判断是否支持该文件。
*/
boolean supports(File file);
/**
* 读取文件并转换成Document。
*/
List<Document> read(File file) throws IOException;
}
TXT 文档读取
TextReader 位于 Spring AI Commons 中,不需要额外引入文档读取依赖。
java
@Component
public class TextReaderStrategy implements DocumentReaderStrategy {
@Override
public boolean supports(File file) {
String name = file.getName().toLowerCase();
return name.endsWith(".txt")
|| name.endsWith(".tex")
|| name.endsWith(".text");
}
@Override
public List<Document> read(File file) {
Resource resource = new FileSystemResource(file);
return new TextReader(resource).get();
}
}
适用于:
- TXT
- TEX
- 其他纯文本文件
PDF 文档读取
核心依赖
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
<version>1.1.0</version>
</dependency>
Spring AI 提供了两个常用的 PDF 读取器:
| 读取器 | 处理方式 |
|---|---|
PagePdfDocumentReader |
按页生成 Document |
ParagraphPdfDocumentReader |
按语义段落生成 Document |
RAG 场景通常优先考虑 ParagraphPdfDocumentReader,因为它能更好地保留语义完整性。
但是它依赖 PDF 的内部结构。如果是扫描版 PDF 或文档结构较差,解析效果可能不理想。
按页读取
java
@Component
public class PdfReaderStrategy implements DocumentReaderStrategy {
@Override
public boolean supports(File file) {
return file.getName()
.toLowerCase()
.endsWith(".pdf");
}
@Override
public List<Document> read(File file) {
PdfDocumentReaderConfig config =
PdfDocumentReaderConfig.builder()
// 忽略页眉区域
.withPageTopMargin(50)
// 忽略页脚区域
.withPageBottomMargin(50)
// 每页生成一个Document
.withPagesPerDocument(1)
.withPageExtractedTextFormatter(
new ExtractedTextFormatter.Builder()
.withNumberOfTopTextLinesToDelete(0)
.build()
)
.build();
Resource resource = new FileSystemResource(file);
return new PagePdfDocumentReader(resource, config).get();
}
}
HTML 文档读取
核心依赖
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-jsoup-document-reader</artifactId>
<version>1.1.0</version>
</dependency>
读取策略
java
@Component
public class HtmlReaderStrategy implements DocumentReaderStrategy {
@Override
public boolean supports(File file) {
String name = file.getName().toLowerCase();
return name.endsWith(".html")
|| name.endsWith(".htm");
}
@Override
public List<Document> read(File file) {
JsoupDocumentReaderConfig config =
JsoupDocumentReaderConfig.builder()
// 只提取p标签
.selector("p")
// 文件编码
.charset("UTF-8")
// 保留链接地址
.includeLinkUrls(true)
// 提取指定meta标签
.metadataTags(List.of("author", "date"))
// 添加自定义元数据
.additionalMetadata(
"filename",
file.getName()
)
.build();
Resource resource = new FileSystemResource(file);
return new JsoupDocumentReader(resource, config).get();
}
}
适用于:
- 本地 HTML 文件
- 网页正文提取
- 使用 CSS Selector 精确提取内容
Markdown 文档读取
核心依赖
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-markdown-document-reader</artifactId>
<version>1.1.0</version>
</dependency>
读取策略
java
@Component
public class MarkdownReaderStrategy implements DocumentReaderStrategy {
@Override
public boolean supports(File file) {
return file.getName()
.toLowerCase()
.endsWith(".md");
}
@Override
public List<Document> read(File file) {
MarkdownDocumentReaderConfig config =
MarkdownDocumentReaderConfig.builder()
// 水平分割线生成新的Document
.withHorizontalRuleCreateDocument(true)
// 不读取代码块
.withIncludeCodeBlock(false)
// 不读取引用块
.withIncludeBlockquote(false)
// 添加文件名元数据
.withAdditionalMetadata(
"filename",
file.getName()
)
.build();
Resource resource = new FileSystemResource(file);
return new MarkdownDocumentReader(resource, config).get();
}
}
JSON 文档读取
JsonReader 适合结构简单的 JSON,不适合复杂嵌套结构。
java
@Component
public class JsonReaderStrategy implements DocumentReaderStrategy {
@Override
public boolean supports(File file) {
return file.getName()
.toLowerCase()
.endsWith(".json");
}
@Override
public List<Document> read(File file) {
Resource resource = new FileSystemResource(file);
// 提取description和content字段
JsonReader jsonReader =
new JsonReader(
resource,
"description",
"content"
);
return jsonReader.get();
}
}
复杂 JSON 建议使用:
- Jackson
- Fastjson
- Gson
解析后再手动构造 Document。
Word 文档读取
核心依赖
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
<version>1.1.0</version>
</dependency>
Apache Tika 能自动识别并解析多种文档格式:
- Word
- PowerPoint
- Excel
- TXT
读取策略
java
@Component
public class TikaReaderStrategy implements DocumentReaderStrategy {
@Override
public boolean supports(File file) {
String name = file.getName().toLowerCase();
return name.endsWith(".doc")
|| name.endsWith(".docx");
}
@Override
public List<Document> read(File file) {
Resource resource = new FileSystemResource(file);
return new TikaDocumentReader(resource).get();
}
}
策略选择器
Spring 会自动注入所有 DocumentReaderStrategy 实现。
java
@Service
public class DocumentReaderStrategySelector {
private final List<DocumentReaderStrategy> strategies;
public DocumentReaderStrategySelector(
List<DocumentReaderStrategy> strategies) {
this.strategies = strategies;
}
public List<Document> read(File file) throws IOException {
return strategies.stream()
.filter(strategy -> strategy.supports(file))
.findFirst()
.orElseThrow(() ->
new IllegalArgumentException(
"不支持的文件类型: " + file.getName()
)
)
.read(file);
}
}
执行流程:
接收文件
↓
遍历所有读取策略
↓
调用supports(file)
↓
找到支持该格式的策略
↓
调用read(file)
↓
返回List<Document>
数据清洗
原始文档通常包含:
- 多余空格
- 连续换行
- 制表符
- 乱码和无效符号
- 重复段落
- 页眉和页脚
- 格式不一致的文本
数据清洗可以提升后续分片、向量化和检索的质量。
文档清洗器
java
@Component
public class DocumentCleaner {
public List<Document> clean(List<Document> documents) {
if (CollectionUtils.isEmpty(documents)) {
return documents;
}
return documents.stream()
.filter(Objects::nonNull)
.map(this::cleanDocument)
.toList();
}
private Document cleanDocument(Document document) {
if (document.getText() == null) {
return document;
}
String text = document.getText();
// 1. 统一换行符
text = text.replace("\r\n", "\n")
.replace('\r', '\n');
// 2. 清理空格和制表符,但保留换行
text = text.replaceAll("[\\t\\x0B\\f ]+", " ");
// 3. 清理无意义的特殊字符
text = text.replaceAll(
"[^\\p{L}\\p{N}\\p{P}\\p{Z}\\n]",
""
);
// 4. 根据换行拆分并删除重复段落
Set<String> paragraphs =
Arrays.stream(text.split("\\n+"))
.map(String::trim)
.filter(paragraph -> !paragraph.isEmpty())
.collect(
Collectors.toCollection(
LinkedHashSet::new
)
);
text = String.join("\n", paragraphs).trim();
// 保留原Document的元数据
return new Document(
text,
document.getMetadata()
);
}
}
清洗顺序:
统一换行
↓
清理空格和特殊字符
↓
根据换行拆分段落
↓
删除重复段落
↓
重新拼接文本
不能在段落去重之前执行:
java
text = text.replaceAll("\\s+", " ");
因为 \s 包含换行符,这会将所有换行替换为空格,导致后续无法根据换行拆分段落。
测试 Controller
java
@RestController
@RequestMapping("/rag")
public class RagController {
private final DocumentReaderStrategySelector selector;
private final DocumentCleaner cleaner;
public RagController(
DocumentReaderStrategySelector selector,
DocumentCleaner cleaner) {
this.selector = selector;
this.cleaner = cleaner;
}
/**
* 读取并清洗文档。
*/
@GetMapping("/read")
public List<Document> readDocument(
@RequestParam("path") String path) {
File file = new File(path);
if (!file.exists() || !file.isFile()) {
throw new IllegalArgumentException(
"文件不存在或不是有效文件: " + path
);
}
try {
List<Document> documents =
selector.read(file);
return cleaner.clean(documents);
} catch (IOException e) {
throw new RuntimeException(
"读取文件失败: " + e.getMessage(),
);
}
}
}
调用示例:
GET /rag/read?path=D:/data/test.pdf
不同读取器对比
| 文件类型 | 读取器 | 特点 |
|---|---|---|
| TXT | TextReader |
直接读取纯文本 |
PagePdfDocumentReader |
按页读取 | |
ParagraphPdfDocumentReader |
按语义段落读取 | |
| HTML | JsoupDocumentReader |
支持 CSS Selector |
| Markdown | MarkdownDocumentReader |
支持标题、代码块和引用配置 |
| JSON | JsonReader |
适合简单 JSON |
| Word 等 | TikaDocumentReader |
通用性强,自动识别格式 |
核心总结
DocumentReader:
负责把不同格式的原始文件转换成统一的Document。
策略模式:
负责根据文件扩展名选择对应的DocumentReader。
DocumentCleaner:
负责清理噪声、重复内容和格式问题。
最终目标:
为文档分片、Embedding和向量检索提供高质量数据。
注意事项:
- 根据文件格式选择合适的读取器。
- PDF 优先考虑语义完整性。
- 扫描版 PDF 需要先进行 OCR。
- 清洗时不要误删有价值的符号和换行。
- 清洗后应该保留原始
metadata。 - 数据质量会直接影响 RAG 的检索和生成效果。
七. 常见文档分片方式
什么是文档分片
文档分片也叫:
text
Chunking
Splitting
分段 / 分片 / 分块
它是将大文档拆成较小且尽量语义完整的 Chunk,方便:
- 满足嵌入模型和大模型的上下文限制。
- 提高向量检索的准确性。
- 只将与问题相关的内容交给大模型。
- 降低 Token 消耗。
text
原始文档
↓ Chunking
多个文本块
↓ 向量检索
相关文本块
↓
大模型生成答案
五种常见分片方式
text
Fixed Size Chunking 固定大小分块
Recursive Chunking 递归分块
Document Based Chunking 基于文档结构分块
Semantic Chunking 语义分块
LLM-based Chunking 基于大模型分块
固定大小分块
按照固定字符数直接切分,不考虑文档结构和语义。
核心参数:
text
chunk_size 每个文本块的最大长度
chunk_overlap 相邻文本块的重叠长度
示例:
text
原文:123456789ABCDEFGHIJ
chunk_size = 10
chunk_overlap = 3
Chunk 1:123456789A
Chunk 2:89ABCDEFGH
Chunk 3:FGHIJ
重叠内容可以减少文本块边界处的语义断裂,但也会产生重复数据。
优点:
- 实现简单。
- 分块大小稳定。
- 处理速度快。
缺点:
- 不考虑段落、标题和句子。
- 容易从句子中间切断。
- 可能破坏语义完整性。
递归分块(常用)
按照分隔符优先级逐层切分:
java
String[] separators = {
"\n\n",
"\n",
"。",
"!",
"?",
";",
",",
" "
};
执行逻辑:
- 先按段落切分。
- 分块仍然过长,则按换行切分。
- 仍然过长,则按句号、逗号等切分。
- 所有分隔符都无法满足要求,最后按固定长度切分。
核心代码:
java
private void splitText(
String text,
int separatorIndex,
List<String> chunks) {
if (text.isEmpty()) {
return;
}
if (text.length() <= chunkSize) {
chunks.add(text);
return;
}
if (separatorIndex >= separators.length) {
for (int i = 0; i < text.length();
i += chunkSize) {
int end = Math.min(
i + chunkSize,
text.length()
);
chunks.add(text.substring(i, end));
}
return;
}
String separator =
separators[separatorIndex];
String[] splits = text.split(separator);
for (String split : splits) {
if (split.length() > chunkSize) {
splitText(
split,
separatorIndex + 1,
chunks
);
} else {
chunks.add(split);
}
}
}
之所以叫递归分块,是因为当前分隔符切分后仍然过长时,会递归使用下一个分隔符继续切分。
常用参数:
text
分段标识符
分段最大长度
分段重叠长度
基于文档结构分块
根据文档自身结构切分,例如:
- Markdown 标题
- HTML 标签
- PDF 页码
- 表格
- 代码中的类和方法
- Python、JavaScript 等代码结构
这种方式比普通字符切分更容易保留文档的层级和上下文关系。
语义分块(常用)
根据主题、句子含义或语义变化确定分块边界,使语义相似的内容尽量处于同一个 Chunk。
部分实现会使用 OpenNLP 进行句子边界识别:
java
SentenceDetectorME sentenceDetector =
new SentenceDetectorME(sentenceModel);
String[] texts =
sentenceDetector.sentDetect(text);
SentenceDetectorME:OpenNLP 提供的句子检测器。sentenceModel:预训练的句子识别模型。sentDetect():将文本切分为句子。
注意:句子检测主要负责识别句子边界;严格意义上的语义分块通常还会结合向量相似度判断主题变化。
基于LLM分块
将文档交给大模型,让模型根据主题和语义组织文本块。
示例提示词:
text
请将以下文档按照主题进行分块:
要求:
1. 每个分块保持语义完整。
2. 不同主题分别放入不同分块。
3. 为每个分块生成标题。
4. 不要修改原文内容。
示例结果:
text
Chunk 1:基础信息与出版
Chunk 2:故事背景与世界观
Chunk 3:市场表现与荣誉
Chunk 4:衍生作品开发
Chunk 5:IP价值与法律案例
优点:
- 能理解主题和上下文。
- 分块结果更加自然。
- 适合结构混乱的复杂文档。
缺点:
- Token 成本较高。
- 处理速度较慢。
- 输出结果可能不稳定。
常用方案
百炼、Dify、Coze 等平台常见的分块方式包括:
- 按长度切分
- 按符号切分
- 按标题切分
- 按页切分
- 按正则切分
- 智能或语义切分
- 父子分块
实际使用最多的是:
text
递归分块
语义分块
选择建议
text
普通文本 → 递归分块
Markdown/HTML → 基于文档结构分块
主题变化明显 → 语义分块
结构复杂且质量优先 → LLM分块
快速测试 → 固定大小分块
重点
- Chunk 太大:检索不够精确,并占用更多上下文。
- Chunk 太小:语义容易不完整,缺少上下文。
overlap可以减少边界信息丢失,但会增加存储量和重复召回。- 递归分块简单、稳定,是最常用的默认方案。
- 语义分块效果通常更好,但成本和复杂度更高。
- 最优分块方式需要结合文档类型和实际检索效果确定。
八 . 文档分片的代码实现
本节主要介绍三种实现:
text
Spring AI → TokenTextSplitter
Spring AI Alibaba → RecursiveCharacterTextSplitter
LangChain4j → DocumentBySentenceSplitter
文档处理流程
java
@GetMapping("/read")
public List<Document> readDocument(String path) {
File file = new File(path);
if (!file.exists() || !file.isFile()) {
throw new IllegalArgumentException(
"文件不存在或不是有效文件: " + path
);
}
try {
// 1. 加载文档
List<Document> documents =
selector.read(file);
// 2. 文本清洗
documents = cleanDocuments(documents);
// 3. 文档分片
documents = split(documents);
return documents;
} catch (IOException e) {
throw new RuntimeException(
"读取文件失败: " + e.getMessage(),
e
);
}
}
完整流程:
text
加载文档
↓
文本清洗
↓
文档分片
↓
向量化
↓
存入向量数据库
Spring AI:TokenTextSplitter
TextSplitter 是 Spring AI 文本分片器的抽象基类,TokenTextSplitter 按 Token 数量切分文本。
java
public List<Document> split(
List<Document> documents) {
if (CollectionUtils.isEmpty(documents)) {
return Collections.emptyList();
}
TokenTextSplitter splitter =
new TokenTextSplitter(
600, // 每块目标大小:600 tokens
300, // 每块最小字符数
5, // 最短嵌入长度
8000, // 最大分块数量
true // 保留分隔符
);
return splitter.apply(documents);
}
参数含义:
text
chunkSize 每块目标Token数量
minChunkSizeChars 每块最小字符数
minChunkLengthToEmbed 允许向量化的最小长度
maxNumChunks 单个文档最大分块数量
keepSeparator 是否保留换行等分隔符
注意:
text
Token不等于字符
一个 Token 可能对应一个字符、多个字符或一个单词的一部分。
课程示例中:
text
分片前:12个Document
分片后:23个Document
自定义重叠分片器
课程版本中的 TokenTextSplitter 不支持 overlap,可以继承 TextSplitter 自定义实现。
java
public class OverlapParagraphTextSplitter
extends TextSplitter {
private final int chunkSize;
private final int overlap;
public OverlapParagraphTextSplitter(
int chunkSize,
int overlap) {
if (chunkSize <= 0) {
throw new IllegalArgumentException(
"chunkSize必须大于0"
);
}
if (overlap < 0 || overlap >= chunkSize) {
throw new IllegalArgumentException(
"overlap必须大于等于0且小于chunkSize"
);
}
this.chunkSize = chunkSize;
this.overlap = overlap;
}
@Override
public List<String> splitText(String text) {
if (!StringUtils.hasText(text)) {
return Collections.emptyList();
}
List<String> chunks = new ArrayList<>();
int start = 0;
while (start < text.length()) {
int end = Math.min(
start + chunkSize,
text.length()
);
chunks.add(text.substring(start, end));
if (end == text.length()) {
break;
}
start = end - overlap;
}
return chunks;
}
}
使用:
java
OverlapParagraphTextSplitter splitter =
new OverlapParagraphTextSplitter(
400, // 每块最大字符数
100 // 相邻块重叠字符数
);
List<Document> chunks =
splitter.apply(documents);
重叠效果:
text
Chunk 1:AAAAA BBBBB CCCCC
Chunk 2:CCCCC DDDDD EEEEE
↑
重叠内容
overlap 可以减少分块边界的语义丢失,但会增加存储量和重复召回。
Spring AI Alibaba:递归分片
java
RecursiveCharacterTextSplitter splitter =
new RecursiveCharacterTextSplitter(100);
List<String> chunks = splitter.splitText("""
第一段文本......
第二段文本......
第三段文本......
""");
chunks.forEach(System.out::println);
它会按照分隔符优先级递归切分:
text
段落
↓ 仍然过长
换行
↓ 仍然过长
句号、问号、感叹号
↓ 仍然过长
逗号、空格
↓
固定长度
重要注意事项:
text
使用递归分片前,不要清除换行、空格和标点符号。
因为递归分片依赖这些字符判断分块边界。如果清洗阶段提前删除,分片效果会明显下降。
LangChain4j:DocumentBySentenceSplitter
java
DocumentBySentenceSplitter splitter =
new DocumentBySentenceSplitter(
100, // 每块最大长度
10 // 重叠长度
);
String[] chunks = splitter.split("""
Harry Potter is a series of fantasy novels.
The novels were written by J. K. Rowling.
The main story concerns Harry's conflict
with Lord Voldemort.
""");
for (String chunk : chunks) {
System.out.println(chunk);
}
该分片器会尽量按照完整句子切分,避免简单地从固定字符位置截断。
在课程使用的版本中需要注意调用:
java
split(String text)
而不是:
java
split(Document document)
同时,课程使用的默认句子模型对英文效果较好,对中文支持有限。中文文档需要更换中文句子模型,或者选择递归分片器。
严格来说,DocumentBySentenceSplitter 是句子边界分片,并不是基于向量相似度的完整语义分片。
三种方式对比
| 分片器 | 分片依据 | 重叠 | 适用场景 |
|---|---|---|---|
TokenTextSplitter |
Token数量 | 课程版本不支持 | 控制模型Token数量 |
RecursiveCharacterTextSplitter |
段落、换行、标点 | 视实现而定 | 中文普通文档 |
DocumentBySentenceSplitter |
句子边界 | 支持 | 英文自然语言文档 |
自定义 TextSplitter |
自定义规则 | 支持 | 特殊业务需求 |
重点
TokenTextSplitter适合控制每块的 Token 数量。- 递归分片优先保留段落和句子,是中文文档的常用方案。
- 使用递归分片时,不能提前删除标点和换行。
DocumentBySentenceSplitter更适合默认模型支持的英文文本。- Spring AI 功能不满足时,可以继承
TextSplitter自定义实现。 - 分片效果需要通过实际检索召回结果评估,不能只观察分块长度。
九. 父子分片
什么是父子分片
父子分片同时保存两种大小的文本块:
text
父分片:内容较大,保留完整上下文
子分片:内容较小,用于向量检索
核心思想:
text
使用子分片检索
使用父分片生成
小分片检索精确,但上下文可能不完整;大分片上下文完整,但内容过多会稀释向量语义。父子分片结合了两者的优点。
为什么需要父子分片
Embedding模型存在Token限制
Embedding 模型只能处理一定长度的文本。例如课程中的 text-embedding-v4 单批次最大 Token 数为 8192。
因此,文档分片必须支持:
text
chunkSize
避免文本超过 Embedding 模型的处理限制。
普通切分会破坏语义
完整内容可能被拆到不同 Chunk 中:
text
我是一个完整的句子
当:
text
chunkSize = 5
可能拆成:
text
我是一个完
整的句子
单独召回其中一个子块时,语义可能不完整。
overlap 可以缓解普通文本的语义断裂,但对于 Markdown 图片、表格和代码块等结构化内容,简单重叠不一定有效。
分片示例
text
父分片:
id = 5
content = 我是一个完整的句子
子分片1:
content = 我是一个完
parentChunkId = 5
子分片2:
content = 整的句子
parentChunkId = 5
子分片的 Metadata:
json
{
"chunkType": "child",
"parentChunkId": 5
}
存储方式
父分片不参与向量相似度检索,可以存入关系型数据库:
text
父分片
↓
MySQL
子分片需要向量化并存入向量数据库:
text
子分片
↓ Embedding
向量
↓
pgvector等向量数据库
整体结构:
text
父分片 id=5
├─ 子分片1 parentChunkId=5
└─ 子分片2 parentChunkId=5
文档分片流程
text
原始文档
↓ 按标题、章节或段落生成父分片
父分片
↓ 按chunkSize继续切分
多个子分片
↓
子分片向量化并存入向量库
父分片原文存入关系型数据库
文档检索流程
text
用户问题
↓ Embedding
问题向量
↓
向量数据库检索子分片
↓
读取parentChunkId
↓
查询父分片
↓
父分片去重
↓
将完整父分片交给LLM
伪代码:
java
List<Document> children =
vectorStore.similaritySearch(question);
Set<Long> parentIds = children.stream()
.map(document -> document.getMetadata()
.get("parentChunkId"))
.map(value -> Long.valueOf(value.toString()))
.collect(Collectors.toSet());
List<ParentChunk> parents =
parentChunkRepository.findAllById(parentIds);
重点
- 父分片负责保留完整上下文。
- 子分片负责向量化和精确检索。
- 子分片通过
parentChunkId关联父分片。 - 检索到子分片后,需要替换为对应的父分片再交给大模型。
- 多个子分片可能指向同一个父分片,因此必须去重。
- 父分片通常存关系型数据库,子分片通常存向量数据库。
- 父子分片特别适合长文档、Markdown、表格和包含图片描述的文档。
十. 向量模型、向量数据库与向量存储
核心概念
| 组件 | 作用 |
|---|---|
EmbeddingModel |
将文本转换成向量 |
| 向量数据库 | 保存向量,支持相似度检索 |
VectorStore |
Spring AI 的统一操作接口,封装向量化、存储与检索 |
text
文档读取与清洗
↓
文档分片 List<Document>
↓ EmbeddingModel
向量
↓
向量数据库
EmbeddingModel
Spring AI 使用 EmbeddingModel 统一对接不同厂商的向量模型。
java
// 单个文本向量化
float[] vector = embeddingModel.embed("什么是RAG?");
// 单个文档向量化
float[] documentVector =
embeddingModel.embed(document);
// 批量文本向量化
List<float[]> vectors =
embeddingModel.embed(List.of("文本1", "文本2"));
// 获取完整响应
EmbeddingResponse response =
embeddingModel.embedForResponse(
List.of("文本1", "文本2")
);
一个文本对应一个浮点数组:
text
文本 → [0.12, -0.35, 0.68, ...]
dimensions = 768 表示每个向量包含 768 个数字,不是文本长度限制。
DashScopeEmbeddingModel配置
引入相应 Starter 并完成配置后,可以注入 EmbeddingModel 使用。
yaml
spring:
ai:
dashscope:
api-key: ${DASHSCOPE_API_KEY}
embedding:
options:
model: text-embedding-v4
dimensions: 768
OpenAiEmbeddingModel配置(可选)
也可以通过 OpenAI 兼容接口调用通义千问,两种接入方式选择一种即可。
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
yaml
spring:
ai:
openai:
embedding:
base-url: https://dashscope.aliyuncs.com/compatible-mode/
api-key: ${DASHSCOPE_API_KEY}
options:
model: text-embedding-v4
dimensions: 768
实际地址应与账号地域及厂商提供的接口地址一致。
接入pgvector
pgvector 是 PostgreSQL 的向量扩展,本节用它存储文档及向量。
课程 Docker 示例,改为仅供本机连接:
bash
docker run --name pgvector -e POSTGRES_USER=pgvector -e POSTGRES_PASSWORD=pgvector -e POSTGRES_DB=rag_test -p 127.0.0.1:5433:5432 -v pgvector_data:/var/lib/postgresql/data -d ankane/pgvector:v0.5.0
- 数据库:
rag_test - 用户名、密码:
pgvector,仅用于本地演示。 - 本机连接端口:
5433 - 使用命名卷保存数据库文件。
- 镜像版本沿用课程,不代表最新版。
引入依赖:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
<version>1.1.0</version>
</dependency>
如果项目已经通过 BOM 管理该版本,可以省略 version。
数据库配置
yaml
spring:
datasource:
url: jdbc:postgresql://localhost:5433/rag_test
username: pgvector
password: ${PGVECTOR_PASSWORD:pgvector}
ai:
vectorstore:
pgvector:
index-type: HNSW
distance-type: COSINE_DISTANCE
dimensions: 768
max-document-batch-size: 9
initialize-schema: true
table-name: vector_st
参数含义:
index-type:向量索引类型,示例使用 HNSW。distance-type:相似度计算方式,示例使用余弦距离。dimensions:数据库向量列的维度。max-document-batch-size:数据库批量写入的文档数量。initialize-schema:启动时初始化所需表、索引等结构,需要相应数据库权限。table-name:存储表名称。
向量模型输出的维度,必须与数据库向量列维度一致。
EmbeddingService
java
@Service
public class EmbeddingService {
private final EmbeddingModel embeddingModel;
private final VectorStore vectorStore;
public EmbeddingService(
EmbeddingModel embeddingModel,
VectorStore vectorStore) {
this.embeddingModel = embeddingModel;
this.vectorStore = vectorStore;
}
// 只向量化,不存储
public List<float[]> embed(List<Document> documents) {
if (documents == null || documents.isEmpty()) {
return List.of();
}
return documents.stream()
.map(doc -> embeddingModel.embed(doc.getText()))
.toList();
}
// 自动向量化,并存入数据库
public void embedAndStore(List<Document> documents) {
if (documents == null || documents.isEmpty()) {
return;
}
int batchSize = 9;
for (int i = 0; i < documents.size(); i += batchSize) {
List<Document> batch = documents.subList(
i,
Math.min(i + batchSize, documents.size())
);
vectorStore.add(batch);
}
}
}
两个方法的区别:
text
embeddingModel.embed()
只生成向量
vectorStore.add()
生成向量 + 保存文档、元数据和向量
不需要先调用 embed(),再调用 vectorStore.add(),否则会重复向量化。
PgVectorStore原理
PgVectorStore 依赖两个核心组件:
text
EmbeddingModel → 文本向量化
JdbcTemplate → 数据库读写
add() 内部的执行顺序:
text
接收List<Document>
↓
EmbeddingModel生成向量
↓
按照max-document-batch-size分批
↓
JdbcTemplate写入数据库
因此,数据库的分批设置不能代替向量模型的分批控制。
为什么代码中还要分批
配置:
yaml
max-document-batch-size: 9
只控制数据库写入批次。
代码:
java
vectorStore.add(documents.subList(start, end));
控制传入本次向量化、存储流程的文档数量。
注意:
9是课程示例值,不是所有模型的通用限制。- 需要同时关注模型的输入条数限制和 Token 限制。
- 即使每批只有一条,单条文本过长也可能报错。
串联文档分片与存储
沿用前文的文档读取器和分片器:
java
public String embed(String filePath) throws IOException {
List<Document> documents =
documentReaderFactory.read(new File(filePath));
RecursiveCharacterTextSplitter splitter =
new RecursiveCharacterTextSplitter(
300,
new String[]{"\n\n", "\n"}
);
List<Document> chunks = documents.stream()
.flatMap(document -> splitter.split(document).stream())
.toList();
embeddingService.embedAndStore(chunks);
return "success";
}
如果对外提供文件导入接口,需要限制可读取的路径范围,不能允许任意读取服务端文件。
最终保存的数据
text
id 文档块标识
content 文档块原文
metadata 来源、页码、父分片ID等信息
embedding 向量数组
重点
EmbeddingModel负责向量化,VectorStore封装向量化与存储。vectorStore.add()已经包含向量化,不要重复调用。- 文档与查询应使用同一套兼容的向量模型和维度配置。
- Embedding 请求分批和数据库写入分批是两回事。
- 分片时保留原文及 Metadata,便于后续检索、过滤和来源追踪。
十一. 向量数据库如何选型
向量数据库在RAG中的作用
私有文档
↓ 分片
Embedding模型
↓
向量数据库
用户问题
↓ 向量化
相似度检索
↓
相关文档 + 用户问题
↓
大模型生成答案
Spring AI 通过统一的 VectorStore 接口屏蔽不同数据库的实现差异,提供添加、删除、相似度检索和元数据过滤等能力。Spring AI VectorStore
选型关注点
- 部署复杂度:是否需要集群、Kubernetes及专业运维。
- 检索性能:查询延迟、并发量、召回率和内存占用。
- 扩展能力:能否水平扩容、分片和多副本部署。
- 集成成本:是否能复用已有 PostgreSQL 或 Elasticsearch。
- 元数据过滤:能否按用户、部门、文档类型等过滤。
- 混合检索:是否支持关键词与向量检索结合。
- 可靠性:备份恢复、高可用、监控和数据一致性。
- 社区生态:版本活跃度、Java SDK 和 Spring AI 支持。
- 总体成本:机器、运维、迁移和团队学习成本。
企业知识库通常必须支持元数据过滤,例如:
{
"department": "finance",
"tenantId": "company-01",
"documentType": "contract"
}
否则不同用户或部门的数据权限很难控制。
PGvector
PGvector 是 PostgreSQL 的向量扩展。
特点:
- 复用 PostgreSQL,不需要引入新数据库。
- 支持 SQL、结构化条件和向量检索组合。
- 继承 PostgreSQL 的事务、权限、备份和运维体系。
- 支持精确检索、HNSW 和 IVFFlat 索引。
- 适合已经使用 PostgreSQL 的企业系统。
局限:
- 水平扩展通常需要额外的分片方案。
- 超大规模场景需要根据真实数据压测,不能只按固定数量判断。
PGvector 官方支持 HNSW、IVFFlat以及带 WHERE 条件的过滤检索。pgvector官方说明
适合:
中小型RAG
企业内部知识库
已有PostgreSQL的项目
结构化条件与向量联合查询
Chroma
Chroma 是面向 AI 检索场景的轻量级向量数据库。
特点:
- 上手简单,适合快速开发。
- 支持本地、Docker和持久化运行。
- 支持文档、向量和元数据存储。
- 当前版本支持密集、稀疏和混合检索。
- 支持元数据和文档内容过滤。
局限:
- 本地单机模式更适合开发及规模可控的项目。
- 大规模生产使用前需要重点评估高可用、运维和扩展方案。
Chroma 当前官方文档已经包含混合检索和元数据过滤,因此不能再简单记录为"不支持混合检索"。Chroma官方文档
适合:
教学与实验
快速原型
个人项目
小型知识库
Milvus
Milvus 是面向大规模向量检索的分布式数据库。
特点:
- 支持分布式存储和水平扩容。
- 支持多种向量字段和索引。
- 支持稠密向量、稀疏向量及混合检索。
- 支持 BM25 全文检索与向量检索组合。
- 适合大规模、高并发场景。
局限:
- 部署和运维复杂度较高。
- 集群模式对机器和内存要求较高。
- 小型项目使用可能过重。
Milvus 官方支持稠密、稀疏和多向量混合检索。Milvus混合检索
适合:
大型RAG系统
海量知识库
推荐系统
图片、音频和视频检索
高并发向量检索
Qdrant
Qdrant 是使用 Rust 实现的高性能向量数据库。
特点:
- 支持 HNSW、向量压缩和复杂元数据过滤。
- API 简洁,支持 Docker 和集群部署。
- 支持稠密向量与稀疏向量混合检索。
- 性能和部署复杂度相对均衡。
- 支持分片和副本。
Qdrant 当前已经支持分布式部署,不能简单概括为"没有分布式能力"。Qdrant分布式部署、Qdrant混合检索
适合:
中等规模RAG
重视元数据过滤
希望兼顾性能与易用性的项目
Elasticsearch
Elasticsearch 是全文检索系统,同时支持向量和语义检索。
特点:
- 支持关键词、结构化条件和向量检索。
- 适合实现 BM25 与向量结合的混合检索。
- 企业生态成熟,监控和运维工具完善。
- 已有 Elasticsearch 集群时接入成本较低。
局限:
- 如果只需要简单向量检索,单独引入 Elasticsearch 较重。
- 索引、分片和相关性调优需要一定经验。
Elasticsearch 官方支持全文与向量检索在同一请求中进行混合查询。Elasticsearch混合检索
适合:
企业已有Elasticsearch
关键词精确匹配非常重要
需要全文 + 向量 + 条件过滤
选型对比
| 数据库 | 主要优势 | 推荐场景 | 部署成本 |
|---|---|---|---|
| PGvector | PostgreSQL与向量统一 | 已有PostgreSQL、中小型RAG | 低 |
| Chroma | 简单、开发速度快 | 教学、原型、小型项目 | 极低 |
| Milvus | 分布式和大规模检索 | 大型知识库、高并发 | 高 |
| Qdrant | 性能、过滤和易用性均衡 | 中等规模生产项目 | 中 |
| Elasticsearch | 全文与向量混合检索 | 已有ES、混合检索 | 中高 |
选择建议
快速验证 → Chroma
已有PostgreSQL → PGvector
已有Elasticsearch → Elasticsearch
中等规模、重视过滤和性能 → Qdrant
海量数据、分布式、高并发 → Milvus
不要直接根据"百万级"或"亿级"作决定。向量维度、索引类型、过滤条件、TopK、并发量和硬件配置都会影响性能。
最终选型流程:
明确数据量和并发
↓
筛选安全、过滤、混合检索等必要能力
↓
使用真实数据进行性能测试
↓
比较运维和总体成本
↓
确定最终方案
重点
- 没有最好的向量数据库,只有最适合业务的方案。
- 能复用现有基础设施时,通常优先复用。
- 元数据过滤是企业知识库的重要能力。
- 混合检索通常比单独向量检索更稳定。
- 最终选择必须以真实数据和查询场景的压测结果为准。
总结(重点)
选向量数据库的时候,关键就是看你的需求。
要是只是做实验或者快速验证想法,Chroma 就够用了;
如果你团队已经在用 PostgreSQL,或者你喜欢用 Navicat 来看数据库,PGvector 就是更好的选择;
当数据量很大、需要高性能分布式的,就无脑选择 Milvus;
想使用混合检索,且团队已有了 ELK,那就用 Elasticsearch;
Qdrant 性能不错、用起来也简单,适合中等规模的项目。
总之,没有最好的,只有最适合的。
十二. 相似度搜索的常见算法
精确检索与近似检索
向量检索需要从向量库中找到与查询向量最相似的 K 个向量。
主要分为两类:
text
精确最近邻搜索(Exact KNN)
近似最近邻搜索(ANN)
这里的 KNN 指向量数据库中的精确最近邻搜索,不是机器学习中的 KNN 分类算法。
Exact KNN:精确搜索
对查询向量与数据库中的所有向量逐一计算距离,再返回最相似的 K 个。
text
查询向量
↓
与全部向量计算相似度
↓
排序
↓
返回 TopK
特点:
- 检索结果完全准确。
- 不需要建立复杂索引。
- 数据量越大,查询越慢。
- 单次查询计算量约为
O(N × d)。
其中:
N:向量数量。d:向量维度。
适合:
- 数据规模较小。
- 对准确率要求极高。
- 作为 ANN 检索效果的对照基准。
ANN:近似最近邻搜索
ANN 不再扫描全部向量,而是通过索引快速缩小搜索范围。
text
牺牲少量召回率
↓
大幅提升检索速度
常见 ANN 算法:
text
IVF
HNSW
LSH
IVF:聚类分桶
IVF 使用 K-Means 将向量划分到多个聚类中,每个聚类对应一个倒排列表。
text
全部向量
↓ 聚类
多个向量桶
↓
查询最近的几个桶
↓
在桶内精确搜索
核心参数:
nlist:聚类桶的数量。nprobe:查询时搜索多少个桶。
text
nprobe 越大
→ 召回率越高
→ 查询越慢
常见类型:
IVF_FLAT:桶内保存原始向量,精度较高。IVF_PQ:通过 PQ 压缩向量,内存更低,但会进一步损失精度。
优点:
- 查询速度快。
- 内存相对可控。
- 适合大规模数据。
缺点:
- 建索引前需要训练聚类。
- 可能漏掉位于其他桶中的相似向量。
HNSW:分层图索引
HNSW 将向量组织成多层图结构,整体思路类似跳表。
text
高层:节点少,负责快速定位大致区域
↓
中层:进一步缩小搜索范围
↓
底层:包含全部节点,寻找最终近邻
核心参数:
M:每个节点维护的邻居数量。efConstruction:构建索引时的候选集合大小。efSearch:查询时的候选集合大小。
text
efSearch 越大
→ 召回率越高
→ 查询耗时越高
优点:
- 查询速度快。
- 召回率高。
- 支持动态插入。
- 是当前 RAG 场景的主流选择之一。
缺点:
- 图结构需要额外内存。
- 索引构建速度相对较慢。
HNSW 经常被描述为"接近
O(logN)",这是便于理解的经验描述,不是所有数据和参数下的严格保证。
LSH:局部敏感哈希
LSH 使用特殊哈希函数,让相似向量更容易落入相同的哈希桶。
text
相似向量
↓
产生相似哈希结果
↓
落入相同或相邻桶
↓
只搜索相关桶
为了减少遗漏,通常会使用:
- 多张哈希表。
- 多次哈希映射。
- 同时查询相邻桶。
优点:
- 候选向量筛选速度快。
- 适合海量数据的粗筛和近似去重。
缺点:
- 召回率波动较大。
- 参数调优比较复杂。
- 整体查询复杂度不能简单认为一定是
O(1)。
算法对比
| 算法 | 核心原理 | 查询速度 | 召回率 | 内存占用 | 适用场景 |
|---|---|---|---|---|---|
| Exact KNN | 扫描全部向量 | 慢 | 100% | 中 | 小数据、精确验证 |
| IVF_FLAT | 聚类分桶 | 快 | 可调 | 中 | 大规模通用检索 |
| IVF_PQ | 聚类 + 向量压缩 | 快 | 中 | 低 | 数据量大、内存有限 |
| HNSW | 分层图导航 | 极快 | 高 | 高 | 低延迟、高召回 RAG |
| LSH | 局部敏感哈希 | 快 | 波动较大 | 与哈希表数量有关 | 粗筛、近似去重 |
PGvector 索引示例
HNSW 索引:
sql
CREATE INDEX ON vector_store
USING hnsw (embedding vector_cosine_ops);
调整搜索范围:
sql
SET hnsw.ef_search = 100;
IVFFlat 索引:
sql
CREATE INDEX ON vector_store
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
调整查询桶数量:
sql
SET ivfflat.probes = 10;
Spring AI 配置
yaml
spring:
ai:
vectorstore:
pgvector:
index-type: HNSW
distance-type: COSINE_DISTANCE
dimensions: 768
含义:
HNSW:使用分层图索引。COSINE_DISTANCE:使用余弦距离比较语义相似度。768:向量模型输出的向量维度。
选型建议
text
数据量小、要求绝对准确
→ Exact KNN
追求低延迟和高召回,内存充足
→ HNSW
数据规模较大,需要平衡性能与资源
→ IVF_FLAT
数据规模极大、内存有限
→ IVF_PQ
只需要快速粗筛或近似去重
→ LSH
实际选型还要关注:
- 数据规模。
- 向量维度。
- TopK 大小。
- 内存限制。
- 查询延迟。
- 元数据过滤。
- 召回率要求。
最终应使用真实业务数据进行压测,不能只根据算法名称选择索引。
十三. 索引构建流程(ETL)
ETL 是什么
文档进入向量数据库的过程,可以概括为 ETL:
text
Extract(提取)
↓
Transform(转换)
↓
Load(加载)
对应完整流程:
text
读取文档
↓
清洗与切分
↓
补充元数据
↓
生成向量
↓
存入向量数据库
Spring AI ETL 三个核心组件
text
DocumentReader
↓
DocumentTransformer
↓
DocumentWriter
DocumentReader
负责从磁盘、网络等数据源读取文档,并转换成 Spring AI 的 Document 对象。
常见实现:
TextReaderJsonReaderPagePdfDocumentReaderTikaDocumentReader
DocumentTransformer
负责对读取到的文档进行转换处理,例如:
- 文档清洗
- 文档切分
- 格式统一
- 关键词提取
- 摘要生成
- 元数据补充
常见实现:
text
TokenTextSplitter
ContentFormatTransformer
KeywordMetadataEnricher
SummaryMetadataEnricher
DocumentWriter
负责将处理后的文档写入目标存储。
VectorStore 继承了 DocumentWriter,因此可以直接作为 ETL 的存储组件。
常见实现:
PgVectorStoreMilvusVectorStoreQdrantVectorStoreElasticsearchVectorStore
ContentFormatTransformer
用于统一文档内容与元数据的展示格式,方便后续处理和检索。
不同来源的文档可能具有不同的元数据:
text
PDF:
{ "author": "张三", "date": "2025-01-01" }
Word:
{ "Author": "李四", "Created": "2025-01-02" }
Markdown:
{ "creator": "王五", "timestamp": "2025-01-03" }
如果格式不统一,后续的元数据过滤和提示词拼接会更加困难。
KeywordMetadataEnricher
使用大模型分析文档内容并提取关键词,然后将结果写入文档元数据。
json
{
"keywords": ["Spring AI", "RAG", "向量数据库"]
}
作用:
- 补充文档的语义标签。
- 支持关键词过滤。
- 辅助检索和文档分类。
SummaryMetadataEnricher
使用大模型总结文档内容,并将摘要保存到 summary 元数据中。
json
{
"summary": "本文介绍了Spring AI中RAG索引构建的ETL流程。"
}
作用:
- 快速了解文档主题。
- 为检索结果提供摘要。
- 可以使用摘要辅助向量化或重排序。
ETL 示例流程
java
List<Document> documents = documentReader.get();
List<Document> transformedDocuments =
documentTransformer.apply(documents);
vectorStore.accept(transformedDocuments);
也可以直接使用常见的 VectorStore.add():
java
List<Document> documents = documentReader.get();
List<Document> chunks =
tokenTextSplitter.apply(documents);
vectorStore.add(chunks);
整体关系
text
原始文档
↓
DocumentReader
↓ 读取为 Document
DocumentTransformer
↓ 清洗、切分、补充元数据
EmbeddingModel
↓ 生成向量
VectorStore / DocumentWriter
↓
向量数据库
重点
- Spring AI 将索引构建过程抽象成 ETL Pipeline。
DocumentReader负责读取文档。DocumentTransformer负责清洗、切分和内容增强。DocumentWriter负责写入最终存储。VectorStore是DocumentWriter的一种实现。KeywordMetadataEnricher和SummaryMetadataEnricher都需要调用大模型,会增加时间和调用成本。- ETL 构建的是知识库索引,不是用户提问时的检索流程。
十四. Spring AI 和 LangChain4j 文档处理功能对比
| 对比项 | Spring AI(+ Alibaba) | LangChain4j |
|---|---|---|
| 文档读取(读取到内存中) | 本地文件格式(Office 文档、PDF、Markdown、JSON 等) 云存储服务(腾讯云 COS、阿里云 OSS 等) 数据库(MySQL、MongoDB、SQLite、Elasticsearch 等) 在线平台(GitHub、GitLab、语雀、Notion、Bilibili、YouTube 等) 其他数据源(邮件、归档文件等) | Amazon S3 Azure Blob Google Cloud Storage 本地文件格式 GitHub Selenium COS URL 等 |
| 文档解析(转成 Document 对象) | 文档格式(PDF、Markdown、YAML、HTML 等) 办公文档(通过 Tika 支持多种 Office 格式) 多模态内容(图像 OCR、语音转文字) 特殊格式(BibTeX、PDF 表格等) 批量处理(目录解析) | TextDocumentParser ApacheTikaDocumentParser ApachePoiDocumentParser ApachePdfBoxDocumentParser MarkdownDocumentParser YamlDocumentParser |
| 文本分段 | TokenTextSplitter(按照固定长度分段) SentenceSplitter(按照语义分段) RecursiveCharacterTextSplitter(递归分段) |
DocumentByParagraphSplitter(按段落分割) DocumentByLineSplitter(按行分割) DocumentBySentenceSplitter(按句子语义分割) DocumentByWordSplitter(按单词分割) DocumentByCharacterSplitter(按字符分割) DocumentByRegexSplitter(按正则分割) DocumentSplitters.recursive(递归分割) |
| 文档清洗 | HtmlToTextDocumentTransformer(将 HTML 内容转成纯文本) |
|
| 元数据加工 | ContentFormatTransformer(元数据统一转换) KeywordMetadataEnricher(关键词提取保存为元数据) SummaryMetadataEnricher(摘要提取保存为元数据) |
总结:在线文档使用SpringAI Alibaba,
九. 检索增强生成
RAG 核心流程
完成知识库索引构建后,就可以进行检索增强生成:
text
用户问题
↓
将问题向量化
↓
从向量库检索相关文档
↓
将文档和问题拼接到提示词
↓
调用大模型
↓
生成最终答案
核心思想:
text
先检索相关文档
→ 将文档作为上下文
→ 让大模型基于文档回答
相似度检索
java
/**
* 相似度查询
*
* @param query 用户的原始问题
* @return 检索到的文档块
*/
public List<Document> similarSearch(String query) {
return vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(5)
.similarityThreshold(0.7)
.build()
);
}
参数说明:
| 参数 | 含义 | 示例值 | 说明 |
|---|---|---|---|
query |
用户的查询语句 | "什么是MyBatis?" |
会被自动向量化并与向量库中的向量比较 |
topK |
最多返回的文档数量 | 5 |
一般设置为 3~10 |
similarityThreshold |
相似度阈值 | 0.7 |
越接近 1 越严格,需要根据实际数据调试 |
text
topK 太小
→ 可能遗漏相关文档
topK 太大
→ 可能引入无关内容
阈值太高
→ 可能检索不到文档
阈值太低
→ 可能召回大量无关文档
检索结果的质量会直接影响大模型最终回答的质量。
手动实现增强生成
java
@Autowired
private ChatModel chatModel;
@Autowired
private EmbeddingService embeddingService;
@GetMapping("/retrieve")
public String retrieve(String query, double threshold) {
// 1. 检索相关文档
List<Document> documents = embeddingService.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(5)
.similarityThreshold(threshold)
.build()
);
// 2. 合并文档内容
String documentContent = documents.stream()
.map(Document::getText)
.collect(Collectors.joining(
"\n\n=========文档分隔线===========\n\n"
));
// 3. 构建提示词
String promptTemplate = """
请基于以下提供的参考文档内容,回答用户的问题。
如果参考文档中没有相关信息,请直接说明
"没有找到相关信息",不要编造内容。
参考文档:
{documents}
用户问题:{question}
""";
PromptTemplate template = new PromptTemplate(promptTemplate);
Prompt prompt = template.create(Map.of(
"documents", documentContent,
"question", query
));
// 4. 调用大模型
return chatModel.call(prompt)
.getResult()
.getOutput()
.getText();
}
手动方式的处理流程:
text
similaritySearch()
↓
获得相关 Document
↓
提取并合并文档内容
↓
填充 PromptTemplate
↓
ChatModel 调用大模型
↓
返回答案
优点:
- 可以控制检索、过滤和提示词拼接的每一步。
- 灵活性高。
- 适合具有自定义检索流程的场景。
缺点:
- 代码量较多。
- 多个接口使用时容易出现重复代码。
QuestionAnswerAdvisor
Spring AI 提供了 QuestionAnswerAdvisor,可以自动完成向量检索和提示词增强。
依赖:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-advisors-vector-store</artifactId>
<version>1.1.0</version>
</dependency>
配置:
java
@RestController
@RequestMapping("/rag/retriever")
public class RagRetrieverController implements InitializingBean {
private ChatClient chatClient;
@Autowired
private ChatModel chatModel;
@Autowired
private PgVectorStore vectorStore;
@GetMapping("/retrieveAdvisor")
public String retrieveAdvisor(String query) {
return chatClient.prompt(query)
.call()
.content();
}
@Override
public void afterPropertiesSet() {
PromptTemplate promptTemplate = new PromptTemplate("""
请基于以下提供的参考文档内容,回答用户的问题。
如果参考文档中没有相关信息,请直接说明
"没有找到相关信息",不要编造内容。
参考文档内容:
{question_answer_context}
用户问题:{query}
""");
QuestionAnswerAdvisor advisor =
QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(
SearchRequest.builder()
.similarityThreshold(0.5)
.topK(5)
.build()
)
.promptTemplate(promptTemplate)
.build();
this.chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(advisor)
.defaultOptions(
DashScopeChatOptions.builder()
.withTopP(0.7)
.build()
)
.build();
}
}
自定义 QuestionAnswerAdvisor 提示词时,需要保留其规定的变量:
text
{question_answer_context}
→ 检索到的文档内容
{query}
→ 用户的原始问题
调用时只需要传入用户问题:
java
chatClient.prompt(query)
.call()
.content();
文档检索和提示词拼接会由 QuestionAnswerAdvisor 自动完成。
QuestionAnswerAdvisor 原理
初始化阶段
装配 RAG 所需配置:
VectorStoreSearchRequesttopK- 相似度阈值
- 提示词模板
- Advisor 执行顺序
前置处理
在调用大模型之前执行:
text
接收用户问题
↓
查询向量数据库
↓
获得相关文档
↓
将文档加入提示词
↓
生成新的模型请求
后置处理
模型回答后执行:
- 将检索到的文档及元数据加入响应元信息。
- 不会修改大模型生成的回答内容。
两种实现方式对比
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 手动检索并拼接 Prompt | 灵活,可以控制每个步骤 | 代码量较多 | 自定义检索、过滤和提示词流程 |
QuestionAnswerAdvisor |
简单、复用性强 | 自定义程度相对较低 | 标准知识库问答场景 |
重点
- RAG 的核心是:检索文档 → 注入上下文 → 调用大模型。
SearchRequest用于配置查询内容、topK和相似度阈值。- 手动拼接方式更加灵活,Advisor 方式更加简单。
QuestionAnswerAdvisor会在调用模型前自动检索文档并重写提示词。- 自定义 Advisor 提示词需要保留
{question_answer_context}和{query}。 - 基础的检索增强生成属于
Naive RAG。 topK和相似度阈值需要通过真实业务数据反复调试。- 提示词必须明确要求模型基于参考文档回答,降低模型编造内容的概率。
十五. RAG 优化技术:元数据过滤
什么是元数据
元数据(Metadata)是附加在文本块 Chunk 上的结构化信息,相当于文本块的"身份证"。
常见元数据:
json
{
"fileName": "汽车用户手册(2023年版)",
"pageNumber": 5,
"departmentId": "1001",
"userId": "123",
"version": "2023",
"securityLevel": "internal"
}
元数据不一定参与向量化,主要用于过滤、权限控制和来源追溯。
元数据过滤流程
text
用户问题
↓
提取过滤条件
↓
根据元数据过滤文档范围
↓
在过滤后的文档中进行向量相似度检索
↓
将相关文档交给大模型
元数据过滤将两种检索方式结合起来:
text
元数据过滤:精确匹配
向量检索:语义相似度匹配
使用场景
精确过滤
不同版本的文档可能具有高度相似的内容:
text
汽车用户手册(2023年版):钥匙启动
汽车用户手册(2024年版):旋钮启动
汽车用户手册(2025年版):手机启动
用户提问:
text
根据《汽车用户手册(2023年版)》,汽车应该如何启动?
如果只进行相似度检索,可能同时召回三个版本。
增加元数据过滤:
text
fileName == "汽车用户手册(2023年版)"
就可以只在 2023 年版文档中进行相似度检索。
提供参考来源
可以通过元数据向用户展示答案来源:
text
参考来源:《汽车用户手册(2024年版)》第 5 页
作用:
- 增加回答可信度。
- 方便用户追溯原文。
- 快速定位文档位置。
- 减少用户对模型编造内容的担忧。
访问权限控制
在企业知识库中,可以将权限信息写入元数据:
json
{
"departmentId": "1001",
"roleId": "admin",
"securityLevel": "internal",
"status": "effective"
}
查询时根据当前用户身份进行过滤:
text
用户身份
↓
过滤无权访问的文本块
↓
执行相似度检索
↓
生成回答
这样可以避免普通用户检索到内部机密内容。
写入元数据
在文档读取完成后、存入向量库之前添加元数据:
java
@GetMapping("/embedding")
public String embedding(String filePath, String fileName) {
List<Document> documents;
try {
documents = documentReaderFactory.read(new File(filePath));
} catch (IOException e) {
throw new RuntimeException(e);
}
for (Document document : documents) {
document.getMetadata().put("fileName", fileName);
}
embeddingService.embedAndStore(documents);
return "success";
}
处理流程:
text
读取文档
↓
生成 Document
↓
添加 fileName 元数据
↓
文档切片和向量化
↓
存入向量数据库
使用元数据过滤检索
通过 filterExpression() 设置过滤表达式:
java
List<Document> similarDocs = vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(5)
.similarityThreshold(0.5)
.filterExpression(
"fileName == '" + fileName + "'"
)
.build()
);
参数作用:
| 参数 | 作用 |
|---|---|
query |
用户问题,用于向量相似度检索 |
topK(5) |
最多返回 5 个文本块 |
similarityThreshold(0.5) |
过滤相似度过低的文本块 |
filterExpression(...) |
根据元数据精确过滤 |
Controller 示例
java
@GetMapping("/retrieveMetadata")
public String retrieveMetadata(
String query,
String fileName) {
SearchRequest searchRequest = SearchRequest.builder()
.query(query)
.topK(5)
.similarityThreshold(0.5)
.filterExpression(
"fileName == '" + fileName + "'"
)
.build();
return embeddingService
.similaritySearch(searchRequest)
.toString();
}
该接口会先根据 fileName 限定文档范围,再执行相似度检索。
实际项目中,fileName 可以由大模型从用户问题中提取:
text
用户问题:
根据《汽车用户手册(2023年版)》,汽车如何启动?
大模型提取:
fileName = 汽车用户手册(2023年版)
QuestionAnswerAdvisor 动态过滤
使用 QuestionAnswerAdvisor 时,可以在每次请求中动态传入过滤条件:
java
@GetMapping("/retrieveAdvisorWithMetadata")
public String retrieveAdvisorWithMetadata(
String query,
String fileName) {
return chatClient.prompt(query)
.advisors(advisorSpec -> advisorSpec.param(
"qa_filter_expression",
"fileName == '" + fileName + "'"
))
.call()
.content();
}
其中:
text
qa_filter_expression
是 QuestionAnswerAdvisor 用于接收动态元数据过滤表达式的参数。
处理流程:
text
ChatClient 接收问题
↓
QuestionAnswerAdvisor 获取过滤表达式
↓
VectorStore 执行元数据过滤和相似度检索
↓
检索结果加入提示词
↓
大模型生成回答
重点
- 元数据是文本块附带的结构化信息。
- 元数据过滤负责精确匹配,向量检索负责语义匹配。
- 常见用途包括版本过滤、来源追溯和权限控制。
- 写入元数据应在文档存入向量库之前完成。
SearchRequest.filterExpression()用于设置过滤条件。QuestionAnswerAdvisor可以通过qa_filter_expression动态接收过滤条件。topK和similarityThreshold仍然需要根据实际数据反复调试。- 生产环境不能直接信任用户传入的过滤值,需要校验或转义,防止构造恶意过滤表达式。
十六. RAG 优化技术:问题改写
什么是问题改写
用户问题通常存在表达模糊、信息缺失、依赖上下文等问题,直接检索可能无法匹配到相关文档。
问题改写的目标是:
text
将用户的自然语言问题
转换成更适合向量检索的查询表达
基本流程:
text
用户原始问题
↓
大模型进行问题改写
↓
使用改写后的问题检索
↓
汇总并去重文档
↓
结合原始问题生成回答
常见的四种问题改写策略:
text
分解、富化、多样化、回溯提示
问题分解
将复杂问题拆分成多个可以独立检索的子问题。
例如:
text
原始问题:
iPhone 15 发布的时候,苹果的 CEO 是谁?
子问题:
1. iPhone 15 是什么时候发布的?
2. 2023 年 9 月时,苹果公司的 CEO 是谁?
再例如:
text
原始问题:
Kafka 和 RocketMQ 的异同点是什么?
子问题:
1. Kafka 有哪些特性?
2. RocketMQ 有哪些特性?
3. Kafka 和 RocketMQ 的特性有哪些区别?
适合多步骤、多实体和多跳问题。
Prompt 示例:
text
# 角色
你是一名专业的查询逻辑分析专家。
# 任务
将"用户原始问题"分解为一系列相互独立、逻辑清晰,
且可以单独用于检索的子查询。
# 用户原始问题
{QUESTION}
# 输出格式
[
"子查询1",
"子查询2",
"子查询3"
]
请直接输出 JSON 数组,不要输出解释。
问题富化
根据对话历史补充问题中缺少的实体、时间、地点和背景信息,使问题能够脱离上下文独立存在。
例如:
text
对话历史:
用户:请介绍一下 Redis。
当前问题:
他有什么特点?
富化结果:
Redis 具有哪些主要特点?
适合:
- 问题包含"它、他、这个"等指代。
- 问题依赖历史对话。
- 缺少时间、地点或实体信息。
- 使用了专业术语的简称。
Prompt 示例:
text
# 角色
你是一个专业的问题重写优化器。
# 任务
根据"对话历史"和"用户原始问题",将问题重写为一个
独立、完整、包含必要背景信息的新查询,用于 RAG 检索。
# 对话历史
{CHAT_HISTORY}
# 原始问题
{QUESTION}
# 输出
只输出富化后的新问题,不要输出解释。
问题多样化
为同一个问题生成多个语义相同但措辞不同的查询,提高知识库的召回率。
例如:
json
[
"如何提高接口的执行性能?",
"怎样降低接口的响应时间?",
"接口响应速度有哪些优化方法?"
]
适合:
- 用户与文档的表达方式不同。
- 同一概念存在多个叫法。
- 希望提高检索召回率。
处理流程:
text
原始问题
↓
生成多个查询变体
↓
分别进行向量检索
↓
合并并去重文档
Prompt 示例:
text
# 角色
你是一名专业的语义扩展专家。
# 任务
为"原始问题"生成 3 个语义相同但措辞完全不同、
且利于检索的查询变体。
# 原始问题
{QUESTION}
# 输出格式
[
"变体1",
"变体2",
"变体3"
]
请直接输出 JSON 数组,不要输出解释。
回溯提示
回溯提示来自 Step-Back Prompting,用于将具体问题抽象为更加通用、本质的问题。
例如:
text
原始问题:
我老舅结婚,我可以请几天假参加婚礼?
回溯问题:
公司的探亲假和婚丧假政策是什么?
处理流程:
text
具体问题
↓
抽象出背后的通用问题
↓
检索通用制度或原理
↓
结合原始问题生成答案
Prompt 示例:
text
# 角色
你是一个擅长抽象思维和原理推理的专家。
# 任务
请将用户的具体问题转化为一个更通用、更本质的问题,
聚焦背后的原理、规律、概念或一般性知识。
# 原始问题
{QUESTION}
# 输出
只输出改写后的回溯问题,不要解释或回答。
问题重写代码
java
@Service
public class QuestionRewriteService {
@Autowired
private ChatModel chatModel;
public List<String> decompose(String question) {
PromptTemplate template =
new PromptTemplate(DECOMPOSE_PROMPT);
template.add("QUESTION", question);
String result = chatModel.call(template.create())
.getResult()
.getOutput()
.getText();
return JSON.parseArray(result, String.class);
}
public String enrich(String chatHistory, String question) {
PromptTemplate template =
new PromptTemplate(ENRICH_PROMPT);
template.add("CHAT_HISTORY", chatHistory);
template.add("QUESTION", question);
return chatModel.call(template.create())
.getResult()
.getOutput()
.getText();
}
public List<String> diversify(String question) {
PromptTemplate template =
new PromptTemplate(DIVERSIFY_PROMPT);
template.add("QUESTION", question);
String result = chatModel.call(template.create())
.getResult()
.getOutput()
.getText();
return JSON.parseArray(result, String.class);
}
public String stepBack(String question) {
PromptTemplate template =
new PromptTemplate(STEP_BACK_PROMPT);
template.add("QUESTION", question);
return chatModel.call(template.create())
.getResult()
.getOutput()
.getText();
}
}
组合问题改写策略
java
public List<String> rewriteQuery(String query) {
// 1. 回溯:将具体问题抽象化
String stepBackQuery = stepBack(query);
// 2. 分解:拆分成多个子问题
List<String> decomposedQueries =
decompose(stepBackQuery);
// 3. 多样化:为每个子问题生成多个表达
List<String> finalQueries = new ArrayList<>();
for (String subQuery : decomposedQueries) {
finalQueries.addAll(diversify(subQuery));
}
// 改写失败时使用原始问题
if (finalQueries.isEmpty()) {
finalQueries.add(query);
}
return finalQueries;
}
当前组合流程为:
text
原始问题
↓
回溯
↓
分解
↓
多样化
↓
最终查询列表
该组合方法没有调用
enrich()。如果问题依赖对话历史,需要先执行富化,再进行其他改写。
例如:
text
原始问题
↓
富化
↓
回溯
↓
分解
↓
多样化
使用改写后的问题进行检索
java
@GetMapping("/chatWithQueryRewrite")
public String chatWithQueryRewrite(
@RequestParam("query") String query) {
// 1. 生成多个改写后的查询
List<String> rewrittenQueries =
questionRewriteService.rewriteQuery(query);
// 2. 分别检索并去重
Set<Document> similarDocs = new LinkedHashSet<>();
for (String rewrittenQuery : rewrittenQueries) {
List<Document> docs =
embeddingService.similarSearch(rewrittenQuery);
if (docs != null && !docs.isEmpty()) {
similarDocs.addAll(docs);
}
}
// 3. 合并文档内容
String documentContent = similarDocs.stream()
.map(Document::getText)
.collect(Collectors.joining(
"\n\n=========文档分隔线===========\n\n"
));
// 4. 使用原始问题生成最终答案
PromptTemplate template = new PromptTemplate("""
请基于以下参考文档内容回答用户的问题。
参考文档:
{documents}
用户问题:
{question}
""");
Prompt prompt = template.create(Map.of(
"documents", documentContent,
"question", query
));
return chatClient.prompt(prompt)
.call()
.content();
}
注意最终生成答案时,使用的是用户的原始问题:
java
"question", query
改写后的问题主要用于检索,不应该完全替代用户的原始问题。
整体流程
text
用户原始问题
↓
问题重写
↓
生成多个检索问题
↓
分别执行相似度检索
↓
文档合并与去重
↓
文档内容 + 用户原始问题
↓
大模型生成最终答案
四种策略对比
| 策略 | 主要作用 | 适用场景 |
|---|---|---|
| 分解 | 将复杂问题拆成多个子问题 | 多跳、多实体、多步骤问题 |
| 富化 | 补充上下文和缺失信息 | 指代、省略、依赖历史对话 |
| 多样化 | 生成多种查询表达 | 用户与文档表述不一致 |
| 回溯提示 | 将具体问题抽象为通用问题 | 具体问题难以直接匹配知识库 |
注意事项
- 问题重写不是策略越多越好。
- 每次问题改写都可能增加一次或多次大模型调用。
- 查询数量越多,向量检索次数和响应时间越长。
- 多路检索结果需要去重,生产中可以按照文档 ID 或内容哈希去重。
- 多个相互独立的查询可以并行检索,降低整体响应时间。
- 直接使用
JSON.parseArray()依赖模型严格返回 JSON,生产环境建议增加格式校验、异常处理或结构化输出。 - 应根据用户提问习惯和业务特点选择合适的改写策略。
- 问题重写的核心是在检索质量、响应速度和调用成本之间取得平衡。