第十章 · 入藏流水线:Document 与 ETL
下一章第⼗章 · ⼊藏流⽔线:Document 与 ETL「我要么是⼀段⽂字,要么是⼀帧图像——但我从不同时是两者。请看我胸⼝那道异或符号。」 ——某个 Document 实例的⾃⽩藏经阁的⻔后,并不是⼀架架现成的典籍。
上⼀卷我们站在藏经阁的检索台前,看驿丞拿着检索条陈去调阅典籍。但典籍是怎么进阁的?⼀摞从天南海北运来的外来⽂书——有 Markdown 写的、有 PDF 印的、有 Word/PPT 混装的——它们形制各异、⻓短不⼀,不可能原样塞进书架。译馆后院有⼀条专⻔的⼊藏流⽔线:先有⼈把⽂书读进来(裁开包裹),再有⼈把⻓卷裁成合适的⻚册(切块),最后有⼈把⻚册编号归档、誊抄进藏经阁(写⼊向量库)。
这⼀章,我们就钻进这条流⽔线,看清楚两样东⻄:被搬运的典籍本身( Document ),以及搬运它的三道⼯序(ETL)。
⼀、典籍的真身:⼀个带"异或封印"的 Document先认⼈。整条流⽔线上流动的唯⼀货物,是 Document 。它住在 spring-ai-commons ⾥——注意,是 commons,不依赖 Spring Boot,能裸跑,这是"馆⼼"的⼀部分。
它的真身朴素得有点出乎意料:
private Document(String id, @Nullable String text, @Nullable Media media, Map<String, Object> metadata,
@Nullable Double score) {
Assert.hasText(id, "id cannot be null or empty");
Assert.notNull(metadata, "metadata cannot be null");
Assert.noNullElements(metadata.keySet(), "metadata cannot have null keys");
Assert.noNullElements(metadata.values(), "metadata cannot have null values");
Assert.isTrue(text != null ^ media != null, "exactly one of text or media must be specified");// ...
}spring-ai-commons/.../document/Document.java:218这个私有构造器是所有公开构造器和 Builder 的唯⼀收⼝。最值得盯着的是最后那⾏断⾔⾥的 ^ ——Java 的异或运算符。 text != null ^ media != null 翻成⼈话就是:text 和 media 必须恰好有⼀个⾮空,不能都给、也不能都不给。
这是⼀种刻意的"⾮此即彼"。⼀篇典籍要么承载⽂字,要么承载⼀帧图像,绝不脚踏两条船。框架甚⾄专⻔提供了 isText() 让你在运⾏时判断⼿⾥这本到底是哪种。为什么要这么决绝?因为下游的向量化、检索、拼接 prompt,每⼀步对"⽂本"和"媒体"的处理路径都不同;如果允许⼀个 Document 既有⽂⼜有图,下游每个环节都得写"如果有⽂怎样、如果有图⼜怎样"的分叉代码。⼀道异或断⾔,把这种分叉前移到了⼊⼝——进⻔时就分好流,后⾯都⼲净。
它的字段也就这么⼏样( Document.java:127 – 160 ):
id (final,不可变身份)
text / media (那对异或冤家,都 @Nullable )
metadata (⼀个 Map<String, Object> 的随身⾏李)score ( @Nullable Double ,检索期才赋值的相关度)
🦴 ⻣灰级细节:metadata 的"简单类型洁癖",和那个空的 contentFormatter。
metadata 这只⾏李箱有规矩。字段注释写得明明⽩⽩:
/**
* Metadata for the document. It should not be nested and values should be restricted
* to string, int, float, boolean for simple use with Vector Dbs.
*/
private final Map<String, Object> metadata;Document.java:139不许嵌套、值只该是 string/int/float/boolean。再配上构造器⾥那两道 noNullElements ——key 和 value 都不许为 null。这不是洁癖,是向后兼容向量库的硬约束。专业向量库(Pinecone、PGVector、Milcus…)对 metadata 的过滤能⼒⼤多只认扁平的标量字段;你要是塞个嵌套对象进去,到了某些库那⾥要么报错、要么被悄悄丢弃。Spring AI 在最上游的数据模型层就⽴下这条规矩,等于替你把"到了下游才炸"的雷提前拆了。
还有个容易被忽略的字段—— contentFormatter :
@JsonIgnore
private ContentFormatter contentFormatter = DEFAULT_CONTENT_FORMATTER;Document.java:165它身上挂着 @JsonIgnore 。也就是说,当⼀个 Document 被序列化成 JSON 存盘、再读回来时,序列化的只有 id/text/media/metadata/score 这五样"数据",⽽ contentFormatter 这种"渲染策略"不会跟着⾛——它是临时的、可变的,属于"怎么把这本书念给模型听"的⾏为,不属于书本身。Spring AI 在这⾥把数据和渲染
⾏为切得很⼲净。整个 Document 是 Jackson 全程可序列化的(类上挂着 @JsonDeserialize(builder = Document.Builder.class) ,Document.java:119 ),存进 SimpleVectorStore 的 JSON、跨进程传输、落盘再恢复,都能原样往返。
说回 score 。它平时是 null——你 new ⼀个 Document 时根本没有"相关度"这回事。只有当它经过藏经阁的相似度检索、被打上余弦分数后,score 才被填上(注释⾥点明它代表"相似度/重排分,越⾼越相关", Document.java:145 )。所以同⼀个类,在⼊藏时 score 为空、在调阅时 score 有值——它是⼀条贯穿写与读两端的扁担。这个设计省去了"检索结果"另⽴⼀个类的麻烦,但代价是你看到⼀个 Document 时,得⾃⼰⼼⾥清楚它现在处于哪⼀端。
⼆、三道⼯序,其实是三个 java.util.function 的化名认完货物,看流⽔线。Spring AI 的 ETL 只有三道⼯序,对应三个接⼝。但当你翻开它们的源码,会有⼀种"就这?"的错愕——因为它们短得⼏乎什么都没写:
public interface DocumentReader extends Supplier<List<Document>> {
default List<Document> read() {
return get();
}
}spring-ai-commons/.../document/DocumentReader.java:22
public interface DocumentTransformer extends Function<List<Document>, List<Document>> {
default List<Document> transform(List<Document> transform) {
return apply(transform);
}
}spring-ai-commons/.../document/DocumentTransformer.java:22
public interface DocumentWriter extends Consumer<List<Document>> {
default void write(List<Document> documents) {
accept(documents);
}
}spring-ai-commons/.../document/DocumentWriter.java:27看明⽩没有?
DocumentReader 就是 Supplier<List<Document>> ——⼀个"凭空供给"典籍的源头。 read() 不过是 get() 的化名。
DocumentTransformer 就是 Function<List<Document>, List<Document>> ——典籍进、典籍出的加⼯台。 transform() 是 apply() 的化名。
DocumentWriter 就是 Consumer<List<Document>> ——只吃不吐的终点。 write() 是 accept() 的化名。✨ 爽点时刻:整条⼊藏流⽔线,其实是 Supplier → Function → Consumer 这个 JDK 标准三件套穿上了译馆制服。 这意味着什么?意味着这三道⼯序天⽣可以⽤
lambda 临时充任,也天⽣可以⽤ Function::andThen 、 Supplier::get 这些标准组合⼦拼接。⼀条最朴素的⼊藏管线写出来就是⼀⾏:
vectorStore.write(splitter.apply(reader.read()));读、切、写,⼀⽓呵成。没有任何框架专属的"管线引擎"在背后调度,就是普通 Java 函数的嵌套调⽤。⽽向量库这⼀端能直接当 Consumer ⽤,是因为VectorStore 继承了 DocumentWriter ,它的 accept() 默认委托给 add() (上⼀卷我们在 VectorStore.java:53 ⻅过)—— add() ⾥才真正去调EmbeddingModel ⽣成向量。所以"写⼊"和"向量化"是同⼀个动作的两⾯。
那这三个接⼝为什么不直接让你⽤ Supplier / Function / Consumer ,⾮要再包⼀层取个化名?两点好处:⼀是语义—— reader.read() ⽐supplier.get() 读起来知道在⼲嘛;⼆是类型锚点——你可以 @Autowired List<DocumentReader> 把容器⾥所有 reader ⼀⽹打尽,⽽List<Supplier<List<Document>>> 这种类型 Spring 没法精确注⼊。化名是给⼈和给容器同时看的。
不过这⾥我得说句不那么吹捧的话:这套"函数式别名"虽然优雅,但它也意味着 ETL 这⼀层没有内建的错误处理、重试、断点续传、进度上报。你 reader.read()
读⼀个 500 ⻚的 PDF 读到⼀半 OOM 了,框架不会替你做任何事——它本来就只是个 Supplier.get() 。真要做⽣产级的⼤批量⼊库,这套极简抽象只是个起点,调度、容错得你⾃⼰在外⾯套。优雅是优雅,但别把它当成"开箱即⽤的 ETL 平台"。
三、"读"的时候顺⼿就切了:各家 Reader 的分块分歧流⽔线的第⼀道⼯序是 Reader。但这⾥藏着⼀个很多⼈踩过的认知坑:不同的 Reader,交出来的典籍颗粒度天差地别。
有的 Reader 在"读"的阶段就顺⼿把⽂书结构化分块了。⽐如 Markdown:
@Override
public void visit(Text text) {
if (text.getParent() instanceof Heading heading) {
this.currentDocumentBuilder.metadata("category", "header_%d".formatted(heading.getLevel()))
.metadata("title", text.getLiteral());
}
else {
this.currentParagraphs.add(text.getLiteral());
}
super.visit(text);
}document-readers/.../markdown/MarkdownDocumentReader.java:231
MarkdownDocumentReader ⽤ CommonMark 解析器游⾛ AST,每遇到⼀个 Heading 就 buildAndFlush() ( MarkdownDocumentReader.java:166 )——把上⼀段攒好的内容封成⼀个 Document、清空缓冲、开始下⼀段。它⼀边读⼀边切,⽽且边切边打标签:标题块写 category=header_N 和 title ,引⽤块写category=blockquote ( :204 ),围栏代码块写 category=code_block 还顺⼿记下 lang 语⾔( :223 )。读完,你拿到的是⼀堆已经按章节切好、且带语义标签的⼩ Document。
PDF 也是类似的"读即分块"。 PagePdfDocumentReader 按⻚分组(默认⼀⻚⼀个 Document),落地时写元数据:
protected Document toDocument(String docText, int startPageNumber, int endPageNumber) {
Document doc = new Document(docText);
doc.getMetadata().put(METADATA_START_PAGE_NUMBER, startPageNumber);
if (startPageNumber != endPageNumber) {
doc.getMetadata().put(METADATA_END_PAGE_NUMBER, endPageNumber);}
if (this.resourceFileName != null) {
doc.getMetadata().put(METADATA_FILE_NAME, this.resourceFileName);
}
return doc;
}document-readers/.../pdf/PagePdfDocumentReader.java:200那个 METADATA_START_PAGE_NUMBER 常量的真实值,是字符串 "page_number" ( PagePdfDocumentReader.java:52 )——所以检索回来后,你能从metadata ⾥读出"这段话来⾃第⼏⻚",这对溯源、对给⽤户展示"出处第 17 ⻚"极有⽤。
⚠ 暗礁:Tika 不切,它把整篇当⼀本书。
但不是所有 Reader 都这么贴⼼。 TikaDocumentReader ——那个万⾦油,能⾃动识别 PDF/DOC/PPT/HTML ⼏乎⼀切格式——它的 get() 是这样的:
@Override
public List<Document> get() {
try (InputStream stream = this.resource.getInputStream()) {
this.parser.parse(stream, this.handler, this.metadata, this.context);
return List.of(toDocument(this.handler.toString()));
}
catch (Exception e) {
throw new RuntimeException(e);
}
}document-readers/.../tika/TikaDocumentReader.java:144看清楚返回值: List.of(toDocument(...)) ——整篇⽂档,被塞进了⼀个 Document。⼀本三百⻚的 Word,Tika 给你的是⼀个巨型 Document,metadata ⾥只孤零零写了个 source ( TikaDocumentReader.java:160 )。
这就是坑所在。如果你天真地把 Tika 读出来的东⻄直接喂进向量库:
vectorStore.write(tikaReader.read()); //危险:⼀整本书变成⼀个向量你会得到⼀个把整本书压成单个 embedding 的"超级块"——检索时它要么因为太⻓被截断、要么因为语义太杂导致相似度永远不上不下,RAG 效果⼀塌糊涂。Tika这类"整篇⼀坨"的 Reader,必须在下游接⼀个 TextSplitter 再切:
vectorStore.write(splitter.apply(tikaReader.read())); //正确⽽ Markdown/PDF 因为读的时候就切好了,理论上可以不接 splitter——但实践中它们切出的块仍可能超⻓(⼀节正⽂⼏千字很常⻅),所以稳妥起⻅也常再过⼀道splitter。
这就是为什么前⾯那⾏优雅的 vectorStore.write(splitter.apply(reader.read())) ⾥, splitter 那⼀环不是可有可⽆的装饰——对 Tika ⽽⾔它是性命攸关的。框架没有强制你接 splitter,把这个判断留给了你。这是灵活,也是个容易翻⻋的灵活。
四、切块的⼿艺⼈:TextSplitter 与它的溯源印章中间那道"切"的⼯序,主⼒是 TextSplitter 。它是个抽象类,本身实现了 DocumentTransformer :
public abstract class TextSplitter implements DocumentTransformer {// ...
protected abstract List<String> splitText(String text);
}spring-ai-commons/.../transformer/splitter/TextSplitter.java:33 、 :133这是教科书级的模板⽅法模式:⽗类 TextSplitter 把"怎么把⼀批 Document 拆成更多 Document"的⻣架全写死了,留给⼦类的只有⼀个最⼩的空:
splitText(String) ——给你⼀段纯⽂本,你只管返回切好的⼏段字符串,⾄于元数据怎么继承、id 怎么⽣成、score 怎么传递,你⼀概不⽤操⼼。想⾃定义分块策略?继承它,实现这⼀个⽅法即可。
⻣架⾥最值得拍⼀下的,是它给每个⼦块盖的"溯源三连印":
enhancedMetadata.put("parent_document_id", originalId);
enhancedMetadata.put("chunk_index", chunkIndex);
enhancedMetadata.put("total_chunks", chunks.size());TextSplitter.java:111每切出⼀个⼦块,框架都会:先把⽗⽂档的 metadata 整盘继承过来( :104 – 109 那段流式拷⻉,顺⼿滤掉 null 键值),再追加三枚印章——parent_document_id (我是从哪本书切下来的)、 chunk_index (我是第⼏块)、 total_chunks (这本书⼀共切了⼏块)。
🦴 ⻣灰级细节:这三枚印章是 RAG 溯源与"邻块召回"的命根⼦。 想象检索命中了某本书的第 5 块,你想把它前后第 4、6 块也捞出来给模型更多上下⽂(这叫
small-to-big / 邻块扩展)——靠的就是 parent_document_id 锁定同源、 chunk_index 定位相邻。没有这三枚印章,切碎之后的块就成了⼀地⽆法拼回的碎⽚。Spring AI 在最底层的切块动作⾥就把溯源信息焊死,是相当⽼练的设计。顺带⼀提,⼦块还会继承⽗块的 contentFormatter (受 copyContentFormatter开关控制,默认 true, TextSplitter.java:121 ),保证"念书给模型听"的⻛格也⼀脉相承。