技术栈:Spring Boot 3.5.9 · Spring AI 1.1.2 · Spring AI Alibaba 1.1.2.2 · JDK 21+
前提:本文默认文档已加载为
Document(org.springframework.ai.document.Document),只聚焦"清洗"这一环,不涉及加载、切块、写库等流程。
1. 为什么要清洗
RAG 遵循 GIGO(Garbage In, Garbage Out,垃圾进垃圾出) 定律:喂给向量库的原始文本越脏,检索质量越差。刚加载完的文档往往带着这些问题:
| 问题 | 示例 | 对 RAG 的影响 |
|---|---|---|
| HTML / Markdown 标签残留 | <div><p>正文</p></div> |
标签变成无效 token,污染向量 |
| 多余空白与换行 | 这是 一段\r\n\r\n\r\n 正文 |
浪费 token,语义被切断 |
| 控制字符 / 零宽字符 / BOM | 、、\x00 |
肉眼看不见,却让文本"变了味" |
| 乱码替换符 | �(�) |
GBK 被当 UTF-8 读的典型后果 |
| 过短 / 过长的碎片 | 只剩一个标题,或整篇没切 | 无意义向量 or 检索粒度太粗 |
| 重复内容 | 同一段话出现多次 | 检索结果重复、浪费存储 |
清洗的目标 :把原始文本变成干净、规整、可被可靠切分与向量化 的文本,同时不破坏语义、不丢失元数据。
2. 清洗的入口:DocumentTransformer 接口
Spring AI 中,清洗属于 ETL 的 Transform(转换) 环节,落点是极简的 DocumentTransformer 接口:
java
public interface DocumentTransformer extends Function<List<Document>, List<Document>> {
// 唯一抽象方法:一批文档进,一批文档出
@Override
List<Document> apply(List<Document> documents);
// 语义化别名,内部就是调 apply
default List<Document> transform(List<Document> documents) {
return apply(documents);
}
}
这意味着:
- 只要实现
apply,你就是一个清洗器------无需继承任何复杂基类。 - 它继承自
Function,所以天然支持andThen/compose做函数式串联。
3. 内置的清洗能力
3.1 先说结论:没有专门的 TextCleaner
Spring AI 核心里没有 一个名为 TextCleaner 的"万能清洗器"。所谓"内置清洗能力",其实分散在两处,且定位完全不同:
- 读取器(
DocumentReader)内置的清洗 ------ 在加载阶段就把格式噪音、编码问题解决掉("源头清洗")。 - 内置的
DocumentTransformer------ 它们做的是格式化 / 富化 ,不是清洗,别混淆。
3.2 读取器内置的清洗(源头清洗)
如果你还掌控"加载"这一步,选对读取器,就能在源头完成一半清洗,比自己写正则省心得多:
| 读取器 | 内置的"清洗" | 适用场景 |
|---|---|---|
TikaDocumentReader |
自动探测编码(根治乱码)、从 PDF/Word/PPT/HTML 等抽取纯文本 | 格式未知或混合 |
JsoupDocumentReader |
HTML → 纯文本,CSS 选择器只抽正文,丢掉导航/广告等无关区域 | 网页抓取 |
MarkdownDocumentReader |
结构化解析,标题转元数据,代码块/引用块/分隔线可配置处理 | Markdown 文档 |
TextReader |
纯文本,显式指定 Charset |
纯文本/日志 |
java
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import java.util.List;
// Tika:自动探测编码、抽取纯文本,从源头避免乱码与格式噪音
TikaDocumentReader tikaReader = new TikaDocumentReader("classpath:docs/spec.pdf");
List<Document> docs = tikaReader.get(); // read() 与之等价
java
import org.springframework.ai.reader.markdown.MarkdownDocumentReader;
import org.springframework.ai.reader.markdown.config.MarkdownDocumentReaderConfig;
// Markdown:代码块、引用块是否并入正文、分隔线是否切分,均可配置
MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
.withIncludeCodeBlock(false) // 代码块单独成篇(默认 false)
.withIncludeBlockquote(false) // 引用块单独成篇(默认 false)
.withHorizontalRuleCreateDocument(false) // 分隔线不切分文档(默认 false)
.build();
MarkdownDocumentReader mdReader = new MarkdownDocumentReader(resource, config);
java
import org.springframework.ai.reader.jsoup.JsoupDocumentReader;
import org.springframework.ai.reader.jsoup.config.JsoupDocumentReaderConfig;
// Jsoup:用 CSS 选择器只抽取正文区域,丢掉导航、广告等无关内容
JsoupDocumentReaderConfig config = JsoupDocumentReaderConfig.builder()
.withSelector("article.content")
.build();
JsoupDocumentReader htmlReader = new JsoupDocumentReader(resource, config);
这些读取器的"清洗"发生在加载阶段 。一旦文档已经变成
Document,再想补做清洗,就得回到第 5 节的DocumentTransformer自定义实现------这也是本文的重点。
3.3 内置的 DocumentTransformer(⚠️ 注意:不是清洗器)
Spring AI 内置了三个 DocumentTransformer,常被误当成"清洗器",这里先说清定位,再逐一给出怎么用:
| 内置 Transformer | 做什么 | 是清洗吗 |
|---|---|---|
ContentFormatTransformer |
统一"文本 + 元数据"的拼装格式(输出侧规整) | ❌ 格式化,不删脏数据 |
KeywordMetadataEnricher |
调用 LLM 抽取关键词,写入 excerpt_keywords 元数据 |
❌ 元数据富化 |
SummaryMetadataEnricher |
调用 LLM 生成摘要,写入 section_summary 等元数据 |
❌ 元数据富化 |
它们都是 DocumentTransformer,用法统一:先构造 → 再 apply(documents),也可以像第 6 节那样和自定义清洗器串进同一条流水线。
3.3.1 ContentFormatTransformer ------ 统一内容格式
java
import org.springframework.ai.document.DefaultContentFormatter;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentTransformer;
import org.springframework.ai.transformer.ContentFormatTransformer;
import java.util.List;
// 1) 定义格式化规则:向量化时排除哪些元数据、内容与元数据怎么拼
DefaultContentFormatter formatter = DefaultContentFormatter.builder()
.withExcludedEmbedMetadataKeys("source", "chunk_index") // 向量化时排除
.build();
// 2) 包成一个 DocumentTransformer
DocumentTransformer formatTransformer = new ContentFormatTransformer(formatter);
// 3) 应用到文档:统一各文档"内容 + 元数据"的呈现格式
List<Document> formatted = formatTransformer.apply(documents);
它不改文档正文,只决定"哪些元数据可见、最终拼成什么样"。常用于向量化前统一口径,提升后续检索的可控性。
3.3.2 KeywordMetadataEnricher ------ LLM 抽取关键词
java
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentTransformer;
import org.springframework.ai.model.transformer.KeywordMetadataEnricher;
import java.util.List;
// 需要注入 ChatModel(Spring AI Alibaba 的 DashScope starter 会自动装配)
// private final ChatModel chatModel;
// 1) 用 Builder 构造:抽取 5 个关键词
DocumentTransformer keywordEnricher = KeywordMetadataEnricher.builder(chatModel)
.keywordCount(5)
.build();
// 2) 应用到文档:关键词以逗号分隔,写入元数据 excerpt_keywords
List<Document> enriched = keywordEnricher.apply(documents);
String keywords = (String) enriched.get(0).getMetadata().get("excerpt_keywords");
也支持传自定义模板的构造函数
KeywordMetadataEnricher(chatModel, PromptTemplate);一旦指定模板,keywordCount会被忽略。注意它每次都要调用一次 LLM,有耗时与成本,别在超大语料上无脑全量跑。
3.3.3 SummaryMetadataEnricher ------ LLM 生成摘要
java
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentTransformer;
import org.springframework.ai.model.transformer.SummaryMetadataEnricher;
import org.springframework.ai.model.transformer.SummaryMetadataEnricher.SummaryType;
import java.util.List;
// 1) 生成"上一段 / 当前段 / 下一段"摘要,用于上下文增强
DocumentTransformer summaryEnricher = new SummaryMetadataEnricher(chatModel,
List.of(SummaryType.PREVIOUS, SummaryType.CURRENT, SummaryType.NEXT));
// 2) 摘要写入元数据:section_summary / prev_section_summary / next_section_summary
List<Document> enriched = summaryEnricher.apply(documents);
String current = (String) enriched.get(0).getMetadata().get("section_summary");
同样需要
ChatModel,且一次要生成多个摘要,成本更高。典型用途是"父子文档检索":检索用大块原文,回答时用相邻摘要补上下文。
一句话划清界限 :这三个内置 Transformer 都不去掉任何脏字符 ,而是"把文档加工成另一种形态"(格式化或补元数据)。真正"去除脏东西"的活,必须靠第 5 节自己写的DocumentTransformer。
4. 关键约定:用 mutate() 保留元数据
清洗过程中会不断产生新 Document,我们必须让 source、page 等元数据跟着走,否则检索结果就丢了溯源信息。
Document.mutate() 返回一个携带原文档所有信息 的 Builder,改掉正文后再 build(),就得到一份"换了正文、但 id 与元数据原封不动"的新文档:
java
Document cleaned = doc.mutate()
.text("清洗后的正文")
.build();
// 等价于:新正文 + 老 id + 老元数据
本文所有清洗器都遵循这一约定:只改
text,别动元数据。
5. 自定义清洗 Transformer
下面用五个"小而专"的转换器覆盖最常见的清洗场景。每个都很短,可独立使用,也可串联。
5.1 清洗 HTML 标签
java
import org.jsoup.Jsoup;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentTransformer;
import java.util.List;
/**
* 清洗 HTML:去掉 <script>、<style>、所有标签,只保留正文文本。
*
* 依赖 jsoup(org.jsoup:jsoup)。用 Jsoup 解析而非手写正则,
* 是因为 HTML 结构复杂,正则在面对嵌套标签、实体编码时很容易翻车。
*/
public class HtmlCleanTransformer implements DocumentTransformer {
@Override
public List<Document> apply(List<Document> documents) {
return documents.stream()
.map(this::clean)
.toList();
}
private Document clean(Document doc) {
// 媒体类文档没有 text,先兜底为空串
String raw = doc.getText() == null ? "" : doc.getText();
// Jsoup.parse(...).text():去掉脚本/样式/标签,并把正文压成纯文本
String cleaned = Jsoup.parse(raw).text();
// mutate():换正文,保留 id 与元数据
return doc.mutate().text(cleaned).build();
}
}
5.2 空白与换行规范化
java
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentTransformer;
import java.util.List;
/**
* 空白规范化:
* 1) 合并连续空格 / 制表符 / 不换行空格为单个空格(保留换行,避免句子被粘在一起);
* 2) 折叠 3 个及以上连续换行为一个空行;
* 3) 去掉首尾空白。
*/
public class WhitespaceNormalizer implements DocumentTransformer {
@Override
public List<Document> apply(List<Document> documents) {
return documents.stream().map(this::normalize).toList();
}
private Document normalize(Document doc) {
String text = doc.getText() == null ? "" : doc.getText();
// 空格、制表符、不换行空格(NBSP)
String s = text.replaceAll("[ \\t\\u00A0]+", " ");
s = s.replaceAll("\\n{3,}", "\n\n");
s = s.trim();
return doc.mutate().text(s).build();
}
}
5.3 清理控制字符、零宽字符、乱码符
java
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentTransformer;
import java.util.List;
import java.util.regex.Pattern;
/**
* 清理"不可见 / 垃圾"字符:
* - 控制字符(保留 \t、\n)
* - DEL(\x7F)
* - 零宽字符(零宽空格等)
* - BOM(字节序标记)
* - 乱码替换符 �(常因 GBK 被当作 UTF-8 读取产生)
*/
public class ControlCharacterCleaner implements DocumentTransformer {
private static final Pattern JUNK = Pattern.compile(
"[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F\\u200B-\\u200F\\uFEFF\\uFFFD]");
@Override
public List<Document> apply(List<Document> documents) {
return documents.stream().map(this::clean).toList();
}
private Document clean(Document doc) {
String text = doc.getText() == null ? "" : doc.getText();
String cleaned = JUNK.matcher(text).replaceAll("");
return doc.mutate().text(cleaned).build();
}
}
⚠️ 关于乱码(
�)的忠告 :用正则删�只是"兜底止损",因为字符已被破坏、信息无法找回。根治要在加载阶段做对字符集 (见 3.2 节:读文件时显式指定Charset,或直接用TikaDocumentReader自动探测)。清洗层能做的,是"别再让这些坏字符继续污染向量"。
5.4 过滤过短 / 过长的碎片
java
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentTransformer;
import java.util.List;
/**
* 长度过滤:
* - 过短的碎片(如只剩一个标题)几乎没有可检索语义,直接丢弃;
* - 过长的"碎片"通常是没切干净的整篇,会稀释检索精度,同样可丢弃。
* 阈值可按业务语料调整。
*/
public class FragmentFilter implements DocumentTransformer {
private final int minChars;
private final int maxChars;
public FragmentFilter(int minChars, int maxChars) {
this.minChars = minChars;
this.maxChars = maxChars;
}
@Override
public List<Document> apply(List<Document> documents) {
return documents.stream()
.filter(this::keep) // 注意:这里用 filter,不是 map------清洗也可以"做减法"
.toList();
}
private boolean keep(Document doc) {
String text = doc.getText();
if (text == null) {
return false;
}
int len = text.trim().length();
return len >= minChars && len <= maxChars;
}
}
5.5 去重
java
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentTransformer;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 基于正文内容去重:
* 把"归一化后的正文"当作唯一键,保留第一次出现的文档(用 LinkedHashMap 维持原顺序)。
* 归一化 = 去掉首尾空白 + 把所有空白折叠为空串,避免"多一个空格就判为不同"的误判。
*/
public class DeduplicatingTransformer implements DocumentTransformer {
@Override
public List<Document> apply(List<Document> documents) {
Map<String, Document> unique = new LinkedHashMap<>();
for (Document doc : documents) {
String text = doc.getText() == null ? "" : doc.getText();
String key = text.strip().replaceAll("\\s+", "");
unique.putIfAbsent(key, doc);
}
return List.copyOf(unique.values());
}
}
若数据量很大,可把
key换成正文的哈希(如 SHA-256),避免在内存里存整段原文做键。上面的示例为了直观,直接用文本本身做键。
6. 串联组合:拼出清洗流水线
DocumentTransformer 是 Function,既可以用 andThen 简洁串联,也可以显式嵌套(可读性更好、返回类型仍是 DocumentTransformer):
java
import org.springframework.ai.document.DocumentTransformer;
import java.util.List;
import java.util.function.Function;
DocumentTransformer htmlCleaner = new HtmlCleanTransformer();
DocumentTransformer normalizer = new WhitespaceNormalizer();
DocumentTransformer controlCleaner = new ControlCharacterCleaner();
DocumentTransformer fragmentFilter = new FragmentFilter(20, 5000);
DocumentTransformer deduper = new DeduplicatingTransformer();
// 方式一:利用 Function.andThen 串联(返回的是 Function,不是 DocumentTransformer)
Function<List<Document>, List<Document>> pipeline1 =
htmlCleaner.andThen(normalizer).andThen(controlCleaner).andThen(fragmentFilter).andThen(deduper);
// 方式二:显式嵌套(返回 DocumentTransformer,可直接作为 Bean,推荐)
DocumentTransformer pipeline2 = docs ->
deduper.apply(
fragmentFilter.apply(
controlCleaner.apply(
normalizer.apply(
htmlCleaner.apply(docs)))));
List<Document> cleaned = pipeline2.apply(dirtyDocs);
顺序很重要:先标签清洗 → 再空白规范化 → 再清控制字符 → 再过滤 / 去重。最后做去重和过滤,是因为此时文本已经规整,判断"是否重复 / 是否过短"最准确。
7. 最佳实践
-
清洗只动
text,别动元数据 。始终用doc.mutate().text(...).build(),让source、page等溯源信息一路继承下去。 -
去重要放在清洗之后。文本规整后,基于正文归一化判断"是否重复"才最准确。
-
能在源头解决的,别拖到清洗层 。乱码用
TikaDocumentReader自动探测编码;网页正文用JsoupDocumentReader选择器抽取------都比事后写正则删�可靠。 -
别过度清洗 。清洗的目标是"干净、规整",不是"极简"。例如别一刀切删掉所有标点、别把换行全抹平------标点和换行往往是下游切分器判断语义边界的依据。先弄清下游依赖什么,再决定清洗到什么程度。
-
清洗器保持幂等。同一个转换器跑两遍,结果应当一致(上面所有实现都满足)。这样重跑 ETL 任务时不会引入副作用。
-
阈值可配置 。
FragmentFilter的最小/最大长度,随语料不同而不同,建议外置到配置项,方便调优。 -
写断言验证。清洗逻辑非常适合用单测锁住行为,例如:
java
@Test
void htmlCleanerShouldStripTags() {
Document dirty = new Document("<p>你好</p>", Map.of());
List<Document> result = new HtmlCleanTransformer().apply(List.of(dirty));
assertEquals("你好", result.get(0).getText());
assertEquals(dirty.getId(), result.get(0).getId()); // id / 元数据不丢
}