org.springframework.ai.document.Document
一、先建立总认识:Document 到底是什么,它又不是什么
在当前 Spring AI 中,org.springframework.ai.document.Document 的官方定位非常明确:它是一个文档容器(container),用于承载 内容(content)、元数据(metadata) 和 唯一 ID,并且作为 Spring AI ETL / 检索增强 / 向量存储 / 文档读取与转换流水线中的核心数据对象存在。官方类说明还特别强调了一点:一个 Document 只能承载“文本内容”或“媒体内容”中的一种,不能同时持有两者。 这不是使用建议,而是源码层面的硬性约束。
这句话的分量很大,因为它决定了你理解这个类时不能再用很多旧资料中的“Document 就是一段文本 + metadata”那种简化模型。在旧印象里,Document 似乎只是给 embedding 或 vector store 喂字符串;但在当前 API 里,它已经被设计成更一般化的内容载体。 文本型文档当然仍然是最常见场景,但它也可以持有 org.springframework.ai.content.Media,也就是说,一个文档对象的“主内容”不一定是 String,还可能是一个媒体资源。只是 Spring AI 又用约束保证了:文本与媒体只能二选一,从而避免“一个对象同时代表两种主语义”的混乱。
再进一步说,Document 的意义并不只在“存内容”,而在于它是整个 AI 文档管线里的统一交换模型。例如,DocumentReader 读入外部资源后产出的就是 List<Document>;DocumentTransformer 接收并返回的也是 List<Document>;DocumentWriter 和 VectorStore 消费的仍然是 List<Document>;一些 embedding 相关接口同样直接围绕 Document 运作。也就是说,Document 在 Spring AI 里不是零散的工具类,而是一个贯穿读取、切分、格式化、嵌入、存储、召回全过程的中心对象。如果这个类没理解透,后面很多接口你都会只知其形,不明其神。
与此同时,它也不再是“embedding 自身”的承载类。 这是一个非常容易踩坑的地方。旧版本资料里,Document 曾经带有 embedding 相关概念;但当前 API 中,Document 的核心状态已经转向 id / text or media / metadata / score / formatter 这一组对象语义,而 embedding 请求与模型调用被放到了更明确的接口里,例如 DocumentEmbeddingRequest、DocumentEmbeddingModel 等。你今天再把 Document 理解成“含向量的文档对象”,就会产生结构性误解。
所以,对 Document 最准确的心智模型应该是下面这三层:
- 基础载荷层:
id、text或media、metadata、score。 - 表现投影层:通过
ContentFormatter与MetadataMode,把文档转换成适合 prompt / embedding / 推理输入的文本表示。 - 流水线交换层:作为 Reader、Transformer、Embedding、Writer、VectorStore 之间传递的统一对象。
如果把它说得再直白一点:Document 不是“某段文本”的别名,而是“一个可被 AI 管线处理的文档单元”。 这个文档单元可能来自 PDF、HTML、Markdown、数据库记录、抓取网页、图片或其他媒体资源;它有身份(ID),有内容,有补充描述(metadata),还有在检索阶段常见的相关度/分数(score),并且能够在需要时被“格式化”为真正送入模型的字符串。理解到这里,后面你再看它的字段和方法,就会发现每个设计都很有逻辑。
二、类的内部结构与不变量:Document 里面究竟有什么
从当前源码来看,Document 的核心字段可以概括为:id、text、media、metadata、score、contentFormatter。其中 id 是 String,text 是可空的 String,media 是可空的 Media,metadata 是 Map<String, Object>,score 是可空的 Double,contentFormatter 则是一个可变的 ContentFormatter,默认值来自 DefaultContentFormatter.defaultConfig()。源码上还给类加了 @JsonIgnoreProperties({ "contentFormatter", "embedding" }),并把 contentFormatter 标记为 @JsonIgnore。这说明它一方面有序列化兼容性考虑,另一方面也明确把 formatter 视作一种运行期行为配置,而不是文档的核心持久化数据。
为了便于总览,可以把它理解成下面这张结构表。下表内容直接对应当前官方源码与 API 行为。
| 组成项 | 类型 | 语义 | 关键注意点 |
|---|---|---|---|
id |
String |
文档唯一标识 | 不能为空;可显式传入,也可自动生成 |
text |
String / nullable |
文本文档主内容 | 与 media 互斥 |
media |
Media / nullable |
媒体文档主内容 | 与 text 互斥 |
metadata |
Map<String,Object> |
附加语义信息 | 不能为空,key/value 不能为 null |
score |
Double / nullable |
相关度、排序或召回分值 | 常用于检索结果 |
contentFormatter |
ContentFormatter |
文档转文本表示的格式化策略 | 默认存在,但不是核心持久化字段 |
这里最重要的,不是记住字段名,而是抓住对象不变量(invariants)。当前 Document 的私有主构造逻辑里做了几项强校验:
第一,id 不能为空或空串;第二,metadata 不能为 null;第三,metadata 的 key 不能为 null;第四,metadata 的 value 也不能为 null;第五,text != null ^ media != null,即“恰好一个成立”。 这个异或约束是理解 Document 的核心:不是“至少一个”,也不是“可以都没有”,而是必须且只能有一个主内容来源。
为什么 Spring AI 要把约束做得这么硬?因为 Document 并不是一个随意拼装的 DTO,而是要进入统一流水线的对象。如果允许 text 和 media 同时存在,那么很多接口就必须回答‘到底以哪个为准’;如果允许两者都为空,那么一个没有实际内容的对象就会混入读取、切分、嵌入、存储阶段,语义会非常含混。 当前设计显然是为了让上游和下游都能建立稳定预期:一个 Document 必须明确代表一种主内容。
再看 metadata。官方 Javadoc 明确提示:虽然类型写成 Map<String,Object>,但为了向量数据库兼容性,metadata 值通常应限制在简单类型,例如字符串、整数、浮点、布尔等。这一点非常值得重视。也就是说,API 并没有在 Java 类型系统层面把值收窄到 String 或简单标量,但在真实生态里,你不应把复杂对象、任意嵌套结构都塞进去,否则很多下游存储或序列化链路可能不认。这是一个“签名宽、生态约束窄”的典型设计。
还要特别讲一个细节:getMetadata() 当前实现直接返回内部 metadata 映射本身。 这意味着,虽然构造时会用 new HashMap<>(metadata) 做一次拷贝,防止外部传入的 map 直接成为内部持有对象,但构造完成后,调用者拿到 getMetadata() 返回值,依然可以修改这个映射内容。换句话说,Document 并不是一个“深度不可变对象”;更准确地说,它是一个关键结构固定、但 metadata 与 formatter 在实现层面仍然具有可变性的对象。这一点在阅读源码前很容易被忽略。
score 的定位也不能一带而过。Builder 的官方说明已经说得比较具体:它常用于相似度分数(与距离相对)、检索排序、RAG 排名分值、以及其他自定义评估指标;并且“通常数值越高代表越相关”。注意这里是“通常”,不是数学公理,因为不同系统有的返回相似度,有的返回距离,有的返回标准化分数。所以 score 在 Document 里更像“检索阶段附着到文档上的排序语义”,而不是文档本体内容。
最后,还有一个非常高级、但又非常基础的理解:contentFormatter 字段揭示了 Document 不是只会“存内容”,它还知道“如何被投影为文本”。 也就是说,这个类本身内建了“面向模型输入表示”的能力入口。这正是为什么后面会有 getFormattedContent() 三种变体,以及 MetadataMode 这样的配套机制。很多人把它当普通实体类看,就会低估这个设计。实际上,Document 在 Spring AI 里是“内容实体 + 表示适配入口”的组合体。
三、构造器与 Builder:对象如何被正确创建
当前 Document 官方 API 暴露的构造器主要有五个:Document(String content)、Document(String text, Map<String,Object> metadata)、Document(String id, String text, Map<String,Object> metadata)、Document(Media media, Map<String,Object> metadata)、Document(String id, Media media, Map<String,Object> metadata)。从签名就能看出来,构造路径是围绕两条主线展开的:文本文档 与 媒体文档。如果你不自己提供 id,框架会为你生成;如果你自己提供 id,则直接按你提供的值建立文档。
其中最容易忽略的是最简单的单参构造器:Document(String content)。它在源码里参数名和 @JsonProperty("content") 的组合非常耐人寻味,因为当前内部字段已经叫 text 而不是 content,但它仍保留了 "content" 这个 JSON 属性名入口。这很可能是为了兼容历史序列化字段或生态中的旧命名习惯。也就是说,当前类的语义核心是 text / media 二选一,但在某些输入兼容层面,仍然照顾了旧世界里“content”这个称呼。
再看无 id 的文本/媒体构造器。当前源码中,这两条路径默认都调用 new RandomIdGenerator().generateId() 来生成 ID。换言之,如果你直接用构造器而不传 id,默认得到的是随机 ID。这意味着:同样的内容、同样的 metadata,在两次构造时得到的 id 默认不保证一致。这个特征本身不是好坏问题,但它直接影响你如何理解“文档身份”。如果你的场景需要稳定 ID / 幂等重建 / 可重复导入判定,就不能把“默认无参自动生成”误当成可重放方案。
不过,真正推荐深入理解的创建方式,其实不是这些重载构造器,而是 Document.builder() 返回的 Document.Builder。Builder 当前拥有的核心方法包括:idGenerator(IdGenerator)、id(String)、text(String)、media(Media)、metadata(Map<String,Object>)、metadata(String,Object)、score(Double)、build()。它的默认状态里带有 new HashMap<>() 作为 metadata 容器,并且默认 idGenerator 是 RandomIdGenerator。这说明 Builder 的设计目标并不是“只为了链式调用好看”,而是为了把文档构造过程中的关键决策点都显式暴露出来。
Builder 的一个极关键点在于 build() 的 ID 生成逻辑。源码并不是简单地“如果没 ID 就随机生成一个”,而是:当 id 不存在时,它会先取 text,如果 text 为 null 则用空串 "",再把“文本或空串 + metadata”作为内容参数传给当前配置的 idGenerator.generateId(text, metadata)。这里的默认 generator 仍然是 RandomIdGenerator,所以默认效果还是随机;但这段实现为自定义确定性 ID 生成器留下了接口位置。也就是说,Builder 不是只能“自动给个随机 ID”,而是支持你把“文档身份如何从内容推导出来”显式纳入构造策略。
下面这段代码,用来说明 Builder 视角下的创建语义最合适:
import org.springframework.ai.document.Document;
import org.springframework.ai.document.id.JdkSha256HexIdGenerator;
Document doc = Document.builder()
// 不手动写 id,而是指定“如何生成 id”
.idGenerator(new JdkSha256HexIdGenerator())
.text("Spring AI Document 的核心是统一文档载体。")
.metadata("source", "manual")
.metadata("chapter", 1)
.score(0.92)
.build();
这段代码的关键不在“链式调用很优雅”,而在于它把几个核心问题分离清楚了:
文档内容是什么、附带哪些元数据、是否有检索分数、文档身份如何确定。 当前 API 中 IdGenerator 是独立接口,官方 org.springframework.ai.document.id 包里至少包含 IdGenerator、RandomIdGenerator、JdkSha256HexIdGenerator 这些类型,因此你完全可以把 ID 生成理解为 Document 的一个可插拔策略点,而不是被动接受默认行为。(Home)
Builder 的校验语义也值得单独强调。id(String id) 会校验文本非空;idGenerator(IdGenerator) 要求非空;metadata(Map) 要求 map 本身非空;metadata(String key, Object value) 要求 key/value 都非空。而 text(@Nullable String) 与 media(@Nullable Media) 本身并不立即校验“二选一”,真正的互斥约束在最终 build() 进入 Document 私有构造逻辑时统一完成。这是一种很常见、也很合理的 Builder 设计:构建期间允许中间态,构建完成时才要求对象进入合法终态。
另外一个非常值得记住的入口是 mutate()。它并不是随便起的名字,而是一个“以当前对象为模板,返回新 Builder”的方法。源码中 mutate() 会把当前对象的 id、text、media、metadata、score 装入新的 Builder 中,供你继续修改后再 build()。这意味着 Document 提供了一种“保留原对象大部分状态,只变动局部字段”的官方路径。注意一个细节:mutate() 当前并不会把 contentFormatter 复制进 Builder。 这是源码级事实,不是推测。所以你若曾对某个对象调用 setContentFormatter(),随后再 mutate().build(),新对象未必保留那个 formatter 配置。这个细节非常容易被忽略。
你可以把 mutate() 理解成 toBuilder() 一类模式在 Spring AI 中的实现。示例:
Document refined = doc.mutate()
.metadata("section", "3.2")
.score(0.97)
.build();
这里表达的不是“修改原对象”,而是:以旧对象为基础,构造一个新对象版本。 从 API 设计角度看,这比直接暴露大量 setter 更利于保持对象构造逻辑集中。只是要牢记:metadata 目前依然是 Map,getMetadata() 也会返回内部映射,因此这个类并不是严格意义上的深不可变值对象。
四、核心方法逐个讲透:每个方法到底干什么
如果只从“方法名”去扫一眼 Document,你会觉得很简单:getter、格式化、Builder 入口而已。但真正深入时要问的是:每个方法背后代表的对象语义是什么? 当前官方 API 中,与日常理解最相关的方法主要包括:builder()、getId()、getText()、isText()、getMedia()、三种 getFormattedContent(...)、getMetadata()、getScore()、getContentFormatter()、setContentFormatter()、mutate(),以及 equals() / hashCode() / toString()。
先说 getId()。这不是“一个可有可无的附属字段访问器”,而是文档身份的读取入口。官方 Javadoc 明确指出:返回的文档 ID 可能是显式设置的,也可能是由配置的 IdGenerator 自动生成的,默认是 RandomIdGenerator。这意味着 getId() 读出的不是某个内部自增号,而是你在“文档身份策略”层面最终确认下来的结果。从业务上看,它更接近‘文档主键 / 文档指纹 / 文档标识’。
getText() 和 isText() 必须放在一起理解。当前源码里 getText() 只是返回 this.text,而 isText() 的实现也非常直接:return this.text != null;。也就是说,isText() 并不是做某种复杂类型识别,它就是在检查:这个文档当前是否走的是文本主内容分支。 因为类本身保证了 text 与 media 二选一,所以 isText() 为真时,就代表这是一个文本文档;反之则应去看 getMedia()。这个设计非常朴素,但恰恰因为朴素,它是稳定可靠的对象判别入口。
getMedia() 则是媒体分支的对应读取器。它返回 Media 对象,而不是字符串化内容。这里要特别提醒:Media 并不在 org.springframework.ai.document 包里,而在 org.springframework.ai.content.Media。官方 Javadoc 表明 Media 本身承载 MIME type、数据源(如 URI/Resource)、可选的 id/name 等信息。因此,当 Document 持有 media 时,它表达的不是“另一种字符串内容”,而是另一类主载荷对象。理解这一点后,你就知道为什么 Document 必须强制 text/media 互斥:两者在后续处理链路里的意义完全不同。
接下来进入这个类最有“灵魂”的方法群:getFormattedContent()。它有三个变体:
getFormattedContent()getFormattedContent(MetadataMode metadataMode)getFormattedContent(ContentFormatter formatter, MetadataMode metadataMode)
当前源码中,第一种等价于第二种传入MetadataMode.ALL;第二种会用当前对象持有的contentFormatter去格式化;第三种则允许调用者临时指定 formatter 与 metadataMode。这个设计非常值得细品,因为它说明Document并不把“内容”与“送入模型时看到的文本表示”混为一谈。同一个文档,可以在不同 metadata 模式、不同 formatter 下,投影成不同的字符串。
这三种重载的区别,可以用一句话概括:
第一种:最省事,但默认包含 ALL metadata;第二种:控制 metadata 参与方式;第三种:连格式化策略也一起接管。
这也是为什么在真正的 embedding / inference 场景里,不能只把 getFormattedContent() 当成“拿文本”的同义词。它拿到的是**“文档的某种表示形式”**,而不一定是裸文本。这个“表示”到底长什么样,后面由 DefaultContentFormatter 与 MetadataMode 决定。
getMetadata() 在概念上是“返回附加元数据”,但在实现语义上,它还有两个必须记住的细节:
第一,Javadoc 强调 metadata 值最好是简单类型,以适配向量数据库;第二,当前实现直接返回内部 map,因此你拿到后对它 put(),对象内部状态就真的变了。很多开发者看到 final Map<String,Object> metadata 容易误以为“不可变”,其实 final 只表示引用不变,不表示内容不可改。如果你不分清‘引用稳定’与‘容器内容可变’,就会对对象行为产生误判。
getScore() 则更像检索阶段的“结果属性读取器”。它不是文档原生固有内容,而往往是文档在某个查询、某次排序、某种 rerank 过程下被附着上的值。Builder 文档提到的常见语义包括 similarity score、retrieval ranking、RAG metrics 等,并指出一般来说分数越高越相关。但与此同时,Spring AI 还提供了 DocumentMetadata.DISTANCE 这样的元数据键,其语义恰恰是“距离”,即越低越相似。因此,你在理解 score 时要区分它和 metadata 中可能存在的距离值,不要把二者机械等同。
getContentFormatter() 和 setContentFormatter() 是很多人会低估的方法。它们的意义不在“多了一个 setter”,而在于 Document 把“如何将自己格式化成文本表示”的策略绑定到了对象身上。源码中 contentFormatter 是一个可变字段,并且默认值是 DEFAULT_CONTENT_FORMATTER。这说明 Spring AI 允许你把某个文档对象临时配置成以不同的文本组织方式被读取。换句话说,同一个 Document 不仅可持有内容,还可持有一份‘我该如何被解释成文本’的策略。
mutate() 在上一节已经提到,这里再从方法语义上强调一次:它不是“原地修改”,而是“基于当前状态返回 Builder”。这类 API 特别适合“保留绝大多数字段,只调整一两个属性”的场景。不过因为它当前不会拷贝 formatter,所以如果你的逻辑对 formatter 有定制,mutate() 之后要主动重新设置。这个结论不是经验判断,而是直接来自源码实现。
最后是 equals() / hashCode() / toString()。当前源码里 equals 与 hashCode 比较/计算的是 id、text、media、metadata、score 这一整组字段,而不是只看 id。这点非常重要。因为在很多业务系统里,开发者默认“同 ID 就代表同对象”;但 Spring AI 当前 Document 的相等性定义并不是这么窄。两个文档即便 ID 相同,如果 text 或 metadata 或 score 不同,equals() 也不会判为相等。 这意味着它的“Java 对象相等语义”更接近“结构相等”,而不只是“标识相等”。
五、真正理解 Document 的关键:ContentFormatter、DefaultContentFormatter、MetadataMode
如果说前几节解决的是“这个对象长什么样、怎么建出来、有哪些方法”,那么这一节要解决的是:为什么 Document 在 Spring AI 里不是一个普通 POJO。 答案就在 ContentFormatter 和 MetadataMode。官方 API 对 ContentFormatter 的定义是:它负责把 Document 的文本与元数据转换成适合 AI 模型输入的表示;接口方法就是 String format(Document document, MetadataMode mode)。这表明 Document 与模型输入之间,官方有意识地插入了一个“内容投影层”。你不是只能拿原始 text,还可以根据不同场景,得到不同形式的字符串表示。
官方提供的默认实现是 DefaultContentFormatter。它有自己的 Builder,并提供 defaultConfig() 静态工厂。Javadoc 与源码共同表明,它内部至少包含三类模板概念:
metadataTemplate,默认是"{key}: {value}";metadataSeparator,默认是System.lineSeparator();textTemplate,默认是"{metadata_string}\n\n{content}"。
也就是说,默认情况下,一个文档被格式化时,会先把 metadata 渲染成若干行“键: 值”,再用两个换行与正文拼接起来。这不是抽象概念,而是默认实现的具体文本结构。 (Home)
更重要的是,DefaultContentFormatter 不是无脑拼接全部 metadata。它的 format(Document, MetadataMode) 会先根据 MetadataMode 做一次 metadata 过滤,再把过滤后的结果格式化为字符串。当前源码中,MetadataMode 有四个枚举值:ALL、EMBED、INFERENCE、NONE。而默认 formatter 的过滤逻辑是:
ALL:保留全部 metadata;NONE:完全不带 metadata;INFERENCE:从全部 metadata 中排除excludedInferenceMetadataKeys;EMBED:从全部 metadata 中排除excludedEmbedMetadataKeys。
这意味着MetadataMode并不是“抽象标签”,而是直接决定哪些 metadata 参与生成最终文本。
这一设计的高明之处在于:同一个文档,在“做 embedding”与“做推理上下文拼接”时,不一定应该带同样的 metadata。 某些 metadata 对检索很有帮助,例如标题、章节、来源、标签;另一些 metadata 则可能在推理阶段更重要,或反而会污染 embedding 语义。Spring AI 通过 MetadataMode.EMBED 与 MetadataMode.INFERENCE 把这两个场景显式区分开了,而不是把“带 metadata 与否”粗暴二分。这使得 Document 的文本表示,从“静态字符串”升级为“依场景切换的表示策略”。
再看 DefaultContentFormatter.Builder。官方 API 表示它至少支持:withMetadataTemplate(...)、withMetadataSeparator(...)、withTextTemplate(...)、withExcludedInferenceMetadataKeys(...)、withExcludedEmbedMetadataKeys(...),并且还有 from(DefaultContentFormatter) 这样从现有配置出发再改造的入口。这个 Builder 的价值不在“可配置选项多”,而在于它为你提供了一个非常清晰的控制面:
你可以决定 metadata 的渲染样式、metadata 段内的分隔方式、正文与 metadata 的总体模板关系,以及在 embedding / inference 两个场景下分别排除哪些键。
一个很直观的示例是:
import org.springframework.ai.document.DefaultContentFormatter;
import org.springframework.ai.document.MetadataMode;
DefaultContentFormatter formatter = DefaultContentFormatter.builder()
.withMetadataTemplate("[{key} = {value}]")
.withTextTemplate("META:\n{metadata_string}\n\nTEXT:\n{content}")
.withExcludedEmbedMetadataKeys("distance", "debugInfo")
.build();
String embeddingText = doc.getFormattedContent(formatter, MetadataMode.EMBED);
String inferenceText = doc.getFormattedContent(formatter, MetadataMode.INFERENCE);
这段代码服务于一个核心理解:Document 的“给模型看的内容”不是固定的。 在 EMBED 模式下,distance、debugInfo 这类 metadata 可以被剔除;而在 INFERENCE 模式下,是否保留这些项则由另一组排除规则决定。这样,Document 就不是简单的“文档实体”,而是一个可根据场景投影出不同文本表征的对象。
这里必须特别提醒一个非常容易忽略、但很重要的细节:如果一个 Document 是 media-only 文档,那么 DefaultContentFormatter 在格式化正文时,源码会取 document.getText() != null ? document.getText() : ""。 也就是说,默认 formatter 并不会自动把媒体内容“转写成文本”;对 media-only 文档而言,默认正文部分就是空串,只剩 metadata 段可能被格式化出来。这个事实非常重要,因为它告诉你:媒体型 Document 的“可嵌入文本表示”并不是默认自动具备的。 如果你的场景真的要让媒体文档变成模型可读文本,就必须依赖别的步骤,而不是幻想 DefaultContentFormatter 会自动替你完成。(GitHub)
还可以把 DocumentMetadata 放到这节一并理解。官方当前把它定义为“Documents 中常用的一组 metadata 键”,其枚举中明确可见的常量是 DISTANCE,表示文档 embedding 与查询向量之间的距离度量,且说明“值越低越相似”,是 similarity score 的对立概念。这个类型的存在说明 Spring AI 也意识到:虽然 metadata 表面上是自由 map,但某些键会在生态中形成半标准语义。DISTANCE 正是“召回后文档附带距离信息”的规范化表示。
六、Document 在 Spring AI 整体体系中的位置:上游读入、下游转换、嵌入与存储
只讲 Document 自身还不够,因为很多方法为什么这样设计,必须放回 Spring AI 的整体结构里才真正说得通。官方 API 中,与 Document 最直接相关的几个接口/角色包括:
DocumentReader:读外部资源,产出List<Document>;DocumentTransformer:把一批文档转换成另一批文档;DocumentWriter:消费List<Document>;VectorStore:作为DocumentWriter的一种重要实现,负责向量存储与检索;DocumentEmbeddingRequest/DocumentEmbeddingModel:以文档为输入发起 embedding 计算。
用一个简化流程图来看,会更容易建立整体感:
外部资源(Resource / PDF / HTML / JSON / Markdown / 文本 / 其他)
│
▼
DocumentReader
│
▼
List<Document>
│
├── DocumentTransformer(切分 / 格式整理 / 元数据增强)
│
▼
Embedding / VectorStore / Writer
│
▼
检索结果(通常仍然是 Document 列表,可能带 score / distance 等)
这个流程不是我主观总结出来的“经验图”,而是与官方接口结构高度一致:DocumentReader 继承 Supplier<List<Document>>,DocumentTransformer 继承 Function<List<Document>, List<Document>>,DocumentWriter 继承 Consumer<List<Document>>,VectorStore 又进一步建立在文档写入/检索之上。你会发现,整个文档处理管线的“通用货币”就是 Document。
DocumentReader 一侧,官方包里能看到的实现就有很多,例如 TextReader、JsonReader、JsoupDocumentReader、MarkdownDocumentReader、PagePdfDocumentReader、ParagraphPdfDocumentReader、TikaDocumentReader 等。这些类型的差异在于它们读取的是不同外部格式,但它们的共同点非常重要:不管来源是 PDF、HTML、Markdown 还是普通文本,最终都要落入 Document 这一统一表示。 这正是 Document 需要同时拥有内容、metadata、ID,甚至格式化能力的根本原因。
DocumentTransformer 一侧,官方可见实现包括 ContentFormatTransformer、KeywordMetadataEnricher、SummaryMetadataEnricher、TextSplitter、TokenTextSplitter 等。这里尤其要注意 TextSplitter:它本身就是一个 DocumentTransformer,也就是说“切分文档”在 Spring AI 里不是额外的特殊流程,而是标准的文档变换操作。你从这个角度再看 Document 的 Builder、mutate()、formatter、metadata,就会理解为什么这些东西都需要在一个对象里协作:文档在切分后往往仍需保留来源 metadata、可能衍生新 metadata、还要继续进入 embedding 与存储阶段。
embedding 侧同样离不开 Document。官方 API 存在 DocumentEmbeddingRequest,它的构造方式就是围绕 Document... 或 List<Document>;DocumentEmbeddingModel 的 call() 也直接接收 DocumentEmbeddingRequest。更值得注意的是,EmbeddingModel 文档中还专门提到:默认的 getEmbeddingContent(Document) 会返回 Document.getText(),而支持 MetadataMode 的实现应当覆写为返回 Document.getFormattedContent(MetadataMode)。这个说明非常关键,因为它把“文档格式化系统”与“真实送给 embedding 模型的内容”直接连接起来了。换言之,Document 的 formatter 设计不是装饰性 API,而是与 embedding 输入内容密切相关。
在批量写入向量库时,这种关系更明显。Spring AI 文档对 BatchingStrategy 的描述说明,批处理是围绕 List<Document> 展开的;默认 TokenCountBatchingStrategy 还会结合 Document.DEFAULT_CONTENT_FORMATTER 和 MetadataMode.NONE 来估算与处理批次内容。这个事实再一次说明:Spring AI 不把“文档本体”与“送去嵌入或估 token 的文本表示”割裂开,而是通过 Document 自身的默认 formatter 体系来组织。 所以 Document 不是“离 embedding 很远的纯领域对象”,反而处在 embedding 前夜的关键位置。
检索返回阶段也同样会回到 Document。当相似性检索完成后,结果通常仍以文档对象形式出现,只是此时你会经常看到 score 或 DocumentMetadata.DISTANCE 之类信息附着到文档上。于是 Document 在这个阶段又从“待处理文档”转成了“检索命中文档”。这也是为什么 score 会进入类本身,而不是完全散落在外部响应对象里:Spring AI 需要一个统一对象,同时表达‘文档内容’与‘文档在当前检索上下文中的排序语义’。
七、与旧版本/旧资料对照:最容易产生误解的地方
如果你在网上查 Spring AI 的 Document,非常容易看到一些旧资料还在讲 getContent()、getEmbedding(),甚至把 Document 直接说成“带向量的文档”。这并不是完全胡说,而是版本演进造成的知识错位。官方旧版本 API(如 0.8.1)中的 Document 确实曾经更接近“唯一 ID + content + optional embedding”的模型;而当前 official current API 已经明显演进为 id + text/media + metadata + score + formatter 这一套设计。也就是说,今天如果你把旧版心智模型直接套到 current API 上,几乎必然会误读这个类。
第一个常见误解是:把 getText() 等同于“这个 Document 的全部可用内容”。 这在文本型文档上大体成立,但在当前设计里并不总对。因为 Document 可能是 media-only;而且即使是文本型文档,真正送给模型的内容也可能来自 getFormattedContent(...),其中还会包含某些 metadata。所以 getText() 是“原始文本主内容读取器”,不是“最终模型输入读取器”。 这两个层次必须分开。
第二个误解是:以为 getFormattedContent() 只是 getText() 的别名。 当前源码已经很清楚地否定了这一点:无参 getFormattedContent() 默认走 MetadataMode.ALL,然后调用当前 contentFormatter 来拼装输出。所以一个带 metadata 的文本文档,getFormattedContent() 产生的字符串,默认就可能不等于原始 text;而 media-only 文档在默认 formatter 下,正文部分甚至可能是空串。这个方法拿到的是“格式化后表示”,不是“裸字段值”。
第三个误解是:认为 Document 是严格不可变对象。 这也不准确。虽然它的大部分核心字段没有常规 setter,且构造时会做合法性校验,但当前实现中至少有两处可变性很明显:
setContentFormatter()可以直接更换 formatter;getMetadata()返回内部 map,本身可被修改。
因此更准确的说法是:Document在“身份与主内容结构”上受构造约束,但在 metadata 内容与 formatter 上并非彻底不可变。 这一认识对于调试行为、理解对象共享、副作用分析都很重要。
第四个误解是:认为对象相等只看 id。 当前实现并非如此。equals() / hashCode() 比较的是 id、text、media、metadata、score 整体。结果就是:两个文档即便有同一个 id,只要 metadata 或 score 有差异,也不是相等对象。这一点在把 Document 用作 map key、集合元素、缓存项时都可能产生影响。Spring AI 当前把它视作一个“结构状态对象”,而不是只按主键判断的轻量引用。
第五个误解是:Builder 自动生成的 ID 一定和内容相关且稳定。 这句话只说对了一半。Builder 的 build() 确实会把 “text 或空串 + metadata” 交给 idGenerator,但默认 idGenerator 是 RandomIdGenerator,而 RandomIdGenerator 本身是随机 UUID 风格生成器。因此,默认情况下,它具备“自动生成”但不具备“稳定重现”。只有你明确提供 id(String),或主动改用确定性的 IdGenerator(比如 SHA-256 风格),文档 ID 才可能与内容形成稳定对应关系。
第六个误解是:mutate() 会把原对象的一切状态都完整迁移过去。 当前源码表明,它复制的是 id、text、media、metadata、score,但并没有复制 contentFormatter。这意味着 mutate().build() 更像是“基于文档核心数据重建一个新对象”,而不是“完整克隆现有实例”。这个细节在普通阅读 API 列表时几乎看不出来,必须读源码才知道。
最后一个我认为最值得强调的误区是:把 Document 只当成一个承上启下的数据壳,而忽略它与 MetadataMode / ContentFormatter / embedding 内容选择之间的内在耦合。 实际上,Spring AI 当前围绕 Document 的设计,已经把“文档本体”与“文档如何被模型看见”结合在一起了。你若忽略这一层,就只能理解到 60%;只有把 formatter 与 metadata mode 也纳入 Document 的知识体系,才算真正建立完整认知。
八、一个可操作的“完整理解框架”:以后再看这个类,应该按什么顺序思考
到这里,可以把 org.springframework.ai.document.Document 的理解收束成一个非常稳固的框架。这个框架不是背 API,而是以后你看到任何相关代码时,都能迅速判断它在做什么。
第一步:先判断它是哪种文档。
看 isText()、getText()、getMedia()。当前 Document 的根语义是“文本文档”或“媒体文档”二选一;这一步决定你后面对内容处理的思路。文本文档通常可直接参与文本格式化与 embedding;媒体文档则要警惕默认 formatter 并不会替你自动转文本。
第二步:再判断它的身份与上下文附着信息。
看 getId()、getMetadata()、getScore()。id 告诉你“它是谁”;metadata 告诉你“它从哪来、属于谁、有哪些补充属性”;score 则告诉你“它在当前排序/检索上下文里被怎么看待”。这三者合起来,才是一个文档对象在流水线中的完整上下文。
第三步:不要急着把 getText() 当最终输入,而是问‘当前要做什么’。
如果你只是要查看原始文本,getText() 足够;但如果你要做 embedding 或组织推理上下文,就要看 getFormattedContent(...),并明确 metadata 是否应该参与,以及应该走 ALL、EMBED、INFERENCE 还是 NONE。这一步决定的是“模型最终看到的文本表示”,而不是“对象里原始存了什么”。
第四步:创建对象时优先考虑 Builder 心智,而不是只盯着重载构造器。
构造器适合快速建立简单对象;Builder 更适合清晰表达:内容是什么、metadata 是什么、score 是多少、ID 是显式给定还是由哪个生成器算出。尤其当你需要稳定 ID、增量重建、可重复导入判定时,Builder + IdGenerator 才是真正应该思考的层次。
第五步:把它放回 Spring AI 的大图景。DocumentReader 产出它,DocumentTransformer 改造它,DocumentEmbeddingRequest 包装它,VectorStore 消费它,检索结果又返回它。只要你记住:Document 是 Spring AI 文档流水线的统一交换对象,你就不会把它误当成某个孤立的“工具类”。
结语:一句话归纳 Document
org.springframework.ai.document.Document 的本质,是 Spring AI 用来统一表示“可被 AI 管线处理的文档单元”的核心类。
它不只是保存文本;它可以承载文本或媒体;它不只是存元数据;它还能根据 ContentFormatter 与 MetadataMode 投影出面向模型的文本表示;它不只是上下游传参对象;它还是 Reader、Transformer、Embedding、VectorStore 共同依赖的统一中枢。当前版本里,真正要抓住的不是某一个 getter,而是这套完整结构:内容承载、身份标识、元数据语义、分数附着、格式化表示、管线交换。
你如果愿意,我下一条可以继续直接写成你要的风格:“按方法签名逐项展开的 API 参考手册版”,把 Document 的每个构造器、每个方法都单独拆成 定义、参数、返回值、源码行为、注意事项、典型误区 六栏来讲。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)