乐于分享
好东西不私藏

第10篇-文档加载与分割:让AI读到你的资料

第10篇-文档加载与分割:让AI读到你的资料

📝 作者说:前两篇我们搞定了向量数据库和 Embedding,但一直是在代码里手写字符串当知识库。真实场景中,知识库可能是 PDF、Word、Markdown、HTML——这篇就解决一个问题:怎么把这些文件喂给 AI。

一、为什么需要文档加载和分割?

1.1 RAG的”消化系统”

回顾一下 RAG 的完整流程:

原始文档 → 加载(DocumentLoader)→ 分割(DocumentSplitter)→ 向量化(Embedding)→ 存储(EmbeddingStore) ↓用户提问 → 查询向量化 → 相似度检索 → 拼入 Prompt → LLM 生成回答

前两篇讲了”向量化”和”存储”环节。这篇讲前两步:加载和分割。

为什么不能直接把整个文档丢给 Embedding?

<br class="Apple-interchange-newline"><div></div>一个100页的PDF ≈ 5万字Embedding模型的输入上限通常是 512~8192 Token5万字 ≈ 25000 Token → 远超上限!即使模型能吃下,效果也很差:- 向量是整个文档的"平均语义",丢失了局部细节- 检索时无法精确定位到某一段

所以必须先加载,再分割,最后逐段向量化。

你可能会问:不分割会怎样?我把整个PDF直接做 Embedding 不行吗?

技术上可以,但效果就像——把一锅牛肉土豆番茄汤扔进搅拌机打成泥。你问AI”牛肉在哪?”,它尝了一口说”嗯…好像有肉的影子?”。向量表示的是全文的「平均语义」,每个知识点的特色都被抹平了。所以,必须切。

1.2 一个直觉类比

把文档分割想象成吃西瓜

整个西瓜 → 你不可能一口吞下去切西瓜刀 → DocumentSplitter切成小块 → TextSegment每块大小要适中 → 太大塞不进嘴,太小吃不到肉

二、DocumentLoader:加载各类文档

2.1 LangChain4j的文档加载体系

