RAG检索增强生成:文本清洗

技术栈:Spring Boot 3.5.9 · Spring AI 1.1.2 · Spring AI Alibaba 1.1.2.2 · JDK 21+

前提:本文默认文档已加载为 Documentorg.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);
    }
}

这意味着:

  1. 只要实现 apply,你就是一个清洗器------无需继承任何复杂基类。
  2. 它继承自 Function,所以天然支持 andThen / compose 做函数式串联。

3. 内置的清洗能力

3.1 先说结论:没有专门的 TextCleaner

Spring AI 核心里没有 一个名为 TextCleaner 的"万能清洗器"。所谓"内置清洗能力",其实分散在两处,且定位完全不同

  1. 读取器(DocumentReader)内置的清洗 ------ 在加载阶段就把格式噪音、编码问题解决掉("源头清洗")。
  2. 内置的 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,我们必须让 sourcepage 等元数据跟着走,否则检索结果就丢了溯源信息。

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. 串联组合:拼出清洗流水线

DocumentTransformerFunction,既可以用 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. 最佳实践

  1. 清洗只动 text,别动元数据 。始终用 doc.mutate().text(...).build(),让 sourcepage 等溯源信息一路继承下去。

  2. 去重要放在清洗之后。文本规整后,基于正文归一化判断"是否重复"才最准确。

  3. 能在源头解决的,别拖到清洗层 。乱码用 TikaDocumentReader 自动探测编码;网页正文用 JsoupDocumentReader 选择器抽取------都比事后写正则删 可靠。

  4. 别过度清洗 。清洗的目标是"干净、规整",不是"极简"。例如别一刀切删掉所有标点、别把换行全抹平------标点和换行往往是下游切分器判断语义边界的依据。先弄清下游依赖什么,再决定清洗到什么程度。

  5. 清洗器保持幂等。同一个转换器跑两遍,结果应当一致(上面所有实现都满足)。这样重跑 ETL 任务时不会引入副作用。

  6. 阈值可配置FragmentFilter 的最小/最大长度,随语料不同而不同,建议外置到配置项,方便调优。

  7. 写断言验证。清洗逻辑非常适合用单测锁住行为,例如:

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 / 元数据不丢
}
相关推荐
VIP_CQCRE2 小时前
在 VS Code、Cursor、Windsurf 里接入 Ace Data Cloud:OpenCode IDE Extension 配置指南
ai编程·vs code·cursor·opencode·ace data cloud
柳杉3 小时前
从调研到闭环,我用 GPT-Image-2.5 和 GPT-6 Astra 把智慧厂房 3D 大屏这条链路走完了
前端·ai编程·数据可视化
Carson带你学Android3 小时前
ADK for Kotlin:Google 官方 AI Agent 教程来了
android·agent·ai编程
全栈弄潮儿4 小时前
周复盘:这一周最值得保存的 7 条 AI 编程原则
aigc·openai·ai编程
架构指南4 小时前
macOS 实测:Codex 桌面版双开,官方账号与第三方 API 独立使用
chatgpt·ai编程
VIP_CQCRE4 小时前
在 OpenCode IDE 里接入 Ace Data Cloud:把多模型 AI 能力带进开发工作流
大模型·ai编程·开发工具·opencode·ace data cloud
楚国的小隐士11 小时前
在生产环境中和AI协作编程
ai·大模型·编程·软件工程·ai编程·软件架构
wangruofeng12 小时前
一个 Markdown 文件攒下 37k 星,i-have-adhd 给 AI 输出立了 10 条规矩
github·aigc·ai编程
全栈弄潮儿15 小时前
需求不清时,如何让 AI 帮你补全问题,而不是瞎写代码
aigc·openai·ai编程