RAG(一)离线阶段
RAG (一)离线阶段
github:【https://github.com/Jack-txf/ai-engineering】
在有了前文的基础之后:https://mp.weixin.qq.com/s/xdBy5Sav_gc66NBnTby2yQ
现在来研究一下一个可用的 RAG(Retrieval-Augmented Generation)系统,绝对不是简单的“向量检索 + LLM”拼接。根据ChatGPT的流程图,我们可以知道大致步骤如下:
┌──────────────┐
│ 数据源接入 │
│ (文档/DB/API) │
└──────┬───────┘
↓
┌──────────────┐
│ 数据清洗处理 │
│ 去噪/结构化 │
└──────┬───────┘
↓
┌──────────────┐
│ 文档切分Chunk │
│ (语义切分) │
└──────┬───────┘
↓
┌───────────────────────┐
│ 向量化 Embedding │
│ (BGE/OpenAI/E5等) │
└──────┬────────────────┘
↓
┌────────────────────────────┐
│ 向量数据库 Vector DB │
│ (Milvus / FAISS / ES) │
└────────┬───────────────────┘
↓
┌────────────────────────────┐
│ 检索层 Retrieval │
│ (向量 + BM25 + 重排) │
└────────┬───────────────────┘
↓
┌────────────────────────────┐
│ Prompt构建 / 上下文拼接 │
└────────┬───────────────────┘
↓
┌────────────────────────────┐
│ LLM推理 (GPT / 本地模型) │
└────────┬───────────────────┘
↓
┌────────────────────────────┐
│ 后处理 + 评估 + 审核 │
└────────┬───────────────────┘
↓
┌────────────────────────────┐
│ 返回用户结果 + 日志监控 │
└────────────────────────────┘
环境准备。
Redis6及以上;MySQL8.0及以上;Java21;Milvus2.6.x;
最后一个向量数据库为了方便我们就直接采用docker-compose一键安装,然后单机启动了。
1. 准备阶段
1.1 安装Milvus
安装就直接参考官网的命令了。https://milvus.io/docs/install_standalone-docker-compose.md
首先确保docker、和docker-compose是正常的,可以正常拉取镜像。
然后到你的linux里面去,创建一个milvus目录,到这个目录里面执行命令,将官网的docker-compose.yml下载下来,如下图所示:

如果碰到下载不下来的情况,可以考虑到浏览器里面复制这个地址下载到你的本地,然后上传到你的服务器上就行了。
https://github.com/milvus-io/milvus/releases/download/v2.6.12/milvus-standalone-docker-compose.yml -O docker-compose.yml
实在不行,下面给出文件内容吧 docker-compose.yml:【下面对容器内存进行了限制,还加了attu可视化界面】
# version: '3.5'
services:
etcd:
container_name: milvus-etcd
image: quay.io/coreos/etcd:v3.5.25
restart: always
deploy:
resources:
limits:
memory: 256M
environment:
- ETCD_AUTO_COMPACTION_MODE=revision
- ETCD_AUTO_COMPACTION_RETENTION=1000
- ETCD_QUOTA_BACKEND_BYTES=4294967296
- ETCD_SNAPSHOT_COUNT=50000
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
healthcheck:
test: ["CMD", "etcdctl", "endpoint", "health"]
interval: 30s
timeout: 20s
retries: 3
minio:
container_name: milvus-minio
image: minio/minio:RELEASE.2024-12-18T13-15-44Z
restart: always
deploy:
resources:
limits:
memory: 256M
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
ports:
- "9001:9001"
- "9000:9000"
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
command: minio server /minio_data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
standalone:
container_name: milvus-standalone
image: milvusdb/milvus:v2.6.12
restart: always
deploy:
resources:
limits:
memory: 1G
command: ["milvus", "run", "standalone"]
security_opt:
- seccomp:unconfined
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
MQ_TYPE: woodpecker
volumes:
- ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
interval: 30s
start_period: 90s
timeout: 20s
retries: 3
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- "etcd"
- "minio"
attu:
container_name: milvus-attu
image: zilliz/attu:v2.6.3
environment:
MILVUS_URL: milvus-standalone:19530
ports:
- "3000:3000"
depends_on:
- standalone
restart: always
deploy:
resources:
limits:
memory: 256M
networks:
default:
name: milvus
这个yml文件弄好之后,到这里直接执行命令就行了:【我这里是Ubuntu系统,当前登录的用户没有docker权限,要加个sudo;如果是centos系统,不用这个sudo】
sudo docker-compose -f 文件名 up -d