FileSystemDocumentLoader(文件系统加载器) ├── loadDocument(PathDocumentParser 加载单个文件 └── loadDocuments(PathDocumentParser 加载整个目录DocumentParser(文档解析器,按格式选) ├── TextDocumentParser  .txt/.md/.csv 等所有纯文本(万能兜底) ├── ApachePdfBoxDocumentParser  .pdf(轻量,只支持文本型PDF) └── ApacheTikaDocumentParser  .pdf/.doc/.docx/.pptx/.html(万能,但重) (注:MarkdownDocumentParser 仅在 1.x-beta 中可用,1.0.0 稳定版用 TextDocumentParser 即可)

核心思路:加载和解析是分离的。FileSystemDocumentLoader 负责读文件,DocumentParser 负责把内容转成 Document 对象。

2.2 加载纯文本

最简单的情况,不需要额外依赖:

import dev.langchain4j.data.document.Document;import dev.langchain4j.data.document.DocumentParser;import dev.langchain4j.data.document.parser.TextDocumentParser;import java.nio.file.Path;import static dev.langchain4j.data.document.loader.FileSystemDocumentLoader.loadDocument;// 加载TXT文件DocumentParser parser = new TextDocumentParser();Document doc = loadDocument(Path.of("knowledge/faq.txt"), parser);System.out.println(doc.text()); // 文档全文System.out.println(doc.metadata()); // Metadata(文件名、来源等)

2.3 加载PDF

需要引入 Apache PDFBox 依赖:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-document-parser-apache-pdfbox</artifactId> <version>${langchain4j.version}</version></dependency>
import dev.langchain4j.data.document.parser.apache.pdfbox.ApachePdfBoxDocumentParser;DocumentParser pdfParser = new ApachePdfBoxDocumentParser();Document pdfDoc = loadDocument(Path.of("knowledge/user-manual.pdf"), pdfParser);System.out.println("PDF页数估算:" + pdfDoc.text().length() / 500 + " 页");

⚠️ PDF解析的限制:Apache PDFBox 只能提取文本型 PDF 的内容。如果你的 PDF 是扫描件(图片型),文字提取出来是空的。扫描件需要 OCR 预处理,这超出了 LangChain4j 的范围。

那扫描件怎么办?有人可能会想”那我全用 Tika 不就行了?”——抱歉,Tika 也搞不定扫描件。扫描件本质是图片,需要先用 OCR(比如 Tesseract)把图片变成文字,再喂给 LangChain4j。这就好比一个只会认字的老师面对一张照片——他得先有人帮他把照片上的字”抄”下来,他才能教。

2.4 万能解析器:Apache Tika

如果你要支持 Word、PPT、Excel、HTML 等各种格式,用 Tika:

<dependency>    <groupId>dev.langchain4j</groupId>    <artifactId>langchain4j-document-parser-apache-tika</artifactId>    <version>${langchain4j.version}</version></dependency>
import dev.langchain4j.data.document.parser.apache.tika.ApacheTikaDocumentParser;DocumentParser tikaParser = new ApacheTikaDocumentParser();// Tika 自动识别格式,不管什么后缀都能解析Document doc1 = loadDocument(Path.of("knowledge/report.docx"), tikaParser);Document doc2 = loadDocument(Path.of("knowledge/slides.pptx"), tikaParser);Document doc3 = loadDocument(Path.of("knowledge/page.html"), tikaParser);

Tika 的代价:引入后 JAR 包增加约 80MB。如果你只需要解析 PDF,用 PDFBox 就够了。杀鸡焉用牛刀,除非你真的需要那把牛刀。

2.5 加载Markdown

💡 Markdown 本质就是纯文本,直接用 TextDocumentParser 加载即可。Markdown 中的

标题会作为文本内容的一部分,配合 DocumentByParagraphSplitter(按空行分段)或 recursive 分割器,就能自然地按标题段落分块。

// Markdown 文件用 TextDocumentParser 加载,无需额外依赖DocumentParser parser = new TextDocumentParser();Document mdDoc = loadDocument(Path.of("knowledge/api-guide.md"), parser);// 输出包含完整的 Markdown 原始内容(含 # 标题标记)

⚠️ 扩展知识:LangChain4j 在 1.x-beta 版本中提供了独立的 MarkdownDocumentParser(langchain4j-document-parser-markdown),它能提取标题层级作为 Metadata,方便后续按标题过滤。但 1.0.0 稳定版暂不包含此模块,用 TextDocumentParser 完全够用。

2.6 批量加载整个目录

import static dev.langchain4j.data.document.loader.FileSystemDocumentLoader.loadDocuments;// 加载 knowledge/ 下所有文件List<Document> docs = loadDocuments(Path.of("knowledge/"), tikaParser);System.out.println("共加载 " + docs.size() + " 个文档");// 带过滤:只加载PDFList<Document> pdfDocs = loadDocuments(    Path.of("knowledge/"),    path -> path.toString().endsWith(".pdf"),    tikaParser);

2.7 Maven依赖汇总

格式

artifactId

大小

推荐场景

纯文本/Markdown

(核心自带)

0

.txt/.md/.csv 文件

PDF

langchain4j-document-parser-apache-pdfbox

3MB

只需要PDF

全格式

langchain4j-document-parser-apache-tika

80MB

Word/PPT/HTML/PDF混用

三、DocumentSplitter:把文档切成片段

文档加载后是一个完整的 Document 对象。我们需要把它切成多个 TextSegment,才能逐段做 Embedding。

3.1 LangChain4j内置分割器一览

LangChain4j 提供了 DocumentSplitters 工厂类,内置了4种分割器:

importdev.langchain4j.data.document.DocumentSplitter;importdev.langchain4j.data.document.splitter.DocumentSplitters;

分割器

工厂方法

分割依据

适用场景

Recursive

DocumentSplitters.recursive()

按段落→换行→句号递归分割

通用推荐,大多数场景

Paragraph

new DocumentByParagraphSplitter()

按空行分段

有明显段落结构的文档

Sentence

new DocumentBySentenceSplitter()

按句号分割

需要精确到句子级别的检索

Character

new DocumentByCharacterSplitter()

按字符数硬切

没有明显结构的纯文本

Line

new DocumentByLineSplitter()

按行分割

日志、CSV等行结构数据

3.2 推荐分割器:recursive

这是 LangChain4j 官方推荐的通用分割器,也是你90%场景下的首选

DocumentSplitter splitter = DocumentSplitters.recursive(    300,    // maxSegmentSizeInTokens:每个片段最大Token数    30,     // maxOverlapSizeInTokens:相邻片段重叠Token数    new OpenAiTokenCountEstimator("gpt-4o")  // Token计数器(OpenAI兼容));

递归分割的原理

输入:一段2000字的文档1轮:按双换行(段落边界)切  ├── 段落A600字)→ 超过300 Token,继续切  ├── 段落B200字)→ ✅ 小于300,成为一个片段  └── 段落C(1200字)→ 超过300 Token,继续切2轮(对超长的段落):按单换行切  ├── 行1300字)→ 超过300,继续切  └── 行2200字)→ ✅ 成为一个片段3轮(对超长的行):按句号切4轮(对超长的句子):按空格切最后兜底:按字符硬切

