☰
第14章:文档 ETL——Reader、Splitter、Writer
2026/10/14 14:31:13 网站建设 项目流程

上章思考题答案

  1. 库里只有 3 条且阈值是 0 时,天气问题会返回 3 条。topK是上限,不是「必须像」。这 3 条拼进提示词后,模型最容易把不相关条款说成对天气或对退款都适用的制度。
  2. 单例上重复add,同一条款有两份向量,TopK 会被副本占满。每个用例新建SimpleVectorStore,或者在用例开始前使用空实例,不要往共享单例里累加。

1. 项目背景

1.1 业务场景

售后手册不再是三条 Java 字符串,而是classpath:handbook/after-sale.txt。里面按条款写成几段:R-01 未发货可退并写明条件,R-02 已发货不予退款,T-01 支付后 30 分钟未发货视为超时,S-01 库存不足暂停发货。客服检索「怎么退款」时,命中的那一块必须同时含有条款编号和「未发货」这个条件。只命中标题、条件掉在下一块,模型就会把规则说半句。

本章用TextReader读入,用TokenTextSplitter切块,用FileDocumentWriter把块写到文本文件,再把块放进SimpleVectorStore,并用save写成 JSON,换一个新的库load之后检索。PDF、Markdown、Tika、Jsoup 的读取器在各自模块里,本章只用纯文本。邻里仓的验收标准很具体:问「怎么退款」时,命中的那一块必须同时有R-01和「未发货」。只有编号、条件在下一块,这刀就切坏了。整篇仍是一块时,编号和条件都在,但库存条款也在,问库存和问退款会命中同一块。两种切法都要跑出来给人看。

1.2 痛点

整篇手册当成一条Document,短问句的向量被无关段落拉偏,退款问题可能更接近文末的库存说明。切得太碎,R-01 的编号在一块,条件在下一块,检索命中编号却回答不出「未发货才行」。默认切块参数对短手册几乎不切:目标块大小是 800 token,最小字符数是 350,一篇只有几百字的手册会原样返回一块。

没有切块时的路径:整篇一条向量 → 问句被稀释;或切得太小 → 条件丢失;或用默认参数以为切过了 → 其实还是一整篇。默认参数这一条最隐蔽。测试若只断言块数大于 0,整篇一块也能通过。必须同时断言粗切的块数和细切后某一块的内容。粗切期望是 1,细切期望是某一块同时含编号和条件,且这块不含S-01。少一条断言,切块参数就会在无人注意时退回默认值。

after-sale.txt

TextReader

TokenTextSplitter

FileDocumentWriter

SimpleVectorStore

save JSON

新库 load

检索命中的块


2. 项目设计

小胖说:「这不就是把长菜单撕成一页页吗?一页一个菜,点单的时候不用翻整本。撕得越碎越好找。」

小白问:「菜名在这一页,口味和不能加辣写在下一页呢?客人只拿到菜名,会不会当成什么都能加?默认撕法如果发现本子太薄、一页都不撕,你怎么知道自己没撕?」

小胖又说:「写到磁盘用那个 Writer 就行了。写完再读回来,向量还在不在?」

小白追问:「Writer 写的是文字还是向量?另一个进程加载文字,要不要重新嵌入?JSON 里如果已经有向量,加载的是同一份,还是又算了一遍?」

大师把三条流水分开:「TextReader把整个资源读成一条Document,元数据里有source和字符集。TokenTextSplitter.builder()按 token 切。默认chunkSize是 800,minChunkSizeChars是 350。只有剩余 token 多于 chunkSize 时,才会在句号、问号、叹号或换行处收束。短于一块的全文不会被拆开。本章为了对比,准备两套参数。粗切:withChunkSize(800),配合默认最小字符数,短手册往往仍是 1 块。细切:withChunkSize(80)且withMinChunkSizeChars(20),让每一条条款尽量单独成块。FileDocumentWriter把块的格式化文本写进文件,它不写向量。向量在SimpleVectorStore.save(File)的 JSON 里。新库load这个 JSON 后可以直接similaritySearch,不必再嵌入一遍,前提是你不再更换嵌入模型。」

技术映射:撕菜单是TokenTextSplitter,抄到纸上是FileDocumentWriter,把味道一起封存是SimpleVectorStore.save。

小白又问短手册:「我们的 txt 不到一千字。默认 800 token 几乎肯定大于全文。那apply之后块数是 1,算切块器坏了吗?」

小胖说:「是 1 就再调小,调到块数变多为止。」