然后等待即可。上图中没有attu,是截的之前版本的图,前面给的docker-compose.yml的内容是真实可直接用的,本人测试过。
如果想要外界访问,记得开放端口。上面给出的yml文件中,3000端口是attu可视化界面的,可以webui查看当前向量数据库的状态。打开访问地址后,直接点击 连接 按钮即可登录。ip:3000

能打开这个界面就ok了,刚开始肯定是没有数据的,上面我这个是有数据的。
还有必要简单介绍一下这个Milvus的:
Milvus 是一款为向量相似度检索而设计的云原生数据库。它的核心能力是处理非结构化数据(如文本、图像、音频、视频等)的向量化表示,并通过向量索引和近似最近邻搜索算法,在海量数据中实现毫秒级的检索。
为了更直观,我们把 Milvus 和 MySQL 的术语并排放在一起看:
| 概念层次 | Milvus | MySQL | 说明与类比 |
|---|---|---|---|
| 第一层 | Collection | Table (数据表) | 最顶层的组织单元。一个Collection对应一张MySQL表,用于存放一组相关的实体。 |
| 第二层 | Entity | Row (行) | 数据的基本单位。每个Entity代表一个具体的对象(如一张图片、一段文本)。 |
| 第三层 | Field | Column (列) | 定义数据的属性。但Milvus的Field有特殊分类,比MySQL的列更复杂。 |
同时从实际应用角度来说:
| 概念层次 | Milvus 官方数据模型 | 实际应用视角 (RAG/LLM框架) | 说明 |
|---|---|---|---|
| 最高层 | Collection (集合/数据表) | Index / Knowledge Base (索引/知识库) | 用于存放同一类数据的最大单元,如“公司制度知识库”。 |
| 数据行 | Entity (实体/数据行) | Document (文档/文本块) | 这是关键对应。在 RAG 应用中,你存入 Milvus 的每一个“文档”(或文本块),在 Milvus 内部就是一个 Entity。 |
| 数据列 | Field (字段/列) | Field (字段) | 用于描述一个 Entity 的不同属性,如 id、text_content、vector、metadata 等。 |
1.2 整体流程
一个完整的流程可以如下图所示,大致分为离线存储、在线检索两个阶段