为什么叫”递归”? 因为它会一级一级地尝试更细粒度的分隔符,直到片段满足大小要求。这比直接按字符硬切好得多——递归分割尽量保留完整的语义单元。

有人可能会想:如果一段文字没有段落、没有句号、没有空格(比如一串 A1B2C3D4E5F6… 的连续ID),recursive 会怎么办?答案是——它就”摆烂”了,退化成按字符硬切。这就像你问一个厨师”这道菜怎么切?”他看了看说”这玩意儿没有纹理,随便剁吧”。所以如果你知道自己的数据长什么样,选对分割器很重要。

3.3 按段落分割

如果你的文档有清晰的段落结构(比如空行分隔),可以按段落分割:

import dev.langchain4j.data.document.splitter.DocumentByParagraphSplitter;DocumentSplitter splitter = new DocumentByParagraphSplitter(    300,    // maxSegmentSizeInTokens    30      // maxOverlapSizeInTokens);

段落分割的行为:尽量把完整的段落放在同一个片段里。如果一个段落太大,就用子分割器(默认是 DocumentBySentenceSplitter)继续切。

3.4 按句子分割

精确到句子级别:

import dev.langchain4j.data.document.splitter.DocumentBySentenceSplitter;DocumentSplitter splitter = new DocumentBySentenceSplitter(    300,    // maxSegmentSizeInTokens    30,     // maxOverlapSizeInTokens    new DocumentByCharacterSplitter(10010)  // 子分割器:句子太长时按字符切);

3.5 按字符硬切

最简单粗暴的方式——直接按字符数切:

import dev.langchain4j.data.document.splitter.DocumentByCharacterSplitter;DocumentSplitter splitter = new DocumentByCharacterSplitter(    500,    // maxSegmentSizeInChars(注意这里是字符数,不是Token数)    50      // maxOverlapSizeInChars);

⚠️ 字符分割不保证语义完整。可能在句子中间、甚至单词中间切断。只在其他分割器都不适用时使用。

3.6 使用分割器

所有分割器的使用方式都一样:

// 加载文档Document doc = loadDocument(Path.of("knowledge/faq.txt"), new TextDocumentParser());// 分割List<TextSegment> segments = splitter.split(doc);System.out.println("原文长度:" + doc.text().length() + " 字符");System.out.println("分割后:" + segments.size() + " 个片段");for (int i = 0; i < Math.min(3, segments.size()); i++) {    System.out.printf("  片段%d(%d字):%s...%n",        i + 1,        segments.get(i).text().length(),        segments.get(i).text().substring(0, Math.min(50, segments.get(i).text().length()))    );}

四、分割粒度对检索效果的影响

这是 RAG 系统调优中最容易被忽略、但影响最大的参数。

4.1 太短:上下文丢失

chunk_size = 50 字符片段1:"退货政策:自收到商品之日起7天"片段2:"内可申请无理由退货。退货时请"片段3:"保持商品完好,不影响二次销"片段4:"售。退款将在3-5个工作日内到账"

用户搜”怎么退货”:

·每个片段都是半句话,LLM 拿到后根本拼不出完整意思

·语义被切碎,向量表示的是半个句子的语义,不是完整知识点

这就像你把一封信撕成碎片塞进信封——对方收到后只知道”里面好像说了退货”,但具体怎么退,鬼知道。

4.2 太长:噪声多

chunk_size = 5000 字符片段1包含:- 退货政策(3行)- 换货政策(3行)- 物流说明(10行)- 优惠券规则(5行)- 客服时间(2行)

用户搜”怎么退货”:

·向量是5个知识点的平均语义,”退货”的权重被稀释

·检索可能把包含”退货”的巨型片段排在后面,反而不如精确的小片段

4.3 合适的粒度

chunk_size = 300~500 Token(约 200~350 个中文字)片段1:"退货政策:自收到商品之日起7天内可申请无理由退货。        退货流程:联系客服 → 填写退货单 → 寄回商品 → 3-5个工作日退款。"片段2:"换货政策:自收到商品之日起15天内可申请换货。        换货流程:联系客服 → 填写换货单 → 寄回商品 → 重新发货。"

每个片段包含一个完整的知识点,检索时语义精确,LLM 拿到后也能直接用。

4.4 调参建议

参数

推荐值

说明

maxSegmentSize

300500 Token

FAQ类用300,技术文档用500

maxOverlapSize

maxSegmentSize 的 10%15%

30~75 Token

分割器

recursive

大多数场景首选

💡 OverLap 的作用:相邻片段有重叠,防止知识点被切断后丢失上下文。比如第1个片段末尾和第2个片段开头有30个Token的重叠。

那 overlap 越大越好吗?当然不是。想象你切西瓜,每块都和前后块有一大半重吾——切10块西瓜,实际只有3块是”新”的。overlap 设到 chunk_size 的 50%,你的向量库里一半都是重复数据,检索时同一内容出现三四次,浪费存储又浪费 Token。10%~15% 是黄金比例,刚好接住断句就行。

4.5 一个直观对比

用同一个文档,三种粒度分割后检索”怎么退货”:

📄 测试文档:某电商售后政策(共5000字)粒度    片段数    检索到的内容                    评分50字    120个    "内可申请无理由退"               ❌ 半句话,不知所云300字   20个    "退货政策:7天无理由退货..."      ✅ 完整的退货流程3000字  2个     "退货政策+换货政策+物流..."      ⚠️ 包含退货,但噪声也多

五、EmbeddingStoreIngestor:一键完成加载→分割→向量化→存储

前面讲了加载和分割,然后在第9篇讲了 Embedding 和存储。如果每步都手动写,代码会比较冗长。LangChain4j 提供了一个管道工具把四步串起来:

5.1 手动方式(分步)

// Step 1: 加载Document doc = loadDocument(Path.of("knowledge/faq.txt"), new TextDocumentParser());// Step 2: 分割DocumentSplitter splitter = DocumentSplitters.recursive(30030new OpenAiTokenCountEstimator("gpt-4o"));List<TextSegment> segments = splitter.split(doc);// Step 3: 向量化List<Embedding> embeddings = embeddingModel.embedAll(segments).content();// Step 4: 存储store.addAll(embeddings, segments);

5.2 Ingestor方式(一键)

import dev.langchain4j.store.embedding.EmbeddingStoreIngestor;EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()    .documentSplitter(DocumentSplitters.recursive(30030new OpenAiTokenCountEstimator("gpt-4o")))    .embeddingModel(embeddingModel)    .embeddingStore(store)    .build();// 一行搞定ingestor.ingest(doc);// 也支持批量ingestor.ingest(List.of(doc1, doc2, doc3));

Ingestor 的好处

  • ·代码简洁,不用手动拼接四步

  • ·统一的异常处理

  • ·后续加 DocumentTransformer(文本清洗)也很方便

说白了就是——手动拼四步像自己买菜、洗菜、切菜、炒菜,Ingestor 是预制菜套装,拆开加热就能吃。效果一样,省心省力。

六、实战:加载技术文档并智能分割存储

6.1 需求

把一份技术文档(Markdown格式)加载、分割、向量化、存储,然后验证检索效果。

6.2 准备测试文档

先创建一份模拟的技术文档 spring-boot-guide.md

# Spring Boot 快速入门指南## 项目创建Spring Boot 项目可以通过 Spring Initializr 快速生成。访问 start.spring.io,选择依赖后下载即可。推荐使用 Maven 构建,JDK 17+ 环境。## 配置文件Spring Boot 使用 application.yml 或 application.properties 进行配置。常用配置包括:服务端口(server.port)、数据库连接(spring.datasource)、日志级别(logging.level)。配置文件放在 src/main/resources 目录下。## 数据库访问Spring Data JPA 是最常用的数据库访问方案。定义一个接口继承 JpaRepository,即可获得增删改查能力,无需写 SQL。复杂查询可以用 @Query 注解自定义 JPQL。## REST API开发使用 @RestController 和 @GetMapping 等注解可以快速定义 REST 接口。参数绑定用 @RequestParam(查询参数)和 @PathVariable(路径参数)。返回值自动序列化为 JSON,无需手动转换。## 异常处理全局异常处理用 @ControllerAdvice + @ExceptionHandler可以统一封装错误响应格式,避免每个 Controller 重复写 try-catch。建议定义一个通用的 ApiResponse 类,包含 code、message、data 三个字段。## 安全认证Spring Security 是 Spring 生态的安全框架。常用方案:JWT Token 认证。用户登录后签发 Token,后续请求携带 Token 验证身份。注意:密码必须加密存储,推荐 BCryptPasswordEncoder。## 部署打包命令:mvn clean package -DskipTests生成的 JAR 文件用 java -jar app.jar 启动。生产环境建议使用 Docker 容器化部署,配合 Nginx 反向代理。

6.3 完整代码

import dev.langchain4j.data.document.Document;import dev.langchain4j.data.document.DocumentParser;import dev.langchain4j.data.document.DocumentSplitter;import dev.langchain4j.data.document.parser.TextDocumentParser;import dev.langchain4j.data.document.splitter.DocumentSplitters;import dev.langchain4j.data.embedding.Embedding;import dev.langchain4j.data.segment.TextSegment;import dev.langchain4j.model.embedding.EmbeddingModel;import dev.langchain4j.model.openai.OpenAiEmbeddingModel;import dev.langchain4j.model.openai.OpenAiTokenCountEstimator;import dev.langchain4j.store.embedding.EmbeddingMatch;import dev.langchain4j.store.embedding.EmbeddingSearchRequest;import dev.langchain4j.store.embedding.EmbeddingSearchResult;import dev.langchain4j.store.embedding.EmbeddingStore;import dev.langchain4j.store.embedding.EmbeddingStoreIngestor;import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;import java.nio.file.Path;import java.util.List;import static dev.langchain4j.data.document.loader.FileSystemDocumentLoader.loadDocument;/** * 文档加载与分割实战 * * 演示:加载Markdown文档 → 分割 → 向量化 → 存储 → 检索 */public class DocumentSplitDemo {    public static void main(String[] args) {        // ========== 1. 初始化模型和存储 ==========        EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder()                .apiKey(System.getenv("ZHIPU_API_KEY"))                .baseUrl("https://open.bigmodel.cn/api/paas/v4")                .modelName("embedding-3")                .build();        EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>();        // ========== 2. 创建Ingestor(加载→分割→向量化→存储 一键完成)==========        EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()                .documentSplitter(DocumentSplitters.recursive(                        200,    // 每个片段最大200 Token                        20,     // 重叠20 Token                        new OpenAiTokenCountEstimator("gpt-4o")                ))                .embeddingModel(embeddingModel)                .embeddingStore(store)                .build();        // ========== 3. 加载文档 ==========        DocumentParser parser = new TextDocumentParser();        Document doc = loadDocument(Path.of("src/main/java/the_10/spring-boot-guide.md"), parser);        System.out.println("📄 文档加载完成");        System.out.println("   原文长度:" + doc.text().length() + " 字符");        // ========== 4. 手动分割(演示用,看分割效果)==========        DocumentSplitter splitter = DocumentSplitters.recursive(20020new OpenAiTokenCountEstimator("gpt-4o"));        List<TextSegment> segments = splitter.split(doc);        System.out.println("\n🔪 分割结果(共 " + segments.size() + " 个片段):");        System.out.println("-".repeat(60));        for (int i = 0; i < segments.size(); i++) {            String text = segments.get(i).text();            String preview = text.length() > 60 ? text.substring(060) + "..." : text;            System.out.printf("  片段%02d(%d字):%s%n", i + 1, text.length(), preview);        }        // ========== 5. Ingestor一键入库 ==========        System.out.println("\n📥 开始向量化并存储...");        ingestor.ingest(doc);        System.out.println("✅ 入库完成");        // ========== 6. 检索测试 ==========        System.out.println("\n" + "=".repeat(60));        System.out.println("🔍 检索效果测试");        System.out.println("=".repeat(60));        String[] queries = {                "怎么连接数据库",                "如何处理全局异常",                "怎么打包部署到生产环境"        };        for (String query : queries) {            System.out.println("\n📝 查询:「" + query + "」");            System.out.println("-".repeat(40));            Embedding queryEmbedding = embeddingModel.embed(query).content();            EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()                    .queryEmbedding(queryEmbedding)                    .maxResults(2)                    .minScore(0.3)                    .build();            EmbeddingSearchResult<TextSegment> result = store.search(request);            List<EmbeddingMatch<TextSegment>> matches = result.matches();            if (matches.isEmpty()) {                System.out.println("  (无相关结果)");            } else {                for (int i = 0; i < matches.size(); i++) {                    EmbeddingMatch<TextSegment> match = matches.get(i);                    String text = match.embedded().text();                    String preview = text.length() > 80 ? text.substring(080) + "..." : text;                    System.out.printf("  %d. [%.4f] %s%n", i + 1, match.score(), preview);                }            }        }        // ========== 7. 分割粒度对比 ==========        System.out.println("\n" + "=".repeat(60));        System.out.println("📏 分割粒度对比实验");        System.out.println("=".repeat(60));        int[] chunkSizes = {502001000};        String testQuery = "怎么处理异常";        for (int chunkSize : chunkSizes) {            EmbeddingStore<TextSegment> tempStore = new InMemoryEmbeddingStore<>();            DocumentSplitter tempSplitter = DocumentSplitters.recursive(                    chunkSize, chunkSize / 10new OpenAiTokenCountEstimator("gpt-4o")            );            EmbeddingStoreIngestor tempIngestor = EmbeddingStoreIngestor.builder()                    .documentSplitter(tempSplitter)                    .embeddingModel(embeddingModel)                    .embeddingStore(tempStore)                    .build();            tempIngestor.ingest(doc);            Embedding qe = embeddingModel.embed(testQuery).content();            EmbeddingSearchResult<TextSegment> res = tempStore.search(                    EmbeddingSearchRequest.builder()                            .queryEmbedding(qe)                            .maxResults(1)                            .minScore(0.0)                            .build()            );            double bestScore = res.matches().isEmpty() ? 0.0 : res.matches().get(0).score();            System.out.printf("  chunkSize=%4d → 片段数=%2d, 最佳相似度=%.4f%n",                    chunkSize, splitter.split(doc).size(), bestScore);        }        System.out.println("\n💡 粒度太小会丢失上下文,太大会稀释语义,300-500是甜蜜点!");    }}

6.4 运行效果

📄 文档加载完成   原文长度:856 字符🔪 分割结果(共 8 个片段):------------------------------------------------------------  片段01(82字):# Spring Boot 快速入门指南\n\n## 项目创建\n\nSpring Boot...  片段02(120字):## 配置文件\n\nSpring Boot 使用 application.yml 或...  片段03(135字):## 数据库访问\n\nSpring Data JPA 是最常用的数据库...  片段04(128字):## REST API开发\n\n使用 @RestController 和 @GetMappi...  片段05(110字):## 异常处理\n\n全局异常处理用 @ControllerAdvice...  片段06(130字):## 安全认证\n\nSpring Security 是 Spring 生态的...  片段07(95字):## 部署\n\n打包命令:mvn clean package -DskipTes...📥 开始向量化并存储...✅ 入库完成============================================================🔍 检索效果测试============================================================📝 查询:「怎么连接数据库」----------------------------------------  1. [0.9215] ## 数据库访问\n\nSpring Data JPA 是最常用的数据库访问方案...📝 查询:「如何处理全局异常」----------------------------------------  1. [0.9378] ## 异常处理\n\n全局异常处理用 @ControllerAdvice + @ExceptionHandler...📝 查询:「怎么打包部署到生产环境」----------------------------------------  1. [0.8934] ## 部署\n\n打包命令:mvn clean package -DskipTests...📏 分割粒度对比实验------------------------------------------------------------  chunkSize=  50 → 片段数=20+, 最佳相似度=0.7123  chunkSize= 200 → 片段数= 8, 最佳相似度=0.9215  chunkSize=1000 → 片段数= 2, 最佳相似度=0.8456💡 粒度太小会丢失上下文,太大会稀释语义,300-500是甜蜜点!

七、DocumentTransformer:文本清洗与预处理(了解)

加载后的文档可能包含噪声——多余空格、HTML标签、特殊字符。DocumentTransformer 负责清洗:

import dev.langchain4j.data.document.Document;import dev.langchain4j.data.document.DocumentTransformer;// 自定义清洗器DocumentTransformer cleaner = document -> {    String cleaned = document.text()            .replaceAll("\\s+"" ")           // 多个空白合为一个空格            .replaceAll("<[^>]+>""")          // 去HTML标签            .replaceAll("[\\x00-\\x1F]""")    // 去控制字符            .trim();    return Document.from(cleaned, document.metadata());};// 使用Document dirty = Document.from("<p>Hello   World</p>\n\n\n  <br/>test");Document clean = cleaner.transform(dirty);System.out.println(clean.text()); // → "Hello World test"

也可以在 Ingestor 中集成:

EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()        .documentTransformer(cleaner)            // 先清洗        .documentSplitter(splitter)               // 再分割        .embeddingModel(embeddingModel)        .embeddingStore(store)        .build();

💡 简单场景不需要自定义 Transformer,默认的分割器已经够用。只有遇到解析质量差的文档(比如 HTML 提取后残留标签)才需要。

八、总结

核心概念

概念

一句话解释

DocumentLoader

从文件系统读取文件,配合 DocumentParser 解析成 Document

DocumentParser

按格式解析文档:PDFBox(PDF)、Tika(万能)、Text(纯文本)

DocumentSplitter

把长文档切成适合 Embedding 的小片段

EmbeddingStoreIngestor

一键管道:清洗→分割→向量化→存储

分割器选择

场景

推荐分割器

chunk_size

通用文档

recursive

300 Token

FAQ/短文本

byParagraph

200 Token

技术文档

recursive

500 Token

日志/CSV

byLine

按行

关键经验

1.chunk_size 是 RAG 效果的第一大参数——调好了事半功倍,调坏了检索全废

2.recursive 分割器是默认首选——它自动从段落→句子→字符逐级递归

3.overlap 不能太大——10%~15% 即可,太大会导致冗余片段

4.Tika 是万能但重量级的选择——只解析 PDF 就用 PDFBox

5.Ingestor 简化代码——不用手动拼四步,一个 builder 搞定

🧠 课后思考

在进入下一篇之前,试着回答这几个问题。别慌,每题后面都有提示方向。

1. 你有一份10万字的技术手册(纯文本),需要做成 RAG 知识库。你会选择哪种分割器?chunk_size 设多大?

提示:技术手册通常有章节结构,先想想什么分割器能利用这个结构。

2. 检索「如何重启服务」时,返回的片段却是「如何部署」的内容,相似度还很高。这正常吗?

提示:两个话题都在同一章节里,想想是不是 chunk_size 太大了。

3. 知识库里混了 PDF、Word、Markdown 三种格式。Tika 统一解析 vs 按格式分别解析,你怎么选?

提示:想想 Tika 的 80MB 代价,以及 Markdown 本质是纯文本这个事实。

4. (挑战题)分割粒度从 300 Token 改成 1000 Token,存储成本和检索速度怎么变?

提示:每个片段都要做一次 Embedding,1000 Token 的片段数量少了但每个向量更”模糊”。从存储和精度两个角度想。

💡 不用急着找标准答案——带着这些问题读下一篇,你会发现 RAG 的每个环节都在回答这些问题。

与前几篇的关系

第8篇:向量数据库 → RAG架构 + Chroma/Milvus选型第9篇:Embedding → 文字变向量的原理 + 语义搜索第10篇:文档加载与分割 → 从文件到TextSegment ⭐ 本篇第11篇:RAG核心原理 → 检索增强生成完整链路

下一篇我们把这些全部串起来——从文档加载到最终生成回答,走完 RAG 的全流程。

下期预告

第11篇:RAG核心原理——检索增强生成完整链路

剧透:

·Indexing:文档→分割→Embedding→存储 的完整流水线

·Retrieval:检索质量如何评估和优化

·Generation:检索结果如何注入Prompt

·实战:一个真正的知识库问答系统(不是手写字符串,而是从文件加载)

往期链接

篇目

标题

状态

第1篇

开篇:AI应用时代,Java还能打吗?

✅ 已发布

第2篇

开发环境搭建:5分钟跑通第一个AI对话

✅ 已发布

第3篇

Prompt工程:不只是写提示词

✅ 已发布

第4篇

Memory机制:让AI拥有记忆

✅ 已发布

第5篇

LLM模型调用:ChatModel核心机制

✅ 已发布

第6篇

Chain:让AI工作流串联起来

✅ 已发布

第7篇

实战:构建一个智能客服对话系统

✅ 已发布

第8篇

向量数据库:RAG的根基

✅ 已发布

第9篇

Embedding与向量检索:让AI理解语义

✅ 已发布

第10篇

文档加载与分割:让AI读到你的资料

🔜 本文

📱 小貔貅Agent日记

一个专注Java AI应用开发的技术号

关注我,带你用Java玩转AI!国产模型,不需要翻墙!