RAG学习

一. RAG 简介

RAG(Retrieval-Augmented Generation,检索增强生成)是让大模型在回答问题前,先从外部知识库检索相关资料,再结合资料生成答案的技术。

核心思想:

复制代码
用户提问 → 检索资料 → 注入上下文 → 大模型生成答案

简单理解就是:

复制代码
检索 + 生成

让模型"查完资料再回答",减少仅依赖训练数据产生的错误。

RAG 解决的问题

大模型存在以下局限:

  • 幻觉:生成的内容语言合理,但事实可能错误
  • 知识滞后:无法知道训练完成后的新知识
  • 知识局限:缺少企业内部或专业领域的私有数据
  • 专业性不足:对法律、医疗、金融等领域理解有限

RAG 可以为模型提供实时、专业、私有的外部知识。

适用场景
  • 企业知识库问答
  • 智能客服
  • 产品说明书查询
  • 企业制度和内部文档问答
  • 安全日志与威胁情报分析
  • CVE 漏洞解读
  • 法律、医疗、金融等专业问答
  • 最新政策、法规和业务数据查询
RAG 主要流程

RAG 以用户是否开始提问为分界,可以分为两个阶段:

复制代码
离线阶段:构建索引
在线阶段:检索生成
构建索引

在用户提问前,将原始资料处理成可检索的知识库:

复制代码
加载文档
  ↓
文档清洗
  ↓
文档分片 Chunk
  ↓
Embedding 向量化
  ↓
写入向量数据库

主要步骤:

  1. 加载 PDF、Word、网页或数据库内容
  2. 清洗无效、重复和错误数据
  3. 将长文档切分成多个文本块
  4. 使用 Embedding 模型将文本转换成向量
  5. 将文本、向量和元数据写入向量数据库

构建索引的核心原则:

复制代码
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,
)

作用:

  1. 将用户问题转换为向量。
  2. 从索引中检索最相似的文本块。
  3. 取相关度最高的三个文本块。
  4. 将文本块与问题拼接成提示词。
  5. 调用聊天模型生成最终答案。
当前模型调用问题

当前代理接口可以访问:

复制代码
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
  • PDF
  • 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 直接读取纯文本
PDF PagePdfDocumentReader 按页读取
PDF 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",
    "。",
    "!",
    "?",
    ";",
    ",",
    " "
};

执行逻辑:

  1. 先按段落切分。
  2. 分块仍然过长,则按换行切分。
  3. 仍然过长,则按句号、逗号等切分。
  4. 所有分隔符都无法满足要求,最后按固定长度切分。

核心代码:

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 对象。

常见实现:

  • TextReader
  • JsonReader
  • PagePdfDocumentReader
  • TikaDocumentReader
DocumentTransformer

负责对读取到的文档进行转换处理,例如:

  • 文档清洗
  • 文档切分
  • 格式统一
  • 关键词提取
  • 摘要生成
  • 元数据补充

常见实现:

text 复制代码
TokenTextSplitter
ContentFormatTransformer
KeywordMetadataEnricher
SummaryMetadataEnricher
DocumentWriter

负责将处理后的文档写入目标存储。

VectorStore 继承了 DocumentWriter,因此可以直接作为 ETL 的存储组件。

常见实现:

  • PgVectorStore
  • MilvusVectorStore
  • QdrantVectorStore
  • ElasticsearchVectorStore

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 负责写入最终存储。
  • VectorStoreDocumentWriter 的一种实现。
  • KeywordMetadataEnricherSummaryMetadataEnricher 都需要调用大模型,会增加时间和调用成本。
  • 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 所需配置:

  • VectorStore
  • SearchRequest
  • topK
  • 相似度阈值
  • 提示词模板
  • 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 动态接收过滤条件。
  • topKsimilarityThreshold 仍然需要根据实际数据反复调试。
  • 生产环境不能直接信任用户传入的过滤值,需要校验或转义,防止构造恶意过滤表达式。

十六. 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,生产环境建议增加格式校验、异常处理或结构化输出。
  • 应根据用户提问习惯和业务特点选择合适的改写策略。
  • 问题重写的核心是在检索质量、响应速度和调用成本之间取得平衡。
相关推荐
励志不掉头发的内向程序员1 小时前
【LibreCAD 2D架构】从鼠标点击到图形创建:RS_ActionDrawLine交互流程与状态机解析
开发语言·c++·qt·学习·系统架构·计算机外设·交互
Htr_1 小时前
Reflexio 使用指南:让 AI 智能体从每次交互中持续学习
人工智能·学习·交互
Chill602 小时前
ChatGPT桌面版打不开
学习
sunshine22 girl3 小时前
Java学习一 环境配置3 Idea的基本设置和插件
java·学习
clorinda3 小时前
OpenCV 学习实践:从人脸检测到人脸识别
人工智能·opencv·学习
陈年老古董4 小时前
OpenCV 人脸识别学习笔记:从Haar检测到三种特征脸算法实战
笔记·opencv·学习·人脸识别
旖旎夜光4 小时前
【LangChain实战】LangChain 学习笔记(二):结构化输出、流式传输与消息管理
人工智能·笔记·python·学习·ai·langchain
我是你的开心果7785 小时前
ai全栈应用开发学习day19
学习