小白说:「只看块数会把一句条件切成两半。块数变多可以是成功,也可以是把『未发货』切丢了。要看内容,不能只看个数。」

大师把默认行为说清楚:「全文的 token 数不超过chunkSize时,切块器按实现返回一块,这不是故障。粗切实验就是要看见这个 1。细切必须把chunkSize降到小于全文,同时把minChunkSizeChars降到小于单条条款的字符数,标点收束才会发生。调参之后打印每块是否同时含有编号和条件。没有这种块,就继续加大一点 chunk,而不是无限减小。」

小白问完整性:「怎样叫条款完整?命中文本里要同时有编号和条件,对吗?细切如果把一句条件切成两半,编号在、条件不在,算命中还是算失败?」

小胖说:「有编号就行,条件我让模型补。」

小白反对:「模型补的条件就是编造。R-01 若只剩下标题,它会把已发货也说成可退。」

大师定义完整:「命中块的文本同时包含R-01和『未发货』,才算退款问题命中完整。只有编号、没有条件,这条切分失败,即使分数很高也不送去生成。对比实验用同一句『怎么退款』,分别在粗切库和细切库里取首条,把两段文本打印出来。粗切首条往往是整篇,里面既有 R-01 也有 S-01,完整但混杂。细切若参数合适,首条应含 R-01 和未发货,且不含 S-01。细切若过碎,首条缺条件,就记为失败的切法,而不是调低阈值去凑合。」

技术映射:一页上要同时有菜名和禁忌。只有菜名的那一页不能交给窗口念。

小胖问其他格式:「手册以后是 PDF 怎么办?本章要不要把 Tika 也接上?」

小白问:「PDF 模块和纯文本读出来的是不是同一种Document?切块器和写入器要不要换?」

大师把模块边界说清楚:「PDF、Markdown、Tika、Jsoup 各自在document-readers下。它们的产出仍是Document,后面的TokenTextSplitter和FileDocumentWriter可以复用。本章手册是 UTF-8 纯文本,只用TextReader。不要为了演示去引入 PDF 依赖。字符集不要用平台默认。TextReader.setCharset(StandardCharsets.UTF_8),否则中文在某些环境会变成问号,向量和条款编号一起坏掉。」

技术映射:不同封面的本子,拆开后都是同一张纸。本章只拆这一本 txt。纸上的字必须是 UTF-8,否则条款编号还在,条件句会变成问号。

小白问标记:「展示文件里的### Doc:要不要留在送去嵌入的文本里,方便以后对页码?」

大师说:「不要。标记是给人看的。嵌入用切出来的Document。人看的文件和机器用的向量分开存放。下一章的机器人如果读错了文件,检索词里会出现 Doc 和页码占位,条款编号反而不稳。」


3. 项目实战

3.1 环境准备

在src/main/resources/handbook/after-sale.txt写入四段,段与段之间空行。每段第一行是条款编号。依赖沿用spring-ai-commons里的 reader、splitter、writer,以及上一章的向量库和本地嵌入模型。不需要 DeepSeek。输出目录用target/handbook,不要写到源码树里。JSON 文件同样放在target,并加入忽略规则,避免向量文件被提交。

手册正文保持短而完整,例如 R-01 那一段写明「未发货订单可以申请退款」,不要把条件放到段尾很远的地方。这样细切才有机会把编号和条件留在同一块。四段都写成「编号单独一行,条件紧跟其后」。条件句里要有互斥的词:未发货、已发货、30 分钟、库存不足。缺了这些词,后面的完整性断言就只能看编号,编号相同的两刀切法会分不出来。

粗切和细切使用同一份读入结果,不要各自再读一遍文件。再读一遍只会掩盖字符集问题:第一次用 UTF-8,第二次用平台默认,两套块的汉字不一致,对比失去意义。读入断言先看文本包含「未发货」,再开始切。

3.2 分步实现

步骤 1:TextReader 读成一条文档

步骤目标:确认中文和来源元数据都在。

TextReaderreader=newTextReader("classpath:handbook/after-sale.txt");reader.setCharset(StandardCharsets.UTF_8);List<Document>docs=reader.get();

运行结果(文字描述):docs大小为 1。文本包含R-01和S-01。元数据source指向文件名,charset为UTF-8。

可能的坑:路径写成文件系统绝对路径,换机器就失败。用classpath:。另一个坑是文件不是 UTF-8,读出来条款编号还在、汉字消失。先在测试里断言文本包含「未发货」。

步骤 2:两种切块