离线流水线负责把原始文档变成可检索的结构化知识;在线流水线负责把用户的问题变成精准答案。
1.3 模型管理
本文涉及到的模型有三类:对话、嵌入和重排序三种模型,选用的同一厂商旗下的【Qwen】。平台就用硅基流动的吧。
配置结构大概如下:
# 模型配置
ai-model:
providers:
siliconflow:
api-key: asdsaddas
base-url: https://api.siliconflow.cn/v1
chat-model:
- Qwen/Qwen3.5-122B-A10B
- Qwen/Qwen3.5-35B-A3B
embed-model:
- Qwen/Qwen3-Embedding-4B
- Qwen/Qwen3-Embedding-8B
rerank-model:
- Qwen/Qwen3-Reranker-4B
- Qwen/Qwen3-Reranker-0.6B
对应到配置类就是:
@Data
@Configuration
@ConfigurationProperties(prefix = "ai-model")
public class GlobalModelProperties {
// 模型提供商
private Map<String, ProviderConfig> providers = new HashMap<>();
@Data
public static class ProviderConfig {
/**
* 提供商基础 URL
*/
private String baseUrl;
/**
* API 密钥
*/
private String apiKey;
// 三种模型名字
private List<String> chatModel;
private List<String> embedModel;
private List<String> rerankModel;
}
}
数组是为了备用的。假如第一个模型不行了,可以备用第二个,以此类推(这个功能好像忘记做了)。
见仓库的【all-rag】model包。
本文仅到离线阶段。
2. 知识库构建与管理【离线】
这是 RAG 系统质量的根基,决定了"能检索到什么"。下面的每一步仅给出简单的处理过程,想要做成一个完整的系统功能,还是要耗费相当大的精力的,本文仅对整体大致流程作讲解。
2.1 数据源的接入
RAG 的数据源有很多种类:文档类(pdf,word,excel,ppt,md等等);结构化数据(数据库、csv等等);还可能会有网页内容、多媒体内容(视频、音频、图片)、甚至代码仓库也会有(github等等)。每类的解析挑战差异很大——有些是格式问题,有些是结构问题,有些是权限问题。
本文就做简单一点,采用Tika,Apache Tika 是一个非常强大的开源项目,被誉为内容的“数字瑞士军刀”。它的核心任务只有一件事:从各种不同格式的文档中自动检测并提取元数据和文本内容。
Tika 的工作流程主要分为两个关键步骤:
- 类型检测 (Type Detection): 识别文件的 MIME 类型(例如
application/pdf)。它不是简单看后缀名,而是通过文件的“魔数”(Magic Bytes)来判断。 - 内容提取 (Content Extraction): 调用相应的解析库(如 POI 解析 Office,PDFBox 解析 PDF),统一输出为标准的文本或 XHTML 格式。
Tika 本身并不“发明”解析技术,它更像是一个聚合器。它将市面上几十个零散的解析库封装在统一的接口下,让你不需要为每种格式都写一套代码。
- Detector (检测器): 负责识别文件格式。
- Parser (解析器): 负责具体格式的解析。Tika 拥有一个
AutoDetectParser,它可以自动根据检测到的类型选择最合适的解析器。 - ContentHandler (内容处理器): 决定提取出来的文本往哪儿放(比如存入字符串、写入文件)。
- Metadata (元数据): 提取文档的属性,如作者、创建日期、分辨率等。
接口设计:
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Timed(value = "rag.api.upload", description = "文件上传解析接口耗时")
public ResponseEntity<ApiResponse<DocumentParseResult>> upload(
@RequestPart("file") MultipartFile file,
@RequestParam(value = "sourceId", required = false) String sourceId) {
log.info("[Controller] 文件上传接口调用: fileName={}, sourceId={}",
file.getOriginalFilename(), sourceId);
DataSourceRequest request = new DataSourceRequest();
request.setSourceType(DataSourceRequest.SourceType.FILE_UPLOAD);
request.setFile(file);
request.setSourceId(sourceId);
// 使用统一数据源接入服务的ingest方法
DocumentParseResult result = ingestionService.ingest(request);
return ResponseEntity.ok(ApiResponse.success(result));
}
数据源接入Service层:
@Service
public class DataSourceIngestionService {
// 文档解析service
private final DocumentParserService parserService;
...
/**
* 文档数据源接入主入口。
*/
public DocumentParseResult ingest(DataSourceRequest request) {
log.info("[IngestionService] 接收数据源接入请求: sourceType={}, sourceId={}",
request.getSourceType(), request.getSourceId());
return switch (request.getSourceType()) {
case FILE_UPLOAD -> ingestFile(request.getFile(), request.getSourceId());
case URL -> ingestUrl(request.getUrl(), request.getSourceId());
case RAW_TEXT -> ingestRawText(request.getRawText(),
request.getRawTextFileName(), request.getSourceId());
};
}
}
文档解析service接口设计:
public interface DocumentParser {
/**
* 解析文档,提取纯文本内容和元数据。
*/
DocumentParseResult parse(InputStream inputStream, String fileName, String sourceId);
}
//============== 实现类
public class TikaDocumentParser implements DocumentParser {
@Override
public DocumentParseResult parse(InputStream inputStream, String fileName, String sourceId) {
.....
try {
// ── 步骤1:构建 ParseContext ──────────────────────────────
ParseContext parseContext = buildParseContext();
// ── 步骤2:准备 Metadata 和 ContentHandler ────────────────
Metadata metadata = new Metadata();
// 提前设置文件名,帮助 Tika 在 MIME 检测失败时作为辅助判断依据
metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, fileName);
int maxLen = properties.getParser().getMaxContentLength();
BodyContentHandler contentHandler = new BodyContentHandler(maxLen);
// ── 步骤3:【执行解析】────────────────────────────────────
// TikaInputStream tikaInputStream = TikaInputStream.get(inputStream);
// 注入一个tika解析器
autoDetectParser.parse(inputStream, contentHandler, metadata, parseContext);
// ── 步骤4:提取文本内容 ───────────────────────────────────
String rawContent = contentHandler.toString();
// ── 步骤5:文本清洗 ───────────────────────────────────────
String cleanedContent = TextCleanupUtils.clean(rawContent);
// ── 步骤6:提取元数据 ─────────────────────────────────────
Map<String, String> metadataMap = extractMetadata(metadata);
// ── 步骤7:计算耗时并上报指标 ────────────────────────────
....
}
}
本文的文档解析做的挺简单的。
测试出来可以解析的类型有:pptx,html,doc,docx,json,md,xlsx,csv,xml,pdf,txt,xls

