适用读者:已经跑通了对话(第一篇)、对话记忆(第二篇),正准备让 AI 基于企业内部文档回答,真正解决「胡说八道」和「数据安全」两个落地卡点的 Java 后端开发。
本章内容:
- 先搞懂 RAG 到底在解决什么:为什么大模型会一本正经地编造答案
- 技术选型:网上教程都在用 LangChain4j,我们为什么偏要自己拼?
- 核心链路:PDF/Word/Excel/TXT 自动解析 → 清洗 → 切片 → 向量化 → 存入 Milvus
- 工程细节:魔数校验防伪造、双层清洗防噪音、防 OOM、向量维度怎么对齐
- 前端对接:一个知识库管理界面,上传文件自动进 AI 的「教材库」
- 避坑合集:版本冲突、维度不匹配、记忆不持久......一次讲透
一、先看结果:AI 终于不瞎编了
前两篇做完,AI 能对话、能记住上下文了。但真拿去给业务用,立刻露馅------你问它:
咱们公司差旅报销标准是多少?
它一本正经地回你:「一般 500 元。」
错得离谱。这就是大模型「幻觉」------它脑子里那点通用知识根本不了解你们公司的制度,但它会滔滔不绝地编,编得还特别像真的。
想让它不乱说,只有一条路:把企业的文档喂给 AI。这就是 RAG(检索增强生成)。
一句话本质:
普通问答 = 闭卷考试 ,AI 全凭脑子,不会就瞎蒙; RAG 问答 = 开卷考试,先给它发参考资料,规定只能照着资料答。
而让 AI 能「开卷」,第一步就是:把公司散落的 PDF、Word、Excel、TXT 文档,整理、解析、存进一个它能快速检索的「教材库」。
这就是本章要做的核心工作。
二、技术选型:为什么网上教程的路,我们走不通
2.1 网上主流教程的做法
打开任意一篇「Java RAG 实战」,几乎都是这个套路:
| 环节 | 技术 |
|---|---|
| 大模型 | Ollama 部署本地开源模型(qwen2) |
| AI 框架 | LangChain4j |
| 文档解析 | Apache PDFBox + POI |
| 向量库 | LangChain4j 提供的 InMemory / Milvus 适配器 |
看着很美好,但第一关就卡死 ------我们项目是 JDK 8。
第一篇「选型对比」里已经讲透了:LangChain4j 旧版(v0.1.0 ~ v0.35.0)编译产物 class 版本号 55(JDK 11),新版(v0.36.0 ~ 至今)直接要求 JDK 17。物理上无法在 JDK 8 跑起来。网上那些「导入 LangChain4j 依赖」的教程写得再丝滑,我们一行都用不了。
2.2 我们的取舍:扔掉框架,直调三个 SDK
既然用不了 LangChain4j,那就干脆不用框架,用直调的方式一个一个拼出来,好处反而更多:
| 环节 | 我们用的技术 | 说明 |
|---|---|---|
| 文档解析 | Apache Tika 2.9 | 一套 API 覆盖所有格式 + 魔数检测 |
| 文本向量化 | DashScope 通义 text-embedding-v3 | 沿用直调 SDK 路线,JDK 8 兼容 |
| 向量库 | Milvus 直连 SDK(milvus-sdk-java) | 不经过 LangChain4j 适配器,直连 |
| 工程集成 | Spring Boot 2.7.18 + JDK 8 | 完全贴合老项目 |
⚠️ 这是本文最重要的观念转变 :很多网文把 RAG 讲成了「引一个 LangChain4j 依赖就完事」,但对我们存量 JDK 8 项目根本不成立。放弃框架、用直调 SDK 拼出完整链路,不仅绕开了版本地狱,还让你真正理解每一环在干什么------出问题也好排查。这才是老项目接入 AI 的正确姿势。
依赖上只有三组新增(Tika、DashScope embedding、Milvus SDK),都不碰 LangChain4j:
xml
<!-- 文档解析 -->
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
<version>2.9.0</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parsers-standard-package</artifactId>
<version>2.9.0</version>
</dependency>
<!-- 文本向量化(DashScope)+ 向量库(Milvus)-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>dashscope-sdk-java</artifactId>
<version>2.19.0</version>
</dependency>
<dependency>
<groupId>io.milvus</groupId>
<artifactId>milvus-sdk-java</artifactId>
<version>2.3.6</version>
</dependency>
三、整体架构:一条「文档入库」流水线
无论是纯文本上传,还是 PDF/Word/Excel 文件上传,最终都要走同一条流水线,把文档变成「AI 能检索的向量」:
markdown
上传文件 → 类型校验(魔数) → Tika 解析成正文 → 双层清洗
→ 中文切片(带重叠) → DashScope 向量化 → 写入 Milvus
画成图就是:
css
[用户上传] ──> [魔数校验: 是不是真PDF] ──> [Tika: 提取纯文本]
│
▼
[TextCleaningService: 双层清洗去噪音]
│
▼
[ChineseTextChunker: 500字符/重叠50]
│
▼
[DashScope embedding: 每块转1024维向量]
│
▼
[MilvusVectorStore: 连同知识ID写入集合]
每一环我按「为什么这样写 → 核心代码 → 容易踩的坑」来讲。
四、第一环:文档解析------Tika 一套通吃
之前很多教程是 PDF 用 PDFBox、Word 用 POI 分开写,遇到不支持的格式就开始踢皮球。我们用 Apache Tika,它底层自动封装了 PDFBox/POI 等解析器,一套 API 通吃所有格式,还能顺带解决「后缀名骗人」的坑。
DocumentParseService 的核心做了 4 个工程化配置:
java
public String parse(InputStream inputStream, String fileName)
throws IOException, SAXException, TikaException {
AutoDetectParser parser = new AutoDetectParser();
// 1. 限制最大文本长度 5MB,防止恶意大文件直接 OOM
BodyContentHandler handler = new BodyContentHandler(MAX_TEXT_LENGTH);
Metadata metadata = new Metadata();
ParseContext context = new ParseContext();
context.set(Parser.class, parser);
// 2. 屏蔽嵌入资源(图片/附件)------让解析结果只有干净正文
context.set(EmbeddedDocumentExtractor.class, new NoOpEmbeddedDocumentExtractor());
// 3. PDF 专用配置:关掉内嵌图片提取、按坐标排序保证多栏阅读顺序
PDFParserConfig pdfConfig = new PDFParserConfig();
pdfConfig.setExtractInlineImages(false);
pdfConfig.setSortByPosition(true);
context.set(PDFParserConfig.class, pdfConfig);
parser.parse(inputStream, handler, metadata, context);
return handler.toString();
}
4.1 第一个关键点:魔数校验,不给「李鬼文件」机会
很多教程的校验就一句 getContentType(),或者拿后缀名判断。但这两者都能被伪造 ------把可执行文件改个 .pdf 后缀就能骗过去。
我们让 Tika 去读文件头部的字节(也叫魔数,类似身份证号),不看客户端说什么:
java
public void assertAllowedType(TikaInputStream inputStream, String fileName) throws IOException {
// 让 Tika 读文件头部字节匹配真实类型,不信任后缀名和 Content-Type
String contentType = tika.detect(inputStream, fileName);
for (String allowed : ALLOWED_TYPES) {
if (contentType != null && contentType.toLowerCase()
.startsWith(allowed.toLowerCase())) {
return;
}
}
throw new IllegalArgumentException(
"不支持的文件类型:" + contentType + ",仅支持 PDF/Word/文本");
}
只收 PDF、DOC/DOCX、TXT 白名单。伪装成 PDF 的 exe,第一关就被拦下来。
4.2 第二个关键点:BodyContentHandler 限长,防 OOM
new BodyContentHandler(5 * 1024 * 1024) 是文本长度上限。一个 3MB 的 Word,解析出的纯文本通常只有几百 KB,但一个恶意构造的文件可能让解析输出膨胀几十倍------这个上限就是防这种极端情况的。超了抛 SAXException,而不是把内存撑爆。
4.3 第三个关键点:NoOp 屏蔽嵌入资源
解析 Word 文档时有个著名坑:文档里的插图会被解析成一行 image1.jpeg 混进正文,AI 甚至会据此给出「建议移除图片」这种荒唐回答。NoOpEmbeddedDocumentExtractor 让 shouldParseEmbedded() 恒返回 false,从源头跳过所有图片、附件:
java
public boolean shouldParseEmbedded(Metadata metadata) {
return false; // 跳过所有嵌入资源
}
配套的 TextCleaningService 是对解析结果的第二层兜底清洗 ,正则预编译,专门清掉残留的 file:///tmp/xxx 路径、--- 分隔线、连续空行等。这就是「双层防护」:Tika 配置清一遍,正则再兜底一遍。
五、第二环:中文切片------别把一句话切成两半
文档很长,不能整份扔给 embedding(超模型输入上限,检索也不准)。所以要把长文档切成一小块一小块,每块都转成向量,检索时才精准。
切片有个通用参数:size(每块多长)+ overlap(相邻块重叠多少)。重叠是为了不让「一个完整语义」恰好被从中间切开------就像一段话读到一半被截断,后面补了前面几个字,专业术语不至于断。
ChineseTextChunker 就是一个简洁的递归式固定窗口切片:
java
public List<String> split(String text, int size, int overlap) {
List<String> chunks = new ArrayList<>();
String source = text.trim();
int length = source.length();
int start = 0;
while (start < length) {
int end = Math.min(start + size, length);
chunks.add(source.substring(start, end).trim());
if (end == length) {
break;
}
// 下一个起点 = 当前终点 - 重叠数,保证相邻块语义连贯
start = Math.max(end - overlap, start + 1);
}
return chunks;
}
调用处用的是 500 字符 / 重叠 50:
java
private static final int CHUNK_SIZE = 500;
private static final int CHUNK_OVERLAP = 50;
⚠️ 切片大小不是死的。中文场景 400-600 字符、重叠 10% 左右效果最好。表格类文档建议切小一点(300),长文本文档切大一点(600),按需调整。
六、第三环:向量化------DashScope 直调,把文本变成坐标
切好的每一块文本,都要通过 embedding 模型转成「向量」(一串数字坐标)。语义相近的文本,向量在空间里靠得近,Milvus 才能做到「语义搜索」。
DashScopeEmbeddingService 通过 dashscope-sdk-java 直调通义 text-embedding-v3:
java
private List<List<Float>> embedBatchInternal(List<String> texts) {
TextEmbeddingParam param = TextEmbeddingParam.builder()
.apiKey(apiKey)
.model(model)
.texts(texts)
.build();
TextEmbeddingResult result = new TextEmbedding().call(param);
// 拿到每个文本的向量,Double 转 Float
...
}
一个必须处理的细节 :DashScope embedding 接口单次批次最多 20 条,超了会报错。所以 embedBatch 做了分批合并:
java
final int batchSize = 20; // DashScope 上限
for (int i = 0; i < texts.size(); i += batchSize) {
List<String> subTexts = texts.subList(i, Math.min(i + batchSize, texts.size()));
allVectors.addAll(embedBatchInternal(subTexts));
}
一个小文档可能切出几十块,正好一批几十条请求,必须分批。文档越多,这个分片逻辑越关键。
七、第四环:写入 Milvus------向量库的建库、入库
向量存哪?我们用 Milvus------专为向量检索设计的数据库,数据持久化、支持集群和大规模检索(InMemory 那种重启就丢、只适合 Demo 的就不考虑了)。
MilvusVectorStore 在 @PostConstruct 里完成建库(集合 + 索引 + 加载),字段设计如下:
| 字段 | 类型 | 说明 |
|---|---|---|
id |
Int64 | Milvus 自增主键 |
knowledge_id |
Int64 | 关联知识库文件表的 id |
chunk_index |
Int64 | 切片序号 |
content |
VarChar | 切片原文(检索结果靠它还原) |
vector |
FloatVector | 1024 维嵌入向量 |
核心入库逻辑:切片 → 批量向量化 → 逐行组装 → insert:
java
public void indexFile(KnowledgeFile file, String content) {
// 1. 中文切片
List<String> chunks = chunker.split(content, CHUNK_SIZE, CHUNK_OVERLAP);
// 2. 批量向量化(内部自动分批)
List<List<Float>> vectors = embeddingService.embedBatch(chunks);
// 3. 组装成 Milvus 的行数据(JSONObject)
List<JSONObject> rows = new ArrayList<>(chunks.size());
for (int i = 0; i < chunks.size(); i++) {
JSONObject row = new JSONObject();
row.put(FIELD_KNOWLEDGE_ID, file.getId());
row.put(FIELD_CHUNK_INDEX, (long) i);
row.put(FIELD_CONTENT, chunks.get(i));
row.put(FIELD_VECTOR, vectors.get(i));
rows.add(row);
}
// 4. 写入集合
InsertParam param = InsertParam.newBuilder()
.withCollectionName(collectionName)
.withRows(rows).build();
...
}
向量维度必须对齐!
建集合时,向量的 dimension 字段必须和 embedding 模型输出维度完全一致,差一位都建库失败。配置里两处要同步约定:
yaml
ai:
embedding:
model: text-embedding-v3 # DashScope 通义
dimension: 1024 # embedding 输出维度
milvus:
collection-name: knowledge_vectors
dimension: 1024 # 必须与 ai.embedding.dimension 完全一致
⚠️ 这是最高发的坑:换 embedding 模型(比如 v3 变 v2)维度就从 1024 变 1536,不改 Milvus 的 dimension 直接建集合失败。换模型前先查官方维度,两边一起改。
另外 Milvus 是分布式组件,我用 Docker 一键起了 standalone 单机 + 配套组件(etcd、MinIO)+ 可视化的 Attu,一条 compose 就能在本地跑通,http://localhost:8000 可看数据、http://localhost:19530 是 gRPC 端口(Java SDK 连这个)。
还有个小坑:Milvus 集合名默认转小写,所以配置里统一
knowledge_vectors,别用大写,否则读写不一致查不到。
八、串起来:Service 层的一键入库
8.1 Service 层:上传就自动索引
KnowledgeServiceImpl.upload 把「存文件 → 存元数据 → 解析入库」串起来,最核心的是最后一步 indexVector------它把整条流水线跑完:
java
private void indexVector(KnowledgeFile entity) {
File source = resolveFile(entity.getStoredName());
try (InputStream in = Files.newInputStream(source.toPath())) {
// 1. Tika 解析成正文
String rawText = documentParseService.parse(in, entity.getFileName());
// 2. 双层清洗
String cleanText = textCleaningService.clean(rawText);
if (StringUtils.hasText(cleanText)) {
// 3. 切片 → 向量化 → 写入 Milvus
milvusVectorStore.indexFile(entity, cleanText);
}
} catch (Exception e) {
log.error("文档向量化失败,id: {}, 文件名: {}", entity.getId(),
entity.getFileName(), e);
}
}
一个值得借鉴的设计 :向量化失败不阻断文件上传 ,只记告警日志。文件本体已经存好了,向量索引后补重试即可。语义就是:功能可以打折,流程不能挂。 和上一篇 Redis「宕机降级到本地记忆」是同一个思路。
上传时还会把文件信息登记进 MySQL(标题、分类、作者、状态、文件类型、大小),和 Milvus 里的 knowledge_id 一一对应:
java
if (fileType == null) {
throw new IllegalArgumentException("仅支持 pdf / word / excel 格式文件");
}
// 落盘 uploadFiles 目录 + 元数据入 MySQL + indexVector 入库
顺便说:
upload走一条链路,update(换新文件)会先删旧向量再重建 ,delete会删磁盘文件 + 删 Milvus 向量 + 删库记录,三个操作保持数据一致。这块是工程完整度很高的体现。
九、前端对接:一个页面,搞定知识库管理
前两篇我们是给 AI 加能力,这次是给用户一个「管理教材库」的入口。前端新增了「知识库管理」菜单(路由 /knowledge/list),List.vue 用 Element Plus 表格展示所有知识,支持搜索、分类筛选、上传、下载、删除。
状态管理抽成了 Pinia store useKnowledgeStore,对接后端接口:
js
export const useKnowledgeStore = defineStore('knowledge', () => {
const knowledgeList = ref([])
const total = ref(0)
const loading = ref(false)
const fetchKnowledgeList = async (params = {}) => {
loading.value = true
try {
const res = await knowledgeApi.fetchKnowledgeList({
page, pageSize, keyword, category
})
const data = res?.data || res
knowledgeList.value = data?.records || []
total.value = data?.total || 0
} finally {
loading.value = false
}
}
return { knowledgeList, total, loading, fetchKnowledgeList }
})
上传按钮走 multipart 表单 POST 到 /api/knowledge/upload,传完返回「上传成功」就自动触发后端解析入库。用户零感知------他以为只是传了个文件,实际文档已经被切块、向量化、扔进 Milvus 了。
十、避坑合集:这些坑,我替你踩过了
坑 1:版本冲突,启动就 NoClassDefFoundError dashscope、milvus、Spring 各自带了不同版本的 Netty / protobuf 依赖,直接引入常常冲突报错。解决:在 dependencyManagement 里统一 Netty 版本(4.1.101)和 protobuf 版本(3.24.0) ,把冲突的一方压制住。版本不对时重点看异常里是哪个类的 NoSuchFieldError / IllegalAccessError,再去 pom 里排除。
坑 2:让嵌入模型维度不匹配 Milvus 集合维度,建库失败 这是最高发的坑。text-embedding-v3 是 1024 维,换 v2 就变 1536 维。换模型必须同步改 Milvus 的 dimension。
坑 3:把东西全塞进 InMemory 向量库,重启就全丢 入门教程爱用内存库,服务一重启文档全没。真正能用得换 Milvus(持久化) 或 PGVector/ES。
坑 4:真按教程引 LangChain4j,JDK 8 直接编译不通过 网上的 RAG 教程 99% 基于 LangChain4j。我们在第一章已经验证过它物理不兼容 JDK 8(旧版 class 55、新版要 17),别硬引,用直调 SDK 拼链路。这也是本文价值所在。
坑 5:相似度阈值放太低,啥都检索得出来 入门阶段可以先用 0.7 这样的阈值,宁可「暂无相关资料」也别硬凑无关文档,AI 才不会乱答。Milvus 检索是按相似度排名的,返回哪些、过滤哪些,后续在问答层再细化。
十一、本章小结
这一章我们给老项目搭起了「企业知识库 + 向量索引」的地基,让它从「只会聊天的金鱼」变成「有教材可查的好学生」:
| 环节 | 技术 | 本系列特色 |
|---|---|---|
| 文档解析 | Apache Tika | 一套 API,魔数校验防伪造 |
| 清洗 | TextCleaningService | 双层清洗去噪音 |
| 切片 | ChineseTextChunker | 中文固定窗口 + 重叠 |
| 向量化 | DashScope 直调 | JDK 8 完全兼容 |
| 向量库 | Milvus 直连 SDK | 持久化、可集群、可扩展 |
| 管理 | Spring Boot 知识库 CRUD | 上传即自动入库 |
最关键的观念 :很多网文把 RAG 包装成「引一个框架就完事」,但存量 JDK 8 项目根本走不了 LangChain4j 那条路。我们偏要扔掉框架、用直调 SDK 把「解析 → 清洗 → 切片 → 向量化 → 入库」五个环节一个个拼出来------不仅绕开了版本地狱,还让你彻底搞懂了背后原理。
到这里,AI 手里已经有一整本「企业教材」了。但教材是教材,AI 得会翻开教材、照着答题------也就是真正把它用起来。
十二、下篇预告
现在文档都进了 Milvus,但 AI 还不能「查教材」。差的就是最关键的一环:
RAG 检索问答:让 AI 边查教材边答题,从根源告别幻觉
会覆盖:
- 用户提问怎么转向量、怎么在 Milvus 做相似度检索
- 检索到的切片怎么拼进 prompt、怎么限制 AI 只能照着答
- 检索阈值、TopN、溯源展示怎么设计
- 一篇下来,让 AI 只认企业自己的「教材」,一问一个准