步骤目标:看见粗切几乎不拆,细切按条款拆开。

TokenTextSplittercoarse=TokenTextSplitter.builder().withChunkSize(800).build();TokenTextSplitterfine=TokenTextSplitter.builder().withChunkSize(80).withMinChunkSizeChars(20).withMinChunkLengthToEmbed(5).build();List<Document>coarseDocs=coarse.apply(docs);List<Document>fineDocs=fine.apply(docs);

运行结果(文字描述):粗切的块数通常是 1,那一块同时包含 R-01 和 S-01。细切的块数大于 1。打印每一块的前 40 个字。至少有一块同时含R-01和「未发货」。若细切全部块都不含这两者的组合,说明 80 仍然把条件切开了,把 chunk 略调大,而不是把minChunkSizeChars再降到 0 去硬切在字中间。

可能的坑:只改chunkSize为 80,却留着默认minChunkSizeChars350。标点收束要求片段长于 350 个字符,短条款不会在句号处断开,结果和预期不符。细切必须把最小字符数降到小于条款长度。另一个坑是默认minChunkLengthToEmbed把很短的标题块丢掉,编号消失。本章把它降到 5,并检查编号还在某一块里。

步骤 3:写入文本文件

步骤目标:人可以打开文件核对切块。

newFile("target/handbook").mkdirs();FileDocumentWriterwriter=newFileDocumentWriter("target/handbook/chunks.txt",true,MetadataMode.NONE,false);writer.accept(fineDocs);

运行结果(文字描述):chunks.txt里能看到多段正文。withDocumentMarkers为 true 时,段首有### Doc:标记。标记方便人读,不要再把这个带标记的文件拿去嵌入,否则标记文字会进入向量。嵌入用的是切块后的Document列表,不是这个展示文件。

可能的坑:append为 true,重复跑测试,文件里有两轮切块。检索若误读这个文件,会命中旧条款。本章每次覆盖写,append为 false。

步骤 4:保存向量 JSON 并在新库中检索

步骤目标:加载后的首条命中完整条款,且不依赖重新下载模型来生成答案。

SimpleVectorStoresource=SimpleVectorStore.builder(embedding).build();source.add(fineDocs);Filejson=newFile("target/handbook/store.json");source.save(json);SimpleVectorStoreloaded=SimpleVectorStore.builder(embedding).build();loaded.load(json);List<Document>hits=loaded.similaritySearch(SearchRequest.builder().query("怎么退款").topK(1).similarityThreshold(0.4).build());

运行结果(文字描述):首条文本同时包含R-01和「未发货」,且不包含S-01。粗切库的首条则同时包含 R-01 和 S-01。对比说明:粗切完整但混杂,细切在参数合适时又完整又单一。阈值仍按上一章的办法,用本机分数校准,不要把 0.4 当成唯一正确答案。

可能的坑:load之后换了另一个TransformersEmbeddingModel配置再搜索。查询向量和新库里的旧向量不在同一空间,首条会乱。加载文件和查询必须用同一嵌入模型。另一个坑是把store.json提交进 Git,仓库变大,而且模型一换文件就作废。文件只留在target。

对比实验要并排打印。粗切首条的长度应接近整篇,里面能同时找到 R-01 和 S-01。细切首条应更短,含 R-01 和未发货,找不到 S-01。若细切首条仍含 S-01,说明没有切开,回到参数而不是去改断言。chunks.txt只给人眼核对,检索使用Document列表或store.json。人眼文件若被再次读入,### Doc:会进入向量,下一章的机器人不要走这条路。

测试全部放在无密钥的类里。读文件失败、编码失败、切块失败都不应该被@EnabledIfEnvironmentVariable跳过。跳过之后,手册丢了也会显示构建成功。DeepSeek 与这条流水线无关。

3.3 完整代码清单

handbook/after-sale.txt、HandbookEtl、测试。源码对照:

  • spring-ai-commons/.../reader/TextReader.java
  • spring-ai-commons/.../transformer/splitter/TokenTextSplitter.java
  • spring-ai-commons/.../writer/FileDocumentWriter.java

PDF 与 Markdown 读取器在document-readers下,本章不引入。

3.4 测试验证

@TestvoidfineChunkKeepsClauseAndCondition()throwsException{List<Document>fine=fineSplit(readHandbook());Stringtop=searchTop(fine,"怎么退款");assertThat(top).contains("R-01");assertThat(top).contains("未发货");assertThat(top).doesNotContain("S-01");}@TestvoidcoarseChunkMixesClauses()throwsException{List<Document>coarse=coarseSplit(readHandbook());assertThat(coarse).hasSize(1);assertThat(coarse.get(0).getText()).contains("R-01").contains("S-01");}
mvn -Dtest=HandbookEtlTest test