访问接口测试一下,如上图所示,上传了一个ppt文件。其他类型文件读者可自行测试。
2.2 清洗
Tika 是一个格式解析器,它的职责是"把文件里的字节翻译成文字",而不是"把文字整理成干净可用的知识"。这两件事之间存在巨大的鸿沟。理解这个问题最直接的方式是看真实案例。一份普通的企业 PDF 报告,Tika 解析后原始输出大概是这样的:
第 1 页,共 48 页 ← 页码信息,每页都有,完全是噪声
─────────────────────── ← 装饰性分隔线
版权所有 © 2024 某某公司 保留所有权利 ← 页脚,每页重复
公司内部文件 请勿外传 ← 水印文字,OCR 识别后混入正文
第三章 市场分析
(内容缩进混乱,空格来自 PDF 排版坐标)
本章 摘 要 ← 字间距导致的空格插入
市场规模预计达到 1 2 3 亿元 ← "123" 被拆成 "1 2 3"(PDF 字符坐标问题)
图 3-1:市场份额分布图 ← 图片标题,图片内容却没有(图片无法提取)
[图片内容无法提取] ← Tika 占位符
表 3-1
┌──────┬──────┬──────┐ ← 表格线条变成了乱码字符
│ 地区 │ 2022 │ 2023 │
├──────┼──────┼──────┤
│ 华东 │ 45% │ 48% │
└──────┴──────┴──────┘
华东452022482023 ← 更糟的情况:表格内容完全错位拼接
← 10 个以上的连续空行
第 2 页,共 48 页 ← 又是页码
─────────────────────── ← 又是分隔线
版权所有 © 2024 某某公司 保留所有权利 ← 又是页脚
这种文本直接送入 Embedding 模型会发生什么?向量空间里,"版权所有 © 2024 某某公司"这句话会被编码成一个语义向量,而这句话在文档的每一页都会重复出现,导致向量库里充斥着语义相同但无价值的噪声 chunk。一份 50 页的企业 PDF 报告,Tika 解析出约 8 万字符。其中版权声明每页 1 行、页码每页 1 行、分隔线每页 1 行、空白行若干,这些噪声内容大约占总字符数的 15%~30%。
如果不清洗直接 Chunking,假设每个 chunk 512 token,50 页文档产生约 80 个 chunk。其中大约有 12~24 个 chunk 的主要内容是噪声(版权声明、页码拼接出的奇怪文本)。这些噪声 chunk 会被向量化并存入向量库,检索时产生干扰,最终导致用户问真实业务问题时,返回的 Top-K 里混进这些无意义内容,LLM 生成答案时被噪声带偏,出现答非所问或幻觉。
不同格式的文档,噪声的来源和形态各不相同。
首先是PDF 的噪声特别多,因为 PDF 本质是打印指令而非语义文档。每个字符都有独立的坐标,Tika 按坐标顺序重组文字,遇到多栏排版、表格、文字环绕图片时,重组顺序很容易乱掉。页眉页脚是每页固定位置的文字块,Tika 无法区分它们和正文,全部平铺输出。
Word 文档相对好一些,但有零宽字符问题。Word 的内部 XML 里大量使用零宽连字符(\u200B)、零宽非连接符(\u200C)等不可见字符来控制排版。这些字符肉眼看不到,但会破坏分词器的处理,让"人工智能"变成"人工智能"(中间有个不可见字符),导致向量计算偏差。
PPT 幻灯片的文字是碎片化的,每个文本框独立提取,Tika 输出时按文本框顺序拼接,结果可能是标题→备注→副标题→内容的混乱顺序,还会混入大量"点击此处添加标题"这类模板占位符。
网页/HTML的噪声来自导航栏、侧边栏、广告位、Cookie 提示、"相关推荐"区块。这些 DOM 节点里的文字和正文在 HTML 里是平等的,Tika 全部提取出来。
剩下的略去....
应该如何清洗呢?
第一层:确定性规则清洗。这层处理所有有确定模式的噪声,用正则表达式批量处理;
第二层:统计指标过滤。规则清洗之后,再对每个切分后的 chunk 做快速质量评分,低质量的直接丢弃,不进入 Embedding 环节(Embedding 是成本最高的环节)。
第三层:LLM 语义级过滤。前两层解决不了的语义噪声(比如模板占位文字"本节内容待补充"、完全跑题的附录免责声明、或者整段都是没有意义的示例数据),才用 LLM 做二次判断。这层成本高,只在文档价值高的场景开启。
Tika 解析
↓
【第一层清洗】字符级 + 格式噪声 ← 文档粒度,全量执行
↓
Chunking(文档切分)
↓
【第二层过滤】统计指标质量评分 ← Chunk 粒度,全量执行
↓
(可选)【第三层过滤】LLM 语义判断 ← Chunk 粒度,仅高价值文档
↓
Embedding(向量化)
2.3 切片Chunking
首先要知道的事, Embedding 模型有输入长度限制。主流的向量模型输入通常是有上限的 token。一份 50 页的 PDF 有几万 token,根本无法整体编码成一个向量。必须切成小块,每块单独编码。
同时呢,LLM 的上下文窗口是宝贵资源。即便检索到了相关内容,也不能把整个文档塞进 Prompt,那会超出 上下文窗口,就算未来的模型token是无上限的,整篇文档会充斥大量无关内容会干扰 LLM 生成。Chunking 让你只把最相关的那几段送进去。
在 RAG中,正常做法不是把整篇文档都一股脑儿给模型,而是先检索出最相关的几段文本,然后只把这几段最相关的给它。这样模型拿到的就是精准、干净的了,同时还节省token(相比于整篇文档),回答质量就很ok了。
所以,我们就需要chunking了,但是做好chunking也是一门大学问。
在进入具体chunking策略之前,需要理解一个贯穿所有策略的核心矛盾:
chunk 越小 → 语义越精准,但上下文越少,LLM 生成时信息不足,容易产生幻觉。
chunk 越大 → 上下文越完整,但语义越模糊,检索相似度被稀释,召回率下降。
没有一个"正确的" chunk 大小,只有适合你的文档类型和查询模式的大小。下面五种策略,从简单到复杂,覆盖了生产中 90% 的场景。
固定长度 + 滑动窗口重叠
最简单的策略。按 token 数量切分,相邻 chunk 之间保留一定比例的重叠内容。重叠内容它是为了防止一句完整的话被切断在两个 chunk 的边界处,导致两个 chunk 都无法单独理解这句话。
结构化边界切分
按段落、句子等自然语义单元切分,再按大小合并或拆分,保证每个 chunk 的边界都是语义完整的位置。这是大多数通用文档的首选策略,因为它在实现复杂度和语义质量之间取得了最好的平衡。
层级切分
这是企业级 RAG 最推荐的策略,核心思路是按文档的标题层级切分,构建父子 chunk 树。企业文档(技术手册、产品规格书、操作规程)天然是有层级结构的:章→节→段落。这个层级本身就是作者对内容的语义划分。顺着它切,比任何算法都准确。
父子 chunk 的检索策略:用小 chunk(段落级)做检索(语义精准),召回后扩展到父 chunk(章节级)送给 LLM(上下文完整)。这解决了"检索精度"和"生成质量"的核心矛盾。
命题切分
将文档分解为最小的原子事实单元,每个 chunk 只表达一个独立命题。这是精度最高的策略,代价是需要 LLM 辅助,成本较高。
Late Chunking
前四种策略都有一个共同问题:切分发生在 Embedding 之前,导致每个 chunk 的向量只能看到自己内部的文字,看不到文档的整体语境。
Late Chunking 反转了这个顺序:先对整篇文档做 Embedding,再切分。
传统方案:先切分 → 再 Embedding(每个 chunk 只看自己)
Late Chunking:先 Embedding → 再切分(每个 chunk 能感知全文语境)
总结这五种策略:
2.4 demo1
到此处,我们已经有了这些基础:
Tika解析文档提取文本 --> 清洗去除噪声【分块前去噪声】--> 内容分块 --> 再清洗【分块后去噪声】的部分。
此时可以完成一个小案例了。见分支【demo1】
Tika解析文档可以看DataSourceController里面的接口一,读者可以自行测试一下。主要就是DocumentParseResult result = ingestionService.ingest(request);
public DocumentParseResult ingest(DataSourceRequest request) {
log.info("[IngestionService] 接收数据源接入请求: sourceType={}, sourceId={}",
request.getSourceType(), request.getSourceId());
return switch (request.getSourceType()) {
// Java 21 switch expression:更简洁,编译器强制覆盖所有 case
case FILE_UPLOAD -> ingestFile(request.getFile(), request.getSourceId());
case URL -> ingestUrl(request.getUrl(), request.getSourceId());
case RAW_TEXT -> ingestRawText(request.getRawText(),
request.getRawTextFileName(), request.getSourceId());
};
}
private DocumentParseResult ingestFile(MultipartFile file, String sourceId) {
.....
try {
byte[] bytes = file.getBytes();
return parserService.parseBytes(bytes, fileName, sourceId);
} catch (IOException e) {
log.error("[IngestionService] 读取上传文件失败: fileName={}", fileName, e);
throw new DocumentParseException("FILE_READ_ERROR",
"读取上传文件失败: " + e.getMessage(), 500, e);
}
}
最后就是到TikaDocumentParse里面的parse方法:
public DocumentParseResult parse(InputStream inputStream, String fileName, String sourceId) {
Instant startTime = Instant.now();
List<String> parseErrors = new ArrayList<>();
// Micrometer Timer:记录解析耗时,自动上报 Prometheus
Timer.Sample sample = Timer.start(meterRegistry);
try {
// ── 步骤1:构建 ParseContext ──────────────────────────────
ParseContext parseContext = buildParseContext();
// ── 步骤2:准备 Metadata 和 ContentHandler ────────────────
Metadata metadata = new Metadata();
// 提前设置文件名,帮助 Tika 在 MIME 检测失败时作为辅助判断依据
metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, fileName);
int maxLen = properties.getParser().getMaxContentLength();
BodyContentHandler contentHandler = new BodyContentHandler(maxLen);
// ── 步骤3:执行解析 ───────────────────────────────────────
/*
* AutoDetectParser.parse() 是阻塞调用,对于大 PDF 可能耗时数十秒。
* 超时控制在上层 DocumentParserService 中通过 Future.get(timeout) 实现,
* 此处无需重复处理超时。
*/
// TikaInputStream tikaInputStream = TikaInputStream.get(inputStream);
autoDetectParser.parse(inputStream, contentHandler, metadata, parseContext);
// ── 步骤4:提取文本内容 ───────────────────────────────────
String rawContent = contentHandler.toString();
// ── 步骤5:文本清洗 ─────────────────────清洗第一层:确定性规则清洗──────
String cleanedContent = TextCleanupUtils.clean(rawContent);
// ── 步骤6:提取元数据 ─────────────────────────────────────
Map<String, String> metadataMap = extractMetadata(metadata);
// ── 步骤7:计算耗时并上报指标 ────────────────────────────
Instant endTime = Instant.now();
long durationMs = endTime.toEpochMilli() - startTime.toEpochMilli();
.....
// ── 步骤8:构建结果 ───────────────────────────────────────
DocumentParseResult.ParseStatus status = DocumentParseResult.ParseStatus.SUCCESS;
return xxxxxxxxxxx
}
}
到这里就是Tika解析,然后初步清洗的步骤了。接下来就是chunk,再分块的步骤了:
public interface ChunkingStrategy {
/**
* 执行文档分块
*
* @param document 解析后的文档结果
* @param options 分块选项
* @return 分块结果(包含分块列表和统计信息)
*/
ChunkingResult chunk(DocumentParseResult document, ChunkingOptions options);
}
@Slf4j
public abstract class AbstractChunkingStrategy implements ChunkingStrategy {
@Override
public ChunkingResult chunk(DocumentParseResult document, ChunkingOptions options) {
....
try {
// 执行实际分块逻辑(由子类实现)
List<Chunk> chunks = doChunk(content, docId, docName, options);
// 后处理:链接相邻分块、计算质量分、过滤等
chunks = postProcessChunks(chunks, content, options);
...
return result;
} catch (Exception e) {
log.error("[{}] 分块失败: document={}", getStrategyName(), docName, e);
return ChunkingResult.failed(docId, docName, e.getMessage());
}
}
}
由模板方法的设计模式来实现,具体的分块策略在子类里面完成。postProcessChunks方法就相当于再清洗步骤了。
然后调试这个接口就可以看到效果:
@RequestMapping("/v1/chunk")
@RequiredArgsConstructor
public class ChunkController {
private final ChunkingService chunkingService;
private final DataSourceIngestionService ingestionService;
/**
* 上传一个文件,并对文件进行分块 -- 测试方法
*/
@PostMapping("/file")
@Timed(value = "rag.api.chunk.file", description = "文件上传分块接口耗时")
public R chunkFile(@RequestPart("file") MultipartFile file,
@RequestParam(value = "strategy", required = false) String strategy,
@RequestParam(value = "sourceId", required = false) String sourceId) {
// 1. 文件解析
DataSourceRequest request = new DataSourceRequest();
request.setSourceType(DataSourceRequest.SourceType.FILE_UPLOAD);
request.setFile(file);
request.setSourceId(sourceId);
DocumentParseResult result = ingestionService.ingest(request);
// 2.测试分块
ChunkingResult result1 = chunkingService.chunk(result, strategy);
return R.ok().add("result", result1);
}
}
2.5 向量化与存储
在构建 RAG(检索增强生成)系统时,我们经历了文档解析(Tika)、噪声清洗和分块(Chunking)。现在,我们来到了最关键的临门一脚:将处理好的文本块(Chunks)转化为向量,并持久化到向量数据库(Milvus)中。
在这一步,我们需要解决两个核心问题:
- Embedding(向量化):将自然语言映射到高维空间。
- 持久化到Milvus:将向量存储起来,在数百万个向量中实现毫秒级的近似最近邻搜索(ANN)。
2.5.1 Embedding(向量化)
计算机擅长处理数字,但不擅长直接理解图片、文字、音频的含义。向量化就是把一张图片、一段文本或一段语音,通过深度学习模型(也叫 Embedding 模型),转换成一组固定长度的浮点数列表,这个过程也叫“嵌入”(Embedding)。
比如说你有一张猫的图片。把它输入一个图像模型(比如 ResNet、CLIP 等),模型输出一个 512 维的向量,可能像这样:[0.12, -0.34, 0.56, ..., 0.78]。这个向量就是这个图片的数学表示。它在高维空间里占据一个点。如果两张图片的向量在空间中距离很近,就说明它们在语义上很相似。
有了向量之后,我们的需求就变成了:给定一个查询向量,从海量向量中快速找出与之最相似的那些向量。这就是向量检索,也叫相似性搜索。
检索的话不能直接用两个向量“等于”来评判,因为两个不同图片的向量几乎不可能完全相等。通常用距离函数来衡量:余弦相似度(常用):夹角越小越相似,范围 [-1, 1]。欧氏距离:空间直线距离,越小越相似。内积:适合某些模型。
说到这里,应该差不多能理解是啥意思了,剩下来的找的过程就是遍历一遍数据库里面的向量,然后一个个计算对比呗,嘻嘻嘻,学会暴力搜索了!!虽然结果最准,但时间复杂度 O(N),当 N 达到百万、亿级时,耗时太长,无法在生产环境使用。
这里要说的是近似最近邻检索(ANN),为了在“准”和“快”之间取得平衡,向量数据库(包括 Milvus)使用了近似最近邻搜索算法。它们不追求 100% 精确的结果,而是允许一点点误差,换来几个数量级的加速。
这些算法的核心思想是预先建立索引,把高维空间划分为若干区域,或者构建一个图结构,让搜索时只需要访问一小部分数据。
常见的索引类型:
- IVF(倒排文件索引):把向量空间用聚类算法分成若干个“桶”。搜索时先找到距离查询向量最近的几个桶,然后只在这些桶内部进行暴力搜索。
比喻:你要在全国找一个人,不是挨家挨户敲门,而是先确定他在哪个省,再到那个省去找。 - HNSW(分层可导航小世界图):在向量之间构建一张多层图,每个向量是一个节点,节点之间按距离连接。搜索时从顶层开始,沿着最接近查询向量的方向“跳跃”,逐层细化,快速抵达目标区域。
比喻:在高速公路上先走主干道(顶层),下了高速再走小路(底层),快速到达目的地。 - DiskANN:专门为存储在 SSD 上的海量数据设计,把索引结构部分放在磁盘,平衡内存和磁盘 I/O,支持百亿级规模的检索。
一个检索大致过程如下:以 Milvus 为例,一次向量检索的步骤是:
- 生成查询向量:用户输入一张图片或一段文本,使用同样的 Embedding 模型,将其转化为向量(一定要用相同的Embedding模型)。
- 发送搜索请求:带上这个查询向量,以及检索参数(如 Top-K = 10,即想要返回最相似的 10 条结果,以及可能需要的标量过滤条件,如“价格小于 100”)。
- 索引加速:Milvus 根据 Collection 上已构建的索引(比如 HNSW),快速定位到查询向量附近的候选区域。
- 计算距离并排序:在候选集里精确计算距离,选出距离最小的 K 个结果,返回给用户。
- 后处理(如有需要):用户拿到返回的实体 ID 和相似度分数后,再去关系数据库(如 MySQL)中取出完整信息(如商品详情、图片 URL 等)展示。
接下来看看示例:
// -------------------------------------------- Embedding部分 -------------------------
@Override
public EmbeddingResponse embedding(List<String> text) {
...
log.info("开始调用硅基流动[embedding]...");
// 1.构建请求
String jsonData = buildEmbeddingBodyJson(text);
RequestBody body = RequestBody.create(jsonData, MediaType.get("application/json"));
Request request = new Request.Builder()
.url(providerConfig.getBaseUrl() + EMBED_URL)
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + providerConfig.getApiKey())
.post(body)
.build();
Call call = siliconfowClient.newCall(request);
try {
Response response = call.execute();
if (!response.isSuccessful()) {
log.error(" SilvaFlow Embedding not success: {}", response.body().string());
return EmbeddingResponse.builder()
.errorMsg(" SilvaFlow Embedding Error: " + response.body().string())
.build();
}
return buildEmbeddingResponse(response.body().string());
} catch (IOException e) {
log.error(" SilvaFlow Embedding Error: {}", e.getMessage());
throw new RuntimeException(e);
}
}
// 测试类
@Test
public void testEmbedding() {
EmbeddingResponse response = modelFactory.getModel(SiliconfowModel.SILICONFLOW)
.embedding(List.of("hello world", "峻神、建神!!"));
}
调用Embedding模型生成的向量如下图所示:

