org.springframework.ai.document.DefaultContentFormatter
org.springframework.ai.document.DefaultContentFormatter
DefaultContentFormatter 的官方定位非常明确:它是 ContentFormatter 的默认实现,负责把 Document 的文本内容与元数据转换为适合 AI / Prompt 消费的字符串表示。
一、它到底是什么:类的定位、存在意义、接口契约与设计目标
从 Spring AI 的设计看,DefaultContentFormatter 并不是一个“附属小工具”,它其实是 Document 进入模型世界之前非常关键的一层文本化适配器。Spring AI 的 ETL 参考文档说明,RAG/ETL 流程围绕 DocumentReader、DocumentTransformer、DocumentWriter 展开,而 Document 是这个流程中的核心承载对象;与此同时,ContentFormatter 接口专门被定义为“把 Document 的文本和元数据转换为 AI 友好的文本表示”的抽象。DefaultContentFormatter 就是这个抽象的默认实现。换句话说,它解决的不是“文档怎么存”,而是“文档最终以什么字符串面貌进入 Prompt、进入 Embedding、进入调试输出”。
这件事为什么重要?因为在 LLM 或向量化场景里,模型最终真正“看到”的,经常不是对象本身,而是对象被序列化后的文本。你在 Java 里持有一个 Document,其中有 text、metadata、甚至 media;但模型 API 接收的,往往只是字符串或字符串数组。Spring AI 因此把“如何把 Document 展平为字符串”独立成了 ContentFormatter。这意味着:是否携带 metadata、metadata 以什么样子插入正文前、哪些字段只在推理时保留、哪些字段只在 embedding 时保留,全部都会影响模型输入的语义与 token 体积。这不是 UI 层格式问题,而是模型输入语义的一部分。EmbeddingModel 的 Javadoc 甚至明确写到:默认 getEmbeddingContent(Document) 返回 Document.getText();而支持 MetadataMode 的实现,应当改为调用 Document.getFormattedContent(metadataMode),这样 metadata 才会一并进入 embedding 文本。TokenCountBatchingStrategy 也直接提供了带 ContentFormatter 和 MetadataMode 的构造器,说明格式器不只决定“文本长什么样”,还会反向影响分批策略与 token 预算。
再看它在 Document 中的位置,就更能理解其“基础设施”属性。Document 源码里有一个 public static final ContentFormatter DEFAULT_CONTENT_FORMATTER = DefaultContentFormatter.defaultConfig();,并且每个 Document 实例的 contentFormatter 字段默认就指向这个静态默认格式器。Document.getFormattedContent()、Document.getFormattedContent(MetadataMode)、Document.getFormattedContent(ContentFormatter, MetadataMode) 最终都会走到格式器的 format(...)。这说明 Spring AI 的默认假设不是“文档只要 getText() 就够了”,而是文档天生带有一个可替换的内容格式化策略。你可以把 DefaultContentFormatter 看成 Document 的“默认文本投影视图”。
接口层面上,ContentFormatter 非常克制,只定义了一个方法:String format(Document document, MetadataMode mode)。这使得 DefaultContentFormatter 的价值不在“接口多复杂”,而在“一个很窄的接口背后,到底约定了怎样的文本拼装规则”。Spring 之所以把接口做得这么小,是因为它希望你可以自由替换实现;但它又提供 DefaultContentFormatter,用一套“模板 + 元数据过滤”的方案,给出一个足够通用的标准答案。这个标准答案包括:默认 metadata 模板、默认 metadata 分隔符、默认文本模板、推理/向量化两套独立的 metadata 排除清单。公开 API 只暴露少量 getter 和 Builder,但背后其实已经把格式规则、metadata 粒度控制、Prompt 前置上下文拼装都内化了。
还要特别注意一点:Document 在 1.1.x 中已经不是“永远有文本”的对象。官方 Document Javadoc 与源码都写明:Document 可以持有 text 或 media,但不能同时持有两者。这意味着 DefaultContentFormatter 的存在不仅是“给文本加 metadata”,还隐含承担了把非纯文本 Document 统一转换到字符串入口的职责判断。虽然在 1.1.4 发布版源码里,format() 仍直接把 document.getText() 塞进模板;但 main 分支已经把这里改成了 document.getText() != null ? document.getText() : "" 的空串兜底。再结合 Spring AI GitHub 上 media-only Document 相关空指针问题的官方 issue,可以看出:当 Document 模型从“文本为主”走向“文本/多模态并存”时,DefaultContentFormatter 的边界条件也随之变得更重要。
所以,理解 DefaultContentFormatter,最核心的心智模型是这句话:
它不是“把字符串打印一下”的类,而是 Spring AI 在
Document → 模型输入文本这条链路上的默认编排器。
只要你理解了这一点,后面它的字段、模板、MetadataMode、Builder 设计乃至一些看似细小的实现差异,就都能串成一个整体。
二、类结构、默认值、占位符体系与“格式化模型”全景拆解
先看类的公开面。官方 Javadoc 表明,DefaultContentFormatter 是 final 类,实现了 ContentFormatter 接口,公开方法很少:builder()、defaultConfig()、format(...),以及若干 getter;它还有一个 public static final 的内部 Builder。这意味着它的扩展方式不是继承,而是配置:Spring 官方希望你通过 Builder 定制行为,而不是通过继承覆写内部流程。这个决策很符合 Spring AI 的抽象风格:外部只暴露有限而稳定的契约,内部格式化逻辑则保持简单直接。
从源码看,这个类的内部“格式化模型”完全建立在四个占位符和三个默认模板值之上。四个占位符分别是:{content}、{metadata_string}、{key}、{value}。默认 metadata 模板是 "{key}: {value}";默认 metadata 分隔符是 System.lineSeparator();默认 text 模板是 "{metadata_string}\n\n{content}"。这几行常量基本就定义了整个类的世界观:先把 metadata 的每个条目变成字符串,再用分隔符拼成一个大的 metadata_string,最后把这个 metadata 字符串和正文 content 一起填进文本模板中。换言之,DefaultContentFormatter 的本质不是通用模板引擎,而是“两级替换”:第一层把单个 metadata entry 映射为文本;第二层把“整个 metadata 块”和正文拼进最终输出。
这套模型非常简单,却足够表达大部分 RAG 输入样式。比如,默认情况下,一个文档会被格式成下面这种结构(示意):
source: user-manual
page: 12
这里是真正的正文内容……
这不是 YAML,也不是 JSON,而是面向模型阅读的轻量文本格式。其优点是直接、token 成本通常低于结构化标记、对大多数 LLM 足够友好;其缺点也同样明显:没有层级、没有强约束、无法自动转义、metadata 的顺序与稳定性并不被保证。源码里并没有对 metadata 做排序,也没有使用专门的模板库或 JSON 序列化器,而是直接 stream() + replace() + joining()。所以你应该把它理解为一种轻量、可控、但并不强结构化的字符串投影方式。
这里有一个非常容易被忽略、但在源码层面很关键的点:metadata 顺序并没有稳定契约。为什么?因为 Document 在构造时把传入的 metadata 复制进 new HashMap<>(metadata);而 DefaultContentFormatter 又是直接对 metadata.entrySet().stream() 做处理,没有排序。HashMap 的遍历顺序在 Java 语义上不应被依赖,因此你不能把 DefaultContentFormatter 默认输出当成“字段顺序确定”的序列化结果。很多人写示例时会默认 source 在前、page 在后,但从实现上说,这不应成为依赖。如果你的 prompt 非常依赖 metadata 的出现顺序,那么默认实现并没有对此提供保证。
再看字段设计。它有五个核心实例字段:metadataTemplate、metadataSeparator、textTemplate、excludedInferenceMetadataKeys、excludedEmbedMetadataKeys。前三个负责“长什么样”,后两个负责“哪些 metadata 出现在什么场景里”。这说明类的设计不是单纯的字符串模板配置,而是把“样式控制”和“语义裁剪”放在了一个对象里。对于 AI 场景,这很合理:你不仅要决定“怎么拼”,还要决定“拼哪些”。比如某些 metadata 对回答很有帮助,但会污染 embedding;另一些 metadata 适合作为 embedding 的上下文标签,却不适合直接暴露给推理模型。Spring AI 用 INFERENCE 和 EMBED 两套排除列表,把这件事直接内建进默认格式器。
从“模板能力”角度说,DefaultContentFormatter 其实非常朴素:它不是 StringTemplate、不是 Mustache、不是 SpEL、不是占位符解析器,只是连续调用 String.replace(...)。这带来几个直接后果。第一,它只认识那几个硬编码占位符;第二,没有条件判断、没有循环、没有转义语法;第三,模板不会验证你是否真的写了必须占位符。也就是说,withTextTemplate("正文如下:{content}") 完全合法,此时 metadata 即便存在也会被静默丢弃;withMetadataTemplate("[{key}]") 也完全合法,此时 value 会被静默丢弃。Spring 只校验模板字符串“非空”,并不校验其“语义完整”。这让它足够灵活,但也意味着它把正确性责任交给了调用方。
下面用一个最小示例感受它的“默认格式化模型”:
import org.springframework.ai.document.Document;
import org.springframework.ai.document.MetadataMode;
Map<String, Object> metadata = Map.of("source", "manual");
Document doc = new Document("Spring AI 文档格式化示例", metadata);
String formatted = doc.getFormattedContent(MetadataMode.ALL);
System.out.println(formatted);
这段代码最终会得到“metadata 块 + 两个换行 + 正文”的默认形态;如果 metadata 为空,那么 {metadata_string} 会是空串,默认模板仍会保留那两个换行,因此输出往往会以空行起头。这个现象不是 bug,而是默认 textTemplate 固定为 "{metadata_string}\n\n{content}" 的直接结果。也就是说,默认模板语义上假定 metadata 区域始终存在,只是有时为空。
如果你把这部分彻底理解了,那么 DefaultContentFormatter 就已经不再神秘。它其实可以被抽象成下面这张流程图:
这张图对应的正是源码里的真实执行顺序:先筛 metadata,再格式化 metadata,再拼正文。后面你会看到,所有公开方法、Builder 选项、MetadataMode 分支,本质上都在影响这条链上的某一个节点。
三、核心方法 format(Document, MetadataMode) 与 metadataFilter(...):真正决定输出内容的两段源码
如果说前一部分是在看“结构图”,那么这部分要进入真正的核心:format(Document, MetadataMode) 与私有的 metadataFilter(...)。从源码看,format(...) 的逻辑很短,但它几乎决定了这个类的一切行为。整体流程可以概括成四步:
第一步,调用 metadataFilter(document.getMetadata(), metadataMode) 得到“本次允许输出”的 metadata;
第二步,遍历过滤后的 metadata,把每个 entry 用 metadataTemplate 映射成字符串;
第三步,用 metadataSeparator 把这些 metadata 行拼接成一个 metadataText;
第四步,把 metadataText 和正文文本分别替换进 textTemplate 的 {metadata_string} 与 {content}。整个方法没有任何额外装饰层。
先看 metadata 的单条格式化。源码是:
.map(metadataEntry -> this.metadataTemplate
.replace("{key}", metadataEntry.getKey())
.replace("{value}", metadataEntry.getValue().toString()))
这段实现有三个非常关键的含义。第一,key 一定按字符串处理,因为 metadata map 的 key 类型就是字符串语义;第二,value 最终一律走 toString(),不做 JSON 序列化,也不做引号包装;第三,输出质量在很大程度上取决于 metadata value 自己的 toString() 实现。官方 Document Javadoc 的确建议 metadata value 限制在 string/int/float/boolean 这类简单类型,以兼容向量库;而 Document 构造也会校验 metadata key 和 value 不能为 null。也正因为如此,DefaultContentFormatter 才放心地直接 toString()。但从格式器角度说,它并不关心 value 是什么业务语义,只负责把它变成可拼接文本。
再看 metadataFilter(...),它是所有 metadata 模式控制的真正枢纽。官方 MetadataMode 枚举只有四个值:ALL、EMBED、INFERENCE、NONE。实现方式也很直接:
ALL:直接返回原 metadata;NONE:返回空 map;INFERENCE:从 metadata key 集合中移除excludedInferenceMetadataKeys;EMBED:从 metadata key 集合中移除excludedEmbedMetadataKeys;
然后再按照“key 是否仍在可用集合里”去过滤 entry。也就是说,两个排除列表不是“选择器”,而是“黑名单”。你不能配置“只保留这几个字段”,你能做的是“把这些字段排除掉”。这是理解 Builder 两组withExcluded...方法的前提。
这也解释了为什么 Spring AI 会把 INFERENCE 与 EMBED 分开。对于生成模型,你可能想保留 title、source、author、section 之类辅助语义;但对 embedding 来说,某些 metadata 可能只是噪音,甚至会让向量语义偏离正文主题。反过来,有些 metadata 也可能适合作为 embedding 上下文的一部分,却不希望直接暴露给回答模型。DefaultContentFormatter 并不替你判断“哪些字段应当排除”,它只提供了分别面向推理和向量化的两套排除通道。这就是它设计上非常“Spring 风格”的地方:提供机制,不替你做业务判断。
来看一个非常有代表性的例子:
import org.springframework.ai.document.DefaultContentFormatter;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.MetadataMode;
DefaultContentFormatter formatter = DefaultContentFormatter.builder()
.withExcludedInferenceMetadataKeys("embeddingHint")
.withExcludedEmbedMetadataKeys("debugNote")
.build();
Document doc = Document.builder()
.text("Redis 支持基于向量的相似检索。")
.metadata("source", "handbook")
.metadata("embeddingHint", "vector-search")
.metadata("debugNote", "仅供调试")
.build();
System.out.println(doc.getFormattedContent(formatter, MetadataMode.INFERENCE));
System.out.println("----");
System.out.println(doc.getFormattedContent(formatter, MetadataMode.EMBED));
这段代码的核心意义不在于输出长什么样,而在于:同一个 Document,在不同 MetadataMode 下会得到不同的“模型输入文本”。INFERENCE 会排除 embeddingHint,EMBED 会排除 debugNote。也就是说,DefaultContentFormatter 并不是单值函数 Document -> String,而是双参数函数 (Document, MetadataMode) -> String。对于理解整个类来说,这一点非常重要,因为很多人容易把它当成“文档转字符串”的静态工具,而忽略第二个参数其实决定了语义分支。
format(...) 还有若干实现层面的细节,值得单独强调。第一,它不做排序,前面已经解释过,所以 metadata 顺序不应依赖。第二,它不对模板占位符做补全,如果你的 textTemplate 没有 {content},正文就真的没了;没有 {metadata_string},metadata 就真的没了。第三,它不对 document 或 metadataMode 做显式 Assert 校验;Javadoc 没承诺这里会做防御性校验,而 Document.getFormattedContent(MetadataMode) 会在外层校验 metadataMode 非空。也就是说,直接手动调用 formatter.format(...) 时,调用者自己要承担参数正确性的责任。第四,在 1.1.4 发布版源码中,它直接把 document.getText() 用作替换值;而 main 分支则先把 null 文本转为空串,这说明维护者已经意识到多模态 Document 带来的边界问题。
我们再看一个“模板控制输出结构”的例子:
DefaultContentFormatter formatter = DefaultContentFormatter.builder()
.withMetadataTemplate("- {key} = {value}")
.withMetadataSeparator("\n")
.withTextTemplate("【Metadata】\n{metadata_string}\n\n【Content】\n{content}")
.build();
Document doc = new Document("向量数据库用于保存嵌入向量。", Map.of("source", "wiki"));
String s = formatter.format(doc, MetadataMode.ALL);
System.out.println(s);
这段代码说明的不是“如何自定义模板”这么简单,而是:DefaultContentFormatter 的可配置点,刚好对应它内部两级替换模型的三个关键连接处——单条 metadata 的样式、metadata 条目之间的连接方式、metadata 块与正文之间的总布局。你不能在这个类里做复杂逻辑控制,但只要你明白这三个连接点,几乎所有常见的“让模型看到更清晰上下文”的字符串组织方式,都可以用它完成。
最后补一个非常实用但常被忽略的观察:MetadataMode.NONE 并不是“metadata 格式化完再隐藏”,而是直接在 metadataFilter(...) 阶段返回空 map。这意味着当 mode 是 NONE 时,后续 metadata 流程仍然执行,但处理的是空集,最终 {metadata_string} 为空串。于是默认模板下你仍会得到那两个换行。这个行为看似细小,但它能帮助你判断:如果你真想完全移除 metadata 区域带来的空白,不应该只依赖 MetadataMode.NONE,还应该把 textTemplate 改成只输出 {content}。这正是理解“过滤 metadata”与“改变模板布局”是两回事的最好例子。
四、Builder 全方法逐项精讲:配置语义、替换规则、隐藏坑点与源码级行为差异
DefaultContentFormatter 的全部定制能力都集中在它的 Builder 里。官方 Javadoc 给出的 Builder 方法并不多,但每一个方法的“赋值语义”都很关键:from(...)、withMetadataTemplate(...)、withMetadataSeparator(...)、withTextTemplate(...)、两组 withExcluded...(各自都有 List 与 varargs 两个重载),以及最终的 build()。表面上它是一个很普通的 Builder;但只要深入源码,你会发现这里面有几处非常值得理解的实现细节。
先说入口方法。DefaultContentFormatter.builder() 是静态工厂,返回新的 Builder;DefaultContentFormatter.defaultConfig() 则是 builder().build() 的快捷形式。也就是说,默认配置没有隐藏 magic,它就是一个使用默认字段值构造出的格式器。默认值分别是:metadataTemplate = "{key}: {value}",metadataSeparator = System.lineSeparator(),textTemplate = "{metadata_string}\n\n{content}",两组排除列表都为空。这一点非常重要,因为它告诉你:“默认配置”不是来自外部配置文件,也不是框架注入,而是源码里写死的一组 Builder 初始值。
withMetadataTemplate(String metadataTemplate) 与 withTextTemplate(String textTemplate) 都使用 Assert.hasText(...) 做校验,因此它们要求传入字符串非空、且不能只是空白;但这两个方法不会验证模板中是否包含必要占位符。因此,下面这样的代码在语法上完全合法:
DefaultContentFormatter formatter = DefaultContentFormatter.builder()
.withMetadataTemplate("[{key}]")
.withTextTemplate("{content}")
.build();
运行后,metadata 的 value 会被静默丢弃,而最终文本中 metadata 整体也会被静默丢弃,因为 textTemplate 根本没有 {metadata_string}。这是一个非常典型的“Builder 只保证字符串有内容,不保证模板语义完整”的设计。它很灵活,但也意味着:你在配置模板时,实际上是在定义“丢弃哪些信息”。Spring 并不会替你防错。
withMetadataSeparator(String metadataSeparator) 的行为和前两个略有不同:源码使用的是 Assert.notNull(...),不是 hasText(...)。这意味着 metadataSeparator 可以是空字符串 "",只要不是 null 就行。这个细节很有意思,因为它说明框架允许你把多条 metadata 紧密拼接在一起。例如,你可以故意把 separator 设为空,生成紧凑型元数据头;当然,大多数场景下这会降低可读性。但从 API 语义看,Spring 明确把 separator 视为“分隔符对象”,而不是“可阅读文本”。这和模板字段必须“有文本内容”的约束形成了鲜明对比。
再来看两组最关键的方法:withExcludedInferenceMetadataKeys(...) 与 withExcludedEmbedMetadataKeys(...)。这两组方法各有两个重载,而它们的语义不是等价的。
withExcludedInferenceMetadataKeys(List<String>)/withExcludedEmbedMetadataKeys(List<String>):直接替换 Builder 当前持有的列表引用。withExcludedInferenceMetadataKeys(String... keys)/withExcludedEmbedMetadataKeys(String... keys):对当前列表执行addAll(Arrays.asList(keys)),也就是追加。
这意味着,List 重载是 set/replace 语义,varargs 重载是 append/merge 语义。如果你连续多次调用 varargs 版本,排除项会累积;如果你调用一次 List 版本,之前的列表引用会被整体替换。这种差异在 Javadoc 标题里并不明显,但在源码层面非常真实,理解错了就很容易造成“为什么排除项越配越多”或者“为什么之前的排除项突然没了”的困惑。
下面这个例子能非常直观地看出这种差异:
DefaultContentFormatter formatter = DefaultContentFormatter.builder()
.withExcludedInferenceMetadataKeys("a", "b") // 追加 a,b
.withExcludedInferenceMetadataKeys("c") // 再追加 c
.build();
这时推理排除列表是 a,b,c。但如果你中间换成:
List<String> keys = List.of("x", "y");
DefaultContentFormatter formatter = DefaultContentFormatter.builder()
.withExcludedInferenceMetadataKeys("a", "b")
.withExcludedInferenceMetadataKeys(keys) // 直接替换为 x,y
.build();
那么最终排除列表就是 x,y,前面的 a,b 已被覆盖。源码没有做“智能合并”,是否追加还是替换,完全取决于你调用的是哪个重载。
from(DefaultContentFormatter fromFormatter) 是另一个很值得讲透的方法。它会把已有 formatter 的 excludedEmbedMetadataKeys、excludedInferenceMetadataKeys、metadataSeparator、metadataTemplate、textTemplate 全部复制到 Builder 中。表面看,这就是一个普通的“以已有配置为基底继续改”。但源码里它调用的是 getter,而 getter 返回的是 Collections.unmodifiableList(...) 包装后的列表;与此同时,Builder 的 withExcluded...List(...) 重载只是直接赋值引用。这会引出一个非常容易踩的实现细节:**如果你先 from(existingFormatter),再调用对应的 varargs 追加方法,理论上会因为尝试对不可修改列表 addAll 而触发 UnsupportedOperationException。**这是对源码行为的直接推断:getter 返回不可修改视图,from 把它赋给 Builder 字段,varargs 再对该字段 addAll。因此,如果你想在 from(...) 的基础上继续“增补排除键”,最稳妥的做法不是直接 varargs 追加,而是先拷贝为新的可变列表,再用 List 重载回设。
安全写法可以像这样:
DefaultContentFormatter base = DefaultContentFormatter.builder()
.withExcludedEmbedMetadataKeys("rawHtml")
.build();
List<String> newEmbedKeys = new ArrayList<>(base.getExcludedEmbedMetadataKeys());
newEmbedKeys.add("debugField");
DefaultContentFormatter derived = DefaultContentFormatter.builder()
.from(base)
.withExcludedEmbedMetadataKeys(newEmbedKeys)
.build();
这个示例背后的重点,不是“如何多加一个键”本身,而是要你意识到:from(...) 并不意味着 Builder 内部一定变成了“可继续累加的可变副本”。从官方 Javadoc 看,它当然想表达“从已有 formatter 复制配置”;但从源码实现看,它对 list 的处理是浅层的。理解这一点,才能真正掌握这个 Builder。
build() 的 Javadoc 写着“Returns the immutable configuration”,但从源码实现看,这个“immutable”更准确地说是字段引用不再通过 formatter 的 API 被重新赋值,而不是深度不可变。原因很简单:Builder 在 withExcluded...List(...) 中直接持有外部列表引用,DefaultContentFormatter 构造函数也直接把 Builder 字段引用赋给自己的 final 字段,并没有做 defensive copy。getter 虽然返回 Collections.unmodifiableList(...),但如果你手里还保留着原始可变 List,你依然可以在 formatter 构建完成后继续修改那份列表,从而间接改变 formatter 行为。也就是说,这个类对列表的“不可变”更接近 API 视角上的只读,而不是实现层面的深不可变对象。这是理解它线程安全边界、配置复用边界时非常值得知道的一点。
最后看 getter:getMetadataTemplate()、getMetadataSeparator()、getTextTemplate() 返回原值;getExcludedInferenceMetadataKeys() 和 getExcludedEmbedMetadataKeys() 返回不可修改视图。这里的设计意图非常清楚:模板字符串是天然不可变对象,直接返回没问题;列表则需要至少防止调用方通过 getter 直接 add/remove。但正如上面分析过的,getter 的只读包装并不等于底层列表永不可变。因此,对这个 Builder/Formatter 体系的最佳理解应该是:
- 模板字段是值语义;
- 排除列表在 API 上是只读,在实现上可能是浅层共享;
from()是配置复制入口,但不是深拷贝语义的绝对保证。
到这里,Builder 的所有方法其实已经可以形成一个非常清晰的认知框架了:
builder()/defaultConfig():决定“从哪里开始”;withMetadataTemplate()/withMetadataSeparator()/withTextTemplate():决定“文本长什么样”;withExcludedInferenceMetadataKeys()/withExcludedEmbedMetadataKeys():决定“哪些 metadata 在什么模式下出现”;from():决定“如何复用现有配置”;build():把这些规则冻结为一个 formatter 实例。
看懂这一层,DefaultContentFormatter的“可配置语义”就基本完全掌握了。
五、与 Document、ETL、ContentFormatTransformer、Embedding/Batching 的协作关系,以及版本差异与易错点总梳理
单看 DefaultContentFormatter 容易把它理解成一个孤立类,但实际上它的真正价值来自于它被谁调用、在哪些链路生效、会影响哪些后续步骤。首先最直接的协作对象是 Document。Document 源码里明确定义了 DEFAULT_CONTENT_FORMATTER = DefaultContentFormatter.defaultConfig(),并把实例字段 contentFormatter 默认初始化为这个静态默认值。随后,Document.getFormattedContent() 默认等价于 getFormattedContent(MetadataMode.ALL),而 getFormattedContent(MetadataMode) 会调用当前 formatter 的 format(this, metadataMode);如果你想临时使用外部 formatter,也可以调用 getFormattedContent(ContentFormatter formatter, MetadataMode metadataMode)。再加上 setContentFormatter(...) 的存在,这说明 formatter 是 Document 的可替换“文本投影策略”,而不只是某个工具类里的静态函数。
这也意味着:同一个 Document,它的“正文内容”与“格式化后内容”不是一回事。 Document.getText() 只是原始文本字段;但 Document.getFormattedContent(...) 返回的是“metadata + text 经 formatter 投影后的结果”。在很多 Spring AI 使用场景里,模型实际消费的更应该是后者,而不是前者。官方 EmbeddingModel Javadoc 已经非常明确地给出提示:默认 getEmbeddingContent(Document) 返回 Document.getText();若实现支持 MetadataMode,则应覆盖为调用 Document.getFormattedContent(metadataMode)。这句话的重要性在于,它正式确认了:DefaultContentFormatter 不只服务于聊天 prompt,也服务于 embedding 输入的构造。
进一步地,TokenCountBatchingStrategy 的构造器直接接收 ContentFormatter 与 MetadataMode。这意味着在批量向量化时,系统并不是按“原始文本长度”做 token 预算,而可能是按“格式化后文本长度”来估算。于是 DefaultContentFormatter 的模板设计、metadata 是否纳入 embedding、排除列表的多少,都会影响 batch 切分结果。如果你把很多 metadata 一并塞进 embedding 文本,那么单文档 token 数会上升,批次会变小;反过来,如果你用 MetadataMode.NONE 或排除大量键,batch 容量就可能变大。也就是说,DefaultContentFormatter 在 embedding 场景里的影响不是只有语义层,还有吞吐与成本层。这也是为什么 Spring AI 要在 batching 策略构造器里显式暴露 formatter 与 metadataMode。
再看 ETL/转换链路。官方类使用关系里,ContentFormatter 被 ContentFormatTransformer 使用;而 ContentFormatTransformer 的职责,是对一批 Document 应用内容格式器。源码显示:如果文档当前 formatter 与目标 formatter 都是 DefaultContentFormatter,它不会简单粗暴地整体替换,而是进入 updateFormatter(...) 流程,把两边的 inference/embed 排除列表合并,同时沿用文档原 formatter 的 metadataTemplate 与 metadataSeparator,并在默认情况下继续沿用文档原 textTemplate;如果不是双方都属于 DefaultContentFormatter,则直接整体覆盖为新的 formatter。这个行为非常值得知道,因为它说明 DefaultContentFormatter 在 ETL 链路里具有“可合并”的特殊待遇,而不是总被替换掉。([Home][10])
这里还藏着一个实现层面的微妙点:ContentFormatTransformer 在 updateFormatter(...) 里,实际上主要合并的是排除列表;模板方面则更偏向保留文档已有 formatter 的模板值,而不是把传入 formatter 的模板配置完全覆盖过去。换句话说,当双方都是 DefaultContentFormatter 时,ContentFormatTransformer 更像是在“叠加 metadata 过滤规则”,而不是在“替换文档的整体文本模板布局”。这对于理解你为什么“传了一个 formatter,但最后模板没像预期那样变”特别关键。这里我不展开做工程评价,只强调一件事:源码行为比直觉更偏保守合并,而不是激进覆盖。([GitHub][11])
再说几个非常实用的易错点,全部都和 DefaultContentFormatter 的实现细节直接相关。
第一,默认输出不要依赖 metadata 顺序。 原因前面已经分析:Document 内部用 HashMap 存 metadata,formatter 不排序。
第二,不要把 MetadataMode.NONE 理解成“完全无 metadata 痕迹的纯正文模式”。 默认 textTemplate 仍会保留 metadata 区域的位置,因此可能带来空行。
第三,模板不校验占位符语义。 你完全可以合法地构造一个“只输出 metadata、不输出 content”或“只输出 content、不输出 metadata”的 formatter。
第四,List 重载与 varargs 重载语义不同:一个替换,一个追加。
第五,from(...) 之后直接用 varargs 继续追加排除项,从源码推断可能命中不可修改列表问题。
这些点都不是概念层“最佳实践”,而是读源码之后必须知道的行为事实或高概率踩坑点。
还有一个必须单独说的,是版本差异与 media-only Document。Document 官方源码已经明确支持“text 或 media 二选一”;但在 v1.1.4 的 DefaultContentFormatter 源码里,format() 最后仍然直接执行 .replace("{content}", document.getText()),没有为 null 文本做兜底。而在 main 分支源码中,这里已经改成先把 null 文本转为空串再替换。与此同时,Spring AI GitHub 的 issue #3609 记录了 media-only Document 在相关处理链路里触发空指针的问题。这三件事放在一起,给出一个非常明确的源码级信号:如果你研究的是 1.1.4 这一代实现,就必须对 media-only 文档保持额外谨慎;而主干分支已经开始朝 null-safe 方向修正。 我这里刻意不把它泛化成“所有场景都会崩”,而是严格按源码和官方 issue 告诉你:发布版与主干实现之间确实存在这处差异。
为了把这些协作点串起来,下面给一个简化示例。它展示了:Document 如何持有 formatter,formatter 又如何决定生成模型/嵌入模型最终看到的字符串。
import org.springframework.ai.document.DefaultContentFormatter;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.MetadataMode;
DefaultContentFormatter formatter = DefaultContentFormatter.builder()
.withMetadataTemplate("{key} => {value}")
.withTextTemplate("Context:\n{metadata_string}\n\nBody:\n{content}")
.withExcludedEmbedMetadataKeys("debug")
.build();
Document doc = Document.builder()
.text("Spring AI 支持将文档内容送入 EmbeddingModel。")
.metadata("source", "reference")
.metadata("debug", "不要进入 embedding")
.build();
doc.setContentFormatter(formatter);
String forInference = doc.getFormattedContent(MetadataMode.INFERENCE);
String forEmbed = doc.getFormattedContent(MetadataMode.EMBED);
System.out.println("INFERENCE:\n" + forInference);
System.out.println("EMBED:\n" + forEmbed);
这段代码最重要的不是“会输出什么字面文本”,而是它完整体现了 DefaultContentFormatter 的定位:它是 Document 到“下游模型可消费文本”的桥。换言之,当你理解一个 Document 时,不应只看 text 字段,还要看它当前绑定的 contentFormatter 是什么、使用哪个 MetadataMode 输出。只有这样,你对 Spring AI 文档输入链路的理解才是完整的。
六、 format(Document, MetadataMode)
如果要选一个最值得反复看的方法,那一定是 format(Document, MetadataMode)。因为它虽然很短,却承担了这个类几乎全部的业务语义:决定 metadata 是否参与输出、决定 metadata 如何展平、决定 metadata 与正文如何组合、决定最终返回给模型的字符串长什么样。在官方 API 中,ContentFormatter 就只要求实现这一件事;而 DefaultContentFormatter 的源码正是用这个方法把“文档对象”投影为“模型输入文本”。
先把这个方法按逻辑拆成伪代码:
String format(Document document, MetadataMode metadataMode) {
Map<String, Object> metadata = metadataFilter(document.getMetadata(), metadataMode);
String metadataText = metadata.entrySet().stream()
.map(entry -> metadataTemplate
.replace("{key}", entry.getKey())
.replace("{value}", entry.getValue().toString()))
.collect(joining(metadataSeparator));
return textTemplate
.replace("{metadata_string}", metadataText)
.replace("{content}", document.getText());
}
这段伪代码虽然是讲解版,但和官方 v1.1.4 实现的结构是一致的:先过滤 metadata,再把 metadata 用模板与分隔符格式化,再把 metadata_string 与 content 注入总模板。源码中的默认占位符与默认模板也都是硬编码常量,不存在外部模板引擎参与。
6.1 第一行:不是直接用 document.getMetadata(),而是先走 metadataFilter(...)
这一步很关键,因为它告诉你 DefaultContentFormatter 不是“无脑打印 metadata”,而是严格受 MetadataMode 控制。MetadataMode 官方只有四个枚举值:ALL、EMBED、INFERENCE、NONE。DefaultContentFormatter 的私有 metadataFilter(...) 源码明确写了:ALL 直接返回原 metadata;NONE 返回空映射;INFERENCE 和 EMBED 则通过“从 key 集合里移除各自排除列表”的方式进行过滤。也就是说,format() 的第一步已经决定了:这次输出到底有没有 metadata,以及有哪些 metadata。
很多人第一次看这个类,会误以为“模板负责决定要不要显示 metadata”。其实不是。模板只决定“如果有 metadata,要怎么排版”;真正决定“有没有、有哪些”的,是 metadataFilter(...)。这一区别非常重要。因为它意味着:
MetadataMode.NONE是一种数据级别的裁剪;withTextTemplate(...)是一种布局级别的裁剪;
这两件事不是一个层次。前者让metadata_string变空,后者决定空的metadata_string放进模板以后,会不会还留下空白结构。
6.2 第二步:metadata 的每一项都是“纯字符串替换”,没有结构化序列化
源码对 metadata entry 的处理方式非常直接:取出每个 entry,然后把 metadataTemplate 里的 {key} 替换为 key,把 {value} 替换为 value.toString()。这里没有 JSON 序列化、没有类型分发、没有引号、没有转义器、没有 null 兼容分支。之所以可以这么做,是因为 Document 在自己的约束里已经要求 metadata 值不要搞成复杂嵌套对象,并建议限制在 string/int/float/boolean 这类简单类型,以便于向量数据库和下游处理使用。
这一步的含义,远不只是“实现简单”。更深一层的意义在于:DefaultContentFormatter 不是数据交换格式器,而是模型阅读格式器。
它不追求“强结构化、可逆、严格模式”;它追求的是“足够清晰地把 metadata 暴露给模型”。所以它直接采用 key: value 这种轻文本风格,而不是把 metadata 变成 JSON 对象。你当然可以通过 withMetadataTemplate(...) 把它改成接近 JSON 的样子,但那仍然只是字符串模板,并不意味着它真的进入了结构化序列化流程。
例如:
DefaultContentFormatter formatter = DefaultContentFormatter.builder()
.withMetadataTemplate("\"{key}\": \"{value}\"")
.withMetadataSeparator(",\n")
.withTextTemplate("{\n{metadata_string}\n}\n\n{content}")
.build();
这会让输出看起来像 JSON,但本质仍然只是字符串替换。它不会处理引号转义,也不会帮你保证语法完备。所以这类“类 JSON 模板”只适合为了让模型更容易理解语义结构,而不适合当成真正的 JSON 序列化输出。这个区分一定要明确。
6.3 第三步:metadata 之间怎么拼,不由 map 决定,而由 metadataSeparator 决定
源码把所有单条 metadata 文本用 Collectors.joining(this.metadataSeparator) 拼起来。默认 separator 是 System.lineSeparator(),也就是当前运行平台的换行符。因此默认情况下,metadata 是“一行一个条目”的效果。
这看似平常,但其实有两个非常重要的推论。
第一,metadata 条目之间的逻辑结构极其弱。框架只负责“用一个分隔符连接”,并不提供 header、footer、层级或编号。
第二,metadataSeparator 的 Builder 校验是 Assert.notNull(...),不是 Assert.hasText(...),所以你可以把它设为空串。这样做通常不利于阅读,但从 API 角度它是完全合法的。也就是说,Spring 把 separator 视为“连接策略”,而不是“必须可读的分隔文本”。
下面这个例子能看出 separator 对结果密度的影响:
DefaultContentFormatter formatter = DefaultContentFormatter.builder()
.withMetadataTemplate("[{key}={value}]")
.withMetadataSeparator(" ")
.withTextTemplate("{metadata_string}\n{content}")
.build();
假设 metadata 有 source=wiki 和 page=10,那么 metadata 区域就会变成:
[source=wiki] [page=10]
这和默认的一行一个条目相比,token 可能更省,但可读性会下降。这里没有绝对好坏,关键是你要知道:DefaultContentFormatter 其实给了你“metadata 压缩排版”的能力,只是很多人只看到默认换行,没有意识到 separator 的语义空间。
6.4 第四步:最终输出永远由 textTemplate 决定,不是由 formatter 内部固定布局决定
最后一步是把 metadataText 填入 {metadata_string},把正文填入 {content}。默认 textTemplate 是 "{metadata_string}\n\n{content}",这意味着默认布局是:
metadata 块 → 空一行 → 正文。
这一步决定了一个经常被忽视的事实:DefaultContentFormatter 的“最终样子”并不固定。 固定的只是默认配置。只要你改了 textTemplate,metadata 甚至可以放到正文后面、包在特殊分隔符里,或者彻底不出现。比如:
DefaultContentFormatter formatter = DefaultContentFormatter.builder()
.withTextTemplate("{content}\n\n---\nMetadata:\n{metadata_string}")
.build();
这样 metadata 就会被放到正文后面。框架完全允许这种布局。它不会阻止你,因为它把“布局正确性”交给了调用者。
6.5 一个最容易忽略的细节:MetadataMode.NONE 并不自动移除模板里的 metadata 区域
源码里 MetadataMode.NONE 的实现是直接返回空 map。然后 entry stream 为空,joining(...) 得到空字符串,最后空字符串照样被塞进 textTemplate 的 {metadata_string}。如果你还在用默认模板,那么输出就会变成:
这里是正文
也就是 metadata 虽然没了,但那两个换行仍然存在。原因不是框架粗心,而是因为默认模板本来就假定 metadata 区域在正文前,而且 template engine 并没有“如果为空就删掉整段”的条件逻辑。
这意味着一个非常重要的使用结论:
如果你的目标是“彻底变成纯正文字符串”,不能只靠 MetadataMode.NONE,还应该把 textTemplate 改成只包含 {content}。
例如:
DefaultContentFormatter pureText = DefaultContentFormatter.builder()
.withTextTemplate("{content}")
.build();
只有这样,你才能从“数据上不包含 metadata”进一步达到“布局上也没有 metadata 留痕”。
七、Builder 全方法:不仅知道“能配什么”,还要知道“赋值语义是什么”
DefaultContentFormatter.Builder 的方法看起来不多,但它其实是这个类最容易被“想当然”的地方。Javadoc 里能看到 build()、from(...)、三组模板配置方法、两组排除键配置方法;但真正影响行为的,不只是方法名,而是每个方法内部到底是覆盖、追加,还是浅复制。这正是源码阅读最有价值的地方。
7.1 builder() 与 defaultConfig():默认配置不是“魔法”,而是默认字段值直接 build
官方 API 里,builder() 用于开始构建新配置,defaultConfig() 用于返回默认配置。源码层面,defaultConfig() 本质就是 builder().build();而 Builder 的字段默认值就是:
- metadataTemplate =
"{key}: {value}" - metadataSeparator =
System.lineSeparator() - textTemplate =
"{metadata_string}\n\n{content}" - inference/embed 两个排除列表都为空。
因此,理解默认配置最好的方式不是背文档,而是记住:它只是 Builder 默认字段的直接物化。 这也解释了为什么 Document.DEFAULT_CONTENT_FORMATTER 能在类加载时安全初始化:因为它根本不依赖上下文,只依赖一套写死在源码里的简单默认值。
7.2 withMetadataTemplate(...):只校验“有文本”,不校验“语义完整”
withMetadataTemplate(String) 用的是 Assert.hasText(...)。也就是说,不能为空,也不能是全空白字符串;但它不会检查你的模板里是否真的包含 {key} 和 {value}。源码没有任何这类语义校验。
这带来一个非常关键的结论:
你完全可以写出下面这样的 formatter:
DefaultContentFormatter formatter = DefaultContentFormatter.builder()
.withMetadataTemplate("[{key}]")
.build();
它是合法的,但 metadata value 会在输出时完全消失。Spring 不会提醒你“你忘了 {value}”。从框架角度,这不是 bug,因为框架认为模板的语义由你自己负责。
所以,对 withMetadataTemplate(...) 最准确的理解不是“配置 metadata 模板”,而是:
你正在亲手定义每个 metadata 条目最终要暴露给模型的文本信息集合。
缺一个占位符,就等于你主动舍弃了一类信息。这个视角比“换个格式”要深得多。
7.3 withMetadataSeparator(...):只要求非 null,允许空串
这一点我上一节提过,但放在 Builder 语义里更值得强调。withMetadataSeparator(...) 使用的是 Assert.notNull(...),所以空串 ""、单个空格 " "、制表符 "\t" 都是合法的。
这表明 Spring 对 separator 的理解很纯粹:它只是拼接器参数,不是语义模板。
因此你可以利用它做很多紧凑格式:
.withMetadataSeparator(" | ")
或者:
.withMetadataSeparator("")
前者适合做单行头信息,后者适合极限压缩,但可读性会显著下降。源码没有偏好哪种方式;它只要求“不要是 null”。
7.4 withTextTemplate(...):它决定的是“总布局”,不是“正文局部样式”
withTextTemplate(...) 同样用 Assert.hasText(...) 做校验,因此必须有实际文本;但它也不会检查你是否包含 {content} 或 {metadata_string}。你可以把正文丢掉,也可以把 metadata 块丢掉,Spring 都照单全收。
例如:
DefaultContentFormatter formatter = DefaultContentFormatter.builder()
.withTextTemplate("{content}")
.build();
这在语法上完全成立,而且很常用:它会让 formatter 的最终输出永远是纯正文。此时即便 metadata 被 MetadataMode.ALL 保留下来,也不会进入最终字符串,因为模板里根本没有 {metadata_string}。
这也是为什么我前面一直强调:
metadataFilter(...)决定的是“有哪些 metadata”textTemplate决定的是“这些 metadata 有没有机会进入最终布局”
两者必须分开理解。很多误判都来自把它们混成了一件事。
7.5 withExcludedInferenceMetadataKeys(...) / withExcludedEmbedMetadataKeys(...):两个重载语义并不一样
这是 Builder 里最值得重点讲的地方。因为同名方法有两个重载:
- 一个接收
List<String> - 一个接收
String...
它们看起来像只是调用方式不同,但源码语义其实不同:
List重载:直接替换 Builder 当前列表字段;varargs重载:对当前列表执行addAll(Arrays.asList(keys)),也就是追加。
这意味着下面两段代码的结果是不一样的。
第一段:
DefaultContentFormatter f = DefaultContentFormatter.builder()
.withExcludedInferenceMetadataKeys("a", "b")
.withExcludedInferenceMetadataKeys("c")
.build();
最终 inference 排除列表是 a,b,c。因为两次都是 varargs,都是追加。
而第二段:
DefaultContentFormatter f = DefaultContentFormatter.builder()
.withExcludedInferenceMetadataKeys("a", "b")
.withExcludedInferenceMetadataKeys(List.of("x", "y"))
.build();
最终 inference 排除列表是 x,y,前面的 a,b 被替换掉了。因为第二次调用的是 List 重载,直接覆盖字段引用。
这类差异在很多 Builder 中并不常见,所以非常容易被忽略。一旦忽略,你就会产生“为什么我的排除项越来越多”或者“为什么我前面配的突然没了”这类困惑。对 DefaultContentFormatter.Builder 来说,重载不是语法糖,而是语义分流。
7.6 from(DefaultContentFormatter):它是“配置搬运”,但不是深拷贝护栏
from(...) 会把传入 formatter 的配置重新灌进当前 Builder:包括 embed 排除列表、inference 排除列表、separator、metadataTemplate、textTemplate。官方 Javadoc 的意思很直观:从已有配置复制一份出来再继续改。源码也确实这么做了。
但源码层面还有一个更深的细节:from(...) 调用的是 formatter 的 getter;而 getter 返回的是 Collections.unmodifiableList(...) 包装后的列表;与此同时,Builder 的 withExcluded...List(...) 只是直接把这个列表引用赋给自己的字段。于是如果你接着再用 varargs 版本去 addAll(...),就很可能会对一个不可修改列表执行追加操作。按 Java 集合语义,这会触发 UnsupportedOperationException。这不是我在空想,而是从源码实现直接推出来的行为链:
getter 返回只读视图 → from() 把它设置进 Builder → varargs 对当前字段 addAll。
所以,一个更稳妥的模式是:
DefaultContentFormatter base = DefaultContentFormatter.builder()
.withExcludedEmbedMetadataKeys("rawHtml")
.build();
List<String> keys = new ArrayList<>(base.getExcludedEmbedMetadataKeys());
keys.add("debug");
DefaultContentFormatter derived = DefaultContentFormatter.builder()
.from(base)
.withExcludedEmbedMetadataKeys(keys)
.build();
这个写法的重点不在“多加一个 debug”,而在于:先把只读列表拷到新的可变列表,再用 List 重载整体替换回去。 这样才和源码实现相容。
7.7 build():Javadoc 说 immutable,但要分清“API 只读”和“深度不可变”
官方 Builder Javadoc 说 build() 返回 immutable configuration。源码里 build() 只是 return new DefaultContentFormatter(this);。同时,Builder 的 List 字段如果来自外部 List 重载,并没有做 defensive copy;Formatter 的 getter 虽然返回 Collections.unmodifiableList(...),但底层列表未必不是共享对象。
因此,更精确的理解是:
这个 formatter 在 API 视角上是只读对象,但在实现细节上,对外部可变列表并没有绝对的深度防御。
也就是说,如果你把一个可变 List 通过 withExcluded...List(...) 传进去,然后后面又继续修改这份 List,formatter 的行为理论上也会被间接改变。这并不违背 getter 的只读包装,因为 getter 只防止“通过 getter 直接改”,并没有切断所有原始引用。
八、它与 Document 的关系不是“工具调用”,而是“文档内建格式化策略”
如果你只单独研究 DefaultContentFormatter,很容易把它看成“一个需要时才调用的辅助类”。但从 Document 的官方 API 和源码看,这个关系远比“工具调用”更紧密。Document 内部有 DEFAULT_CONTENT_FORMATTER 常量,而且该常量就是 DefaultContentFormatter.defaultConfig();同时,Document 提供 getContentFormatter() / setContentFormatter(),以及三组 getFormattedContent(...)。这说明在 Spring AI 的设计里,formatter 是 Document 自身状态的一部分。
8.1 Document 不是只负责存文本,它还负责暴露“格式化后的文本视图”
官方 Document API 里有:
getText():取原始文本getFormattedContent():取当前 formatter 下、默认MetadataMode.ALL的格式化结果getFormattedContent(MetadataMode):按指定模式格式化getFormattedContent(ContentFormatter, MetadataMode):临时用外部 formatter 格式化。
这几个方法放在一起,就能看出 Spring 的设计取向:
原始内容与模型可消费内容不是同一个概念。
getText() 给你的是数据层文本;getFormattedContent(...) 给你的是策略层文本。
这两个层次必须分开,否则你就无法正确理解为什么同一个文档在不同 MetadataMode 下、或者在绑定不同 formatter 时,会呈现出不同的模型输入。
8.2 setContentFormatter(...) 的存在,说明 formatter 是文档级可替换策略
Document 官方 API 明确写了 setContentFormatter(ContentFormatter),描述是“替换文档的 ContentFormatter”。这不是静态工具式调用,而是状态变更。也就是说,一个 Document 构建完成后,你仍然可以把它默认的 DefaultContentFormatter 换成另一个 formatter。
这件事的重要性在于:
你可以把 formatter 理解成 Document 的“文本渲染策略对象”。
类似于某些 UI 模型对象可替换 renderer,只不过这里的“渲染目标”不是屏幕,而是 LLM / Embedding 的输入字符串。这个视角一旦建立,你会更容易理解为什么 ContentFormatTransformer 会操作 document 的 formatter,而不是直接去改 document 的 text。([Home][7])
8.3 Document 的 metadata 约束,解释了为什么 formatter 可以大胆 toString()
Document 源码里的注释明确写到:metadata 不应嵌套,值应尽量限制为 string/int/float/boolean 之类简单类型,以便于向量数据库使用。这个约束虽然不是格式器直接声明的,但它解释了为什么 DefaultContentFormatter 在格式化时可以非常轻量地直接 value.toString(),而不引入更复杂的序列化机制。([GitHub][8])
这也是一个很典型的 Spring 设计:
约束在上游建模层给出,简化在下游实现层兑现。
所以当你看到 DefaultContentFormatter 没有做复杂对象格式化支持时,不应立刻认为它“功能弱”;更准确的说法是:它建立在 Document 元数据建模约束已经被遵守的前提上。([GitHub][8])
8.4 Document 现在允许 text 或 media,这给 formatter 带来了边界问题
官方 Document API 与源码都表明:Document 可以持有文本,也可以持有媒体,isText() 用于判断当前是文本还是媒体;Builder 注释也写明,text 或 media 二者必须设置其一,但不能同时设置。
这和早期“文档一定是文本”的心智模型已经不同了。问题在于,DefaultContentFormatter 的核心输出还是围绕 {content} 设计的,而 content 在发布版实现中直接取自 document.getText()。这就意味着:当文档是 media-only 时,formatter 的文本替换路径天然处在一个更脆弱的位置。 主干 main 分支已经把这里修成了 document.getText() != null ? document.getText() : "" 的空串兜底,说明维护方向已经意识到这个边界。
这里我给你的结论非常克制:
不是说“只要 media 就一定有问题”,而是说这个类的核心语义依旧是“文本内容格式化器”,当 Document 扩展到多模态后,边界必须重新审视。这也是源码阅读真正有价值的地方:你能看到设计演进留下的痕迹。
九、它如何影响 Embedding、Batching 与 ContentFormatTransformer:不只是“打印好看一点”
很多人理解 DefaultContentFormatter 时,容易把它看成只是“为了给 Prompt 加一点 metadata 头部”。但从官方 API 关系看,它的影响范围明显更大:EmbeddingModel、TokenCountBatchingStrategy、ContentFormatTransformer 都和它有关。 这意味着它不是一个“仅面向聊天提示词”的类,而是整个文档流中“文本表征”的关键部件。([Home][9])
9.1 EmbeddingModel 已经明确承认:embedding 内容不一定只是 Document.getText()
EmbeddingModel 官方 Javadoc 的 getEmbeddingContent(Document) 非常关键。它写得很清楚:默认实现返回 Document.getText();而支持 MetadataMode 的实现,应覆盖为调用 Document.getFormattedContent(metadataMode),这样 metadata 才能进入真正发送给 embedding API 的文本。([Home][9])
这句话几乎直接告诉你:DefaultContentFormatter 并不是 Prompt 专用,它是 embedding 输入构造的候选基础设施。
也就是说,embedding 吃到的文本,可以是:
- 纯正文
- metadata + 正文
- 按 EMBED 模式排除部分 metadata 之后的正文
- 用自定义模板组织过的文本。([Home][9])
这个事实会带来非常实际的影响:
一旦 metadata 被纳入 embedding 文本,向量语义就不再只代表正文本身,而是“正文 + 你选择暴露给 embedding 的上下文信息”。这既可能增强召回,也可能引入噪音。DefaultContentFormatter 不替你做判断,但它提供了精确控制入口。([Home][9])
9.2 TokenCountBatchingStrategy 接收 ContentFormatter 与 MetadataMode,说明格式器会反向影响批量切分
官方 TokenCountBatchingStrategy 构造器中,明确有接收 ContentFormatter 和 MetadataMode 的版本。换句话说,批处理策略在评估 token 数时,可以基于格式化后的文档内容,而不是只基于裸文本。([Home][10])
这件事的意义非常深。因为它说明:
你在 formatter 里做的每一个决定,都可能影响后续 embedding 批处理的大小、分片、吞吐与成本。
例如:
- metadata 很多,且
EMBED模式不过滤 → token 增长 - 用长模板加很多标签文字 → token 增长
- 改成
{content}纯正文模板 → token 下降。([Home][10])
所以 DefaultContentFormatter 不只是“输出长什么样”的问题,它还会影响“系统一次能塞多少文档去做 embedding”。这是非常典型的 AI 基础设施特征:文本表征既决定语义,也决定成本。 ([Home][10])
9.3 ContentFormatTransformer 不是简单覆盖 formatter,它对 DefaultContentFormatter 有特殊合并逻辑
ContentFormatTransformer 的官方 API 说明它是“使用给定 ContentFormatter 处理文档列表”的转换器。关键在源码:当文档当前 formatter 和传入 formatter 都是 DefaultContentFormatter 时,它不会直接 setContentFormatter(newFormatter),而是进入 updateFormatter(...)。这个方法会:
- 把文档原有 embed 排除键复制出来,再把目标 formatter 的 embed 排除键追加进去;
- 把文档原有 inference 排除键复制出来,再把目标 formatter 的 inference 排除键追加进去;
- 继续沿用文档原 formatter 的
metadataTemplate与metadataSeparator; - 在未禁用 template rewrite 的情况下,还沿用文档原有
textTemplate;
最后重新 build 一个新的DefaultContentFormatter再设回 document。([Home][7])
这个行为非常值得单独记住,因为它说明:
在 ETL 转换链里,
DefaultContentFormatter的默认处理策略更接近“合并过滤规则”,而不是“整体替换布局模板”。
也就是说,如果你以为 ContentFormatTransformer(newFormatter) 会把文档里的模板配置全部替换成 newFormatter 的模板,那源码并不完全支持这种直觉。它更保守,尤其在双方都是 DefaultContentFormatter 时。([GitHub][11])
这也从另一个角度印证了 Spring AI 对 DefaultContentFormatter 的态度:它不仅是一个默认实现,还是一个被框架其他组件“理解并特殊处理”的默认实现。这和普通自定义 formatter 的地位并不一样。([Home][7])
十、总结
第一,它是 Spring AI ContentFormatter 的默认实现,职责是把 Document 的 metadata 与正文投影成适合模型消费的字符串。
第二,它的实现本质是“两级模板替换”:先按 metadataTemplate 把每个 metadata 条目变成文本,再用 metadataSeparator 拼成 metadata_string,最后把 metadata_string 与 content 注入 textTemplate。默认模板分别是 "{key}: {value}"、System.lineSeparator()、"{metadata_string}\n\n{content}"。
第三,它不是简单的 Document -> String,而是 Document + MetadataMode -> String。 ALL / INFERENCE / EMBED / NONE 决定 metadata 过滤策略,而两套排除列表则分别服务于推理与向量化。
第四,它和 Document、Embedding、Batching、ETL 转换链路都有关。 Document 默认持有它;支持 MetadataMode 的 embedding 实现应通过 getFormattedContent(...) 取文本;TokenCountBatchingStrategy 也能直接接收 ContentFormatter 与 MetadataMode。
第五,真正容易踩坑的地方都在源码细节里。 比如 metadata 顺序不保证、模板不校验占位符语义、List 重载与 varargs 重载一个是替换一个是追加、from(...) 后继续 varargs 追加在源码层面存在不可修改列表风险,以及 1.1.4 与 main 在 media-only Document 文本兜底上的实现差异。
10.1 它不是普通字符串工具,而是 Document 的默认“模型输入投影器”
你以后看到 DefaultContentFormatter,不要只想到“加 metadata 头”。更准确的认识是:
它定义了
Document如何被投影为一个供模型消费的字符串。
因为 Document 默认持有它,getFormattedContent(...) 默认调用它,embedding 与 batching 也能围绕格式化内容工作,所以它是整个文档处理链里的“文本视图策略对象”。
10.2 它的核心是“两层模板 + 一层过滤”,而不是复杂模板引擎
这个类不是通用模板系统。它的全部机制就是:
- 先按
MetadataMode和排除列表过滤 metadata; - 再用
metadataTemplate+metadataSeparator形成metadata_string; - 最后把
metadata_string和content注入textTemplate。
这个模型极其简单,但非常适合 AI 文本场景。你一旦理解了这个三步结构,整个类就不会再有“黑箱”感。
10.3 MetadataMode 管的是“数据是否参与”,模板管的是“参与后放哪里”
这是理解所有输出差异的总钥匙。
MetadataMode.NONE:metadata 不参与数据流withTextTemplate("{content}"):即使 metadata 参与了,也没有布局位置
二者必须分开。把它们混起来,就会误解为什么“明明 NONE 了还多出空行”。
10.4 Builder 重载不是语法糖,而是语义差异
这一点必须牢牢记住:
List重载 = 替换varargs重载 = 追加from(...)= 复制配置,但不是深拷贝防线。
你真正掌握 Builder,不是会写 .withXXX(...),而是知道哪次调用会覆盖旧值,哪次调用会把旧值带上继续累加。
10.5 当 Document 不再总是纯文本时,这个类的边界就变得更重要
Document 已经支持 text 或 media,而 DefaultContentFormatter 的核心语义仍然是围绕文本内容展开。主干分支对 document.getText() 加了空串兜底,恰恰说明这个边界已经进入框架维护者视野。
所以从“类的历史演进”角度看,DefaultContentFormatter 也是一个很有代表性的观察窗口:它展示了 Spring AI 如何从“纯文本文档”逐步迈向“文本/媒体并存的文档模型”。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)