旧 Java 项目接入 AI 实战系列(三):把企业文档装进知识库,多格式解析 + 向量化入库

适用读者:已经跑通了对话(第一篇)、对话记忆(第二篇),正准备让 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 只认企业自己的「教材」,一问一个准
相关推荐
2601_960356381 小时前
2027金融科技专业求职贷后管理岗:Excel、SQL如何对应业务任务?
人工智能
AI排障笔记1 小时前
TileLang 同步修复:一个 block 为什么上报了两次
人工智能
久曲健的测试窝1 小时前
Testin云测探索脚本自愈落地路径
运维·人工智能·科技·aigc
CHEEVEN_QY1 小时前
热仿真在液冷板设计中的应用价值与局限性客观评估
人工智能·算法
通信大模型1 小时前
IEEE JSTSP| FlashSAM:低空无线网络中多无人机Token通信的轻量级视觉模型
人工智能·深度学习·无人机
熊猫钓鱼>_>1 小时前
告别手工贴发票:增值税发票识别+核验,搭一条报销自动化流水线
人工智能·ai·职场和发展·自动化·大模型·llm·agent
安生生申1 小时前
ESP32-CAM视觉语音AI助手(千问平台):摄像头、INMP441、MAX98357与单片机数据接入
人工智能·单片机·嵌入式硬件
BSD_CGQ1 小时前
人形机器人触觉传感器为什么集中选 FSR 柔性薄膜方案?选型边界与国产替代现状
人工智能·机器人·压力传感器·柔性薄膜压力传感器
Phoenixtree_DongZhao1 小时前
[CVPR2026] 视觉 Mamba:RNN 与线性 Transformer 的统一视角
人工智能·rnn·transformer