【没有截到的信息】,"model":"Qwen/Qwen3-Embedding-4B","usage":{"prompt_tokens":10,"completion_tokens":0,"total_tokens":10}}
{
"object": [
"list"
],
"model": "<string>",
"data": [
{
"object": "embedding",
"embedding": [
123
],
"index": 123
}
],
"usage": {
"prompt_tokens": 123,
"completion_tokens": 123,
"total_tokens": 123
}
}
2.5.2 持久化Milvus
这一节主要就是集成Milvus客户端了,也没有什么要特别说明的。
/*
上传文件,分块,然后向量化,存入milvus
*/
@PostMapping("/upload")
public R uploadFile(@RequestPart("file") MultipartFile file) {
log.info("[VectorController] 上传文件: {}", file.getOriginalFilename());
String org_id = "org_id123456";
return milvusService.tackleFile(file, org_id);
}
向量化与持久化的过程:
// 4. 分批向量化并存储(每批 30 条)
int batchSize = 30;
int totalInserted = 0;
for (int i = 0; i < chunks.size(); i += batchSize) {
List<Chunk> batch = chunks.subList(i, Math.min(i + batchSize, chunks.size()));
// 4.1 获取 embedding
List<String> texts = batch.stream()
.map(Chunk::getContent)
.toList();
EmbeddingResponse embeddingRes = modelFactory
.getModel(SiliconfowModel.SILICONFLOW).embedding(texts);
if (embeddingRes.getData() == null || embeddingRes.getData().isEmpty()) {
log.warn("[tackleFile] 第 {} 批向量化失败,跳过", i / batchSize);
continue;
}
// 4.2 构建 Milvus 数据 (使用 JsonObject)
List<JsonObject> dataList = new ArrayList<>();
for (int j = 0; j < batch.size(); j++) {
Chunk chunk = batch.get(j);
String docId = UUID.randomUUID().toString();
// 元数据
JsonObject metadata = new JsonObject();
metadata.addProperty("source", result.getFileName());
metadata.addProperty("mimeType", result.getMimeType());
metadata.addProperty("chunkIndex", chunk.getChunkIndex());
metadata.addProperty("documentId", chunk.getDocumentId());
metadata.addProperty("startOffset", chunk.getStartOffset());
metadata.addProperty("endOffset", chunk.getEndOffset());
// 构建向量字段
List<Float> vector = embeddingRes.getData().get(j).getEmbedding();
com.google.gson.JsonArray vectorArray = new com.google.gson.JsonArray();
for (Float v : vector) {
vectorArray.add(v);
}
JsonObject row = new JsonObject();
row.addProperty(properties.getCollection().getIdField(), docId);
row.add(properties.getCollection().getVectorField(), vectorArray);
row.addProperty(properties.getCollection().getContentField(), chunk.getContent());
row.add(properties.getCollection().getMetadataField(), metadata);
row.addProperty("chunk_index", chunk.getChunkIndex());
row.addProperty("org_id", org_id);
dataList.add(row);
}
// 4.3 插入 Milvus
InsertReq insertReq = InsertReq.builder()
.collectionName(collectionName)
.data(dataList)
.build();
milvusClient.insert(insertReq);
totalInserted += dataList.size();
log.info("[tackleFile] 第 {} 批插入成功,数量: {}", (i / batchSize) + 1, dataList.size());
}

测试接口结果如上图所示。
受篇幅限制,本文暂且到这里了,接下来的检索阶段见后续文章。
截止到此:【见分支dev-v2-vector】
代码肯定是不完美的,仅仅是供读者学习思路。【同时感谢claude code、gemini】

浙公网安备 33010602011771号