不需要密钥。预期细切命中完整且不含库存条款,粗切仍是混在一起的一整篇。若细切块数大于 1 但没有任何一块同时含有R-01和「未发货」,把这一次的块文本打印出来再调chunkSize。不要删掉完整性断言。粗切若已经变成多块,说明有人把粗切参数也改小了,对照实验就失效了,粗切应保持默认附近的 800。展示文件chunks.txt打开后应能看见细切的多段正文。若文件里只有一整篇,说明写入的是粗切结果,人眼核对会和检索用的列表不是同一套块。写入和检索都使用fineDocs。加载 JSON 之后再检索一次「怎么退款」,首条仍应满足同一组完整性断言。若load之后首条变了,先确认查询用的嵌入模型与save时是同一个实例配置,而不是新建时忘了MetadataMode.NONE。忘了模式时,元数据里的文件名会被嵌进向量,短条款的相似度会被路径左右,完整性断言即使通过,换一台机器路径变了分数也会变。所以展示文件和向量文件都放在target,换机器之后从 txt 重建,不拷贝旧的 JSON。


4. 项目总结

4.1 优点与缺点

对比项整篇一条 DocumentReader、Splitter、Writer
问句是否被稀释容易细切后减轻
条件是否还在在,但和别的条款在一起切坏了就会丢
人眼核对要读整篇chunks.txt 可打开
重启后的向量要重嵌load JSON 即可
换格式重写读取换 Reader,后面可复用

优点:

  1. 手册回到文件里,不再写死在 Java 字符串。
  2. 两种切块可以对照完整性和混杂程度。
  3. 文本文件供人核对,JSON 供机器加载。
  4. 纯文本读取不引入 PDF 依赖。
  5. 加载 JSON 后不必为了检索再嵌入全部手册。

缺点:

  1. 默认切块参数对短手册等于没切。
  2. 切得太碎会丢掉条件。
  3. JSON 和嵌入模型绑定,不能混用。
  4. FileDocumentWriter不保存向量,误以为它是索引会丢向量。
  5. 中文编码不对时,失败很安静,字变成问号仍能入库。

4.2 适用场景

适用:

  1. 一份 UTF-8 售后手册要入库检索。
  2. 需要比较切块大小对条款完整性的影响。
  3. 测试要在无密钥环境下跑通 ETL。
  4. 进程重启后希望从 JSON 恢复内存索引。
  5. 以后可能换成 PDF,但现在先稳定流水线。

不适用:

  1. 把带### Doc:标记的展示文件再次嵌入。标记会污染向量。
  2. 多实例只在一台机器上save,却指望别的机器自动看见。文件要随发布分发,或者改用共享库。

4.3 注意事项

  • 显式设置 UTF-8。
  • 细切时同时下调minChunkSizeChars。
  • store.json放在target,不要提交。
  • 命中文本必须同时包含编号和条件,才允许送去生成。

4.4 常见踩坑

  1. 细切后的命中只有「R-01」。根因是块太小,条件在下一块。处理:增大 chunk,直到编号和「未发货」落在同一块,再送去生成。
  2. 粗切和细切的块数一样。根因是没改minChunkSizeChars,短文没有在标点处断开。处理:把最小字符数降到条款长度以下,并打印块数。
  3. 加载 JSON 后检索结果完全不相关。根因是查询用的嵌入模型和保存时的不是同一个。处理:两端使用同一个TransformersEmbeddingModel配置,换模型就删除 JSON 重建。

4.5 思考题

  1. 展示用的chunks.txt带了### Doc:标记。若有人用TextReader把这个文件再读一遍并嵌入,检索会多出哪些不该有的词?应该嵌入哪一份数据?
  2. 细切命中了完整的 R-01,但用户问的是内部成本。切块和检索都成功了,还要不要把这块送进 DeepSeek?下一章的机器人应在哪一层拦住?

答案在第 15 章开头。

延伸阅读与资源

10倍开发者的 Dify 魔法书:从零构建全栈 AI 应用
后端工程师转型AI第一课-Ollama 与私有化大模型实战
大型语言模型(LLM) vLLM 高性能推理落地实战
Agent开发之LlamaIndex 实战修炼与源码进阶
大语言模型Transformers 实战修炼与源码剖析

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询