LangChain4j 进阶:如何为 Markdown 文档构建智能标题分割器

📅 发布时间:2026/7/10 3:37:10 👁️ 浏览次数:
LangChain4j 进阶:如何为 Markdown 文档构建智能标题分割器
1. 为什么我们需要一个智能的 Markdown 标题分割器如果你处理过大量的技术文档、产品手册或者个人笔记你肯定遇到过这样的场景你有一个上百页的 Markdown 文档里面包含了从项目介绍、API 说明到故障排查的各种内容。当你想要把这些文档喂给大语言模型比如 ChatGPT、Claude 或者本地部署的模型来构建一个智能问答系统时直接扔进去一个巨大的文件往往效果很差。模型可能会“迷失”在信息的海洋里无法精准定位到与问题最相关的部分。这时候把大文档“切”成小块再配上合适的上下文就成了关键。最自然的切割方式是什么就是按照文档本身的结构——标题。一个以“# 安装指南”开头的段落显然应该和“# API 参考”分开。但 LangChain4j 这个强大的 Java AI 应用开发框架在它的早期版本里并没有提供一个现成的、专门为 Markdown 标题结构设计的文本分割器Text Splitter。它提供了按字符数、按句子分割的组件但对于我们这种结构化的 Markdown就显得有点“笨”了。我当初就遇到了这个问题。我需要处理一堆开源项目的 README 和 Wiki它们都是 Markdown 格式。简单的按固定长度分割会把一个完整的代码示例拦腰截断或者把标题和它的正文内容分开这完全破坏了语义。所以我决定自己动手丰衣足食为 LangChain4j 实现一个MarkdownHeaderTextSplitter。这个分割器的核心目标很明确尊重文档的原始结构按照标题层级进行智能分割并且把标题路径作为元数据Metadata自动附加到每一块文本上。这样后续的向量检索就能利用这些标题信息更精准地找到相关内容。2. 核心设计思路像人一样阅读文档在动手写代码之前我们先想想人是如何阅读和理解一篇 Markdown 文档的我们会先扫一眼大纲看到一级标题#是主章节二级标题##是子章节然后逐级深入。在阅读某个具体段落时我们心里很清楚这段文字是属于“第二章 - 第三节 - 第一部分”的。我们的分割器就是要模拟这个过程。2.1 标题栈与上下文路径这是整个设计的灵魂。想象一下你在读文档当前读到“### 快速安装”这个小节。你的大脑里会有一个“面包屑导航”[# 项目介绍, ## 开始使用, ### 快速安装]。我们的分割器也需要维护这样一个“标题栈”Header Stack。每当遇到一个新的标题行比如## 核心功能分割器就会做以下几件事判断层级计算标题的级别#的数量。#是1级##是2级以此类推。更新栈比较新标题和栈顶标题的级别。如果新标题级别更高或同级就弹出栈顶直到栈顶级别低于新标题然后再压入新标题。这保证了栈始终反映当前的阅读路径。例如栈里是[# A, ## A.1]遇到### A.1.a就直接压入遇到## A.2就要先弹出## A.1再压入## A.2。生成元数据将栈里的所有标题信息按照我们预先的配置转换成键值对存入当前文本块的元数据中。比如我们可以配置#对应chapter##对应section那么当前块的元数据可能就是{“chapter”: “项目介绍” “section”: “核心功能”}。这样每一块被分割出来的文本都自带“户口本”清楚标明了自己来自文档的哪个部分。这在后续的向量化存储和检索中价值巨大。2.2 绕过代码块保护技术文档的“灵魂”技术文档里充斥着代码块用包裹。按行分割时绝对不能把代码块内部的“#”或“##”误认为是标题。因此分割器必须有一个状态机来跟踪是否处于代码块内部。一旦检测到以或 ~~~ 开头的行就进入“代码块模式”在此模式下所有行都原样收集不做标题识别直到遇到结束的 。这个细节处理不好整个分割就乱套了我早期版本就踩过这个坑。2.3 灵活的切割与合并策略不同的下游任务对文本块的大小和形态可能有不同要求。我们的分割器提供了两个关键参数来控制splitByLine如果设为true那么分割器会在每个标题处进行“硬切割”产生多个小的TextSegment。这适合需要非常细粒度控制的场景。如果设为false分割器则只记录标题变化点但会将属于同一标题层级下的连续文本直到下一个同级或更高级标题出现合并成一个大的TextSegment。这能保证一个逻辑段落的完整性。removeHeaderLine这个参数决定是否在最终的文本块内容里保留标题行本身。有时为了让文本块更“干净”或者标题信息已完全体现在元数据里了我们可以选择移除标题行。但有些场景下保留标题行能让文本块自身更具可读性。3. 手把手实现从零构建 MarkdownHeaderTextSplitter理论说完了我们来看代码。我会带你一步步理解关键部分。首先我们需要让我们的类实现 LangChain4j 的DocumentSplitter接口。package com.yourcompany.splitter; // 换成你的包名 import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.DocumentSplitter; import dev.langchain4j.data.document.Metadata; import dev.langchain4j.data.segment.TextSegment; import java.util.*; import java.util.stream.Collectors; public class MarkdownHeaderTextSplitter implements DocumentSplitter { // 核心配置标题前缀如“##”到元数据键名如“section”的映射 private final ListMap.EntryString, String headerPrefixToKey; private final boolean splitByLine; private final boolean removeHeaderLine; // 构造函数接收配置 public MarkdownHeaderTextSplitter(MapString, String headerPrefixToKey, boolean splitByLine, boolean removeHeaderLine) { // 对映射进行排序确保更长的前缀如“###”优先匹配避免“#”误匹配“##” this.headerPrefixToKey headerPrefixToKey.entrySet().stream() .sorted(Comparator.comparingInt(e - -e.getKey().length())) .collect(Collectors.toList()); this.splitByLine splitByLine; this.removeHeaderLine removeHeaderLine; } Override public ListTextSegment split(Document document) { // 将文档内容按行拆分 ListString lines Arrays.asList(document.text().split(\n)); ListSegmentChunk chunks new ArrayList(); // 存放初步分割的块 ListString buffer new ArrayList(); // 临时缓冲区积累当前块的内容 // 基础元数据继承自原始文档并会附加标题路径 MapString, Object baseMetadata new HashMap(document.metadata().toMap()); ListHeaderInfo headerStack new ArrayList(); // 核心的标题栈 boolean inCodeBlock false; String codeFence ; // 记录当前代码块的围栏符号或~~~ for (String line : lines) { String trimmed line.trim(); // 1. 代码块状态检测与处理 if (!inCodeBlock (trimmed.startsWith() || trimmed.startsWith(~~~))) { inCodeBlock true; codeFence trimmed.substring(0, 3); // 记录围栏类型 } else if (inCodeBlock trimmed.startsWith(codeFence)) { inCodeBlock false; codeFence ; } if (inCodeBlock) { buffer.add(line); // 代码块内的行直接加入缓冲区 continue; // 跳过标题检测 } // 2. 标题行检测与处理 boolean isHeader false; for (Map.EntryString, String entry : headerPrefixToKey) { String prefix entry.getKey(); // 例如 “##” String metaKey entry.getValue(); // 例如 “section” // 判断是否是当前配置的标题以prefix开头且后面是空格或行尾 if (trimmed.startsWith(prefix) (trimmed.length() prefix.length() || trimmed.charAt(prefix.length()) )) { int level (int) prefix.chars().filter(ch - ch #).count(); // 计算标题级别 String title trimmed.substring(prefix.length()).trim(); // 提取标题文本 // 3. 更新标题栈弹出级别高于或等于当前标题的栈顶元素 while (!headerStack.isEmpty() headerStack.get(headerStack.size() - 1).level level) { baseMetadata.remove(headerStack.remove(headerStack.size() - 1).key); } // 将新标题压栈 headerStack.add(new HeaderInfo(level, metaKey, title)); // 将新标题信息放入元数据映射 baseMetadata.put(metaKey, title); // 4. 遇到新标题意味着上一个内容块结束如果缓冲区有内容 if (!buffer.isEmpty()) { chunks.add(new SegmentChunk(String.join(\n, buffer), new Metadata(baseMetadata))); buffer.clear(); } // 5. 根据参数决定是否将标题行本身加入新块的内容 if (!removeHeaderLine) { buffer.add(line); } isHeader true; break; // 匹配到一个标题前缀就跳出循环 } } // 如果不是标题行就是普通内容行加入缓冲区 if (!isHeader) { buffer.add(line); } } // 处理文档末尾最后一块内容 if (!buffer.isEmpty()) { chunks.add(new SegmentChunk(String.join(\n, buffer), new Metadata(baseMetadata))); } // 6. 根据 splitByLine 参数决定最终输出形式 return splitByLine ? chunksToLines(chunks) : mergeChunks(chunks); } // 当 splitByLine 为 true 时每个 SegmentChunk 直接转为 TextSegment private ListTextSegment chunksToLines(ListSegmentChunk chunks) { return chunks.stream() .map(c - new TextSegment(c.content, c.metadata)) .collect(Collectors.toList()); } // 当 splitByLine 为 false 时合并具有相同元数据的连续块 private ListTextSegment mergeChunks(ListSegmentChunk chunks) { ListSegmentChunk merged new ArrayList(); for (SegmentChunk chunk : chunks) { if (!merged.isEmpty() merged.get(merged.size() - 1).metadata.equals(chunk.metadata)) { // 如果上一个块和当前块元数据相同则合并内容 SegmentChunk last merged.get(merged.size() - 1); last.content \n chunk.content; } else { merged.add(chunk); } } return merged.stream() .map(c - new TextSegment(c.content, c.metadata)) .collect(Collectors.toList()); } // 内部类用于暂存分割后的块和其元数据 private static class SegmentChunk { String content; Metadata metadata; SegmentChunk(String content, Metadata metadata) { this.content content; this.metadata metadata; } } // 内部类记录标题栈中的每一项信息 private static class HeaderInfo { int level; // 标题级别#的数量 String key; // 对应的元数据键如 “section” String title; // 标题文本 HeaderInfo(int level, String key, String title) { this.level level; this.key key; this.title title; } } }这段代码看起来不短但逻辑是清晰的流水线逐行扫描 - 识别代码块 - 识别标题 - 更新栈和元数据 - 切割/合并文本块。你可以把它直接复制到你的项目里使用。4. 实战演练如何配置和使用它光有代码不够我们得知道怎么用。下面我通过几个具体的例子展示不同配置下的分割效果。4.1 基础用法构建文档知识库假设我们有一个简单的产品手册 Markdown 文档内容如下# 产品概述 AwesomeApp 是一个革命性的生产力工具。 ## 安装 请按照以下步骤安装。 ### Windows 安装 1. 下载安装包。 2. 双击运行。 ### macOS 安装 1. 使用 Homebrew。 2. 或者下载 DMG。 ## 使用指南 这里是使用说明。我们想按照章节进行分割并且保留标题路径信息。可以这样配置和使用分割器import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.Metadata; import dev.langchain4j.data.segment.TextSegment; import java.util.List; import java.util.Map; public class Demo { public static void main(String[] args) { String markdownContent # 产品概述\nAwesomeApp 是一个革命性的生产力工具。\n\n## 安装\n请按照以下步骤安装。\n\n### Windows 安装\n1. 下载安装包。\n2. 双击运行。\n\n### macOS 安装\n1. 使用 Homebrew。\n2. 或者下载 DMG。\n\n## 使用指南\n这里是使用说明。; Document document new Document(markdownContent, new Metadata()); // 配置映射关系标题符号 - 元数据字段名 MapString, String headers Map.of( #, chapter, // 一级标题存入 chapter 字段 ##, section, // 二级标题存入 section 字段 ###, subsection // 三级标题存入 subsection 字段 ); // 创建分割器不按行硬切并移除内容中的标题行 MarkdownHeaderTextSplitter splitter new MarkdownHeaderTextSplitter(headers, false, true); ListTextSegment segments splitter.split(document); for (TextSegment segment : segments) { System.out.println( 内容块 ); System.out.println(segment.text()); System.out.println(元数据: segment.metadata().toMap()); System.out.println(); } } }运行后你可能会得到类似这样的输出具体块的数量取决于splitByLine参数 内容块 AwesomeApp 是一个革命性的生产力工具。 元数据: {chapter产品概述} 内容块 请按照以下步骤安装。 元数据: {chapter产品概述, section安装} 内容块 1. 下载安装包。 2. 双击运行。 元数据: {chapter产品概述, section安装, subsectionWindows 安装} 内容块 1. 使用 Homebrew。 2. 或者下载 DMG。 元数据: {chapter产品概述, section安装, subsectionmacOS 安装} 内容块 这里是使用说明。 元数据: {chapter产品概述, section使用指南}看每个内容块都清晰地知道自己属于哪个章节。当用户提问“如何在 Windows 上安装”你的检索系统就可以利用subsection元数据字段精准地定位到第三个块将其作为上下文提供给大模型从而得到非常准确的答案。4.2 高级配置应对复杂文档结构不是所有文档都严格遵循######的结构。有些文档可能只用##和###或者有自定义的标题样式。我们的分割器通过映射配置可以灵活应对。场景一只关心二级和三级标题如果你的文档一级标题只是文档标题你更关心内部结构可以这样配置MapString, String headers Map.of( ##, main_section, ###, sub_section ); // 这样一级标题 (#) 就不会触发分割其内容会并入文档开头直到第一个二级标题出现。场景二需要细粒度控制splitByLinetrue如果你希望每个标题都成为一个独立的分割点即使下面内容很少可以开启splitByLine。这在构建需要极高召回率的问答系统时可能有过因为每个标题都成了一个独立的、可被检索的单元。MarkdownHeaderTextSplitter splitter new MarkdownHeaderTextSplitter(headers, true, false);场景三保留标题行文本有时标题文字本身富含关键词比如“常见错误代码 0x80070005”保留在内容中有助于向量模型理解。这时就不移除标题行。MarkdownHeaderTextSplitter splitter new MarkdownHeaderTextSplitter(headers, false, false);5. 集成到 LangChain4j 应用流这个分割器的真正威力在于无缝嵌入到 LangChain4j 的文档处理流水线中。一个典型的从文档加载到向量存储的流程如下import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.loader.FileSystemDocumentLoader; import dev.langchain4j.data.document.parser.TextDocumentParser; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.store.embedding.EmbeddingStoreIngestor; import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; // 假设你有一个嵌入模型 import dev.langchain4j.model.embedding.EmbeddingModel; import java.nio.file.Paths; import java.util.Map; public class IngestPipeline { public static void main(String[] args) { // 1. 加载 Markdown 文档 Document document FileSystemDocumentLoader.loadDocument( Paths.get(path/to/your/docs.md), new TextDocumentParser() ); // 2. 创建我们的智能标题分割器 MapString, String headerMapping Map.of( #, chapter, ##, section, ###, subsection ); MarkdownHeaderTextSplitter splitter new MarkdownHeaderTextSplitter(headerMapping, false, true); // 3. 分割文档 ListTextSegment segments splitter.split(document); // 4. 创建内存向量存储生产环境可用 Chroma、Postgres等 InMemoryEmbeddingStoreTextSegment embeddingStore new InMemoryEmbeddingStore(); // 5. 创建嵌入模型这里需要实际模型如AllMiniLmL6V2EmbeddingModel // EmbeddingModel embeddingModel new AllMiniLmL6V2EmbeddingModel(); // 6. 构建摄取管道将分割后的片段转换为向量并存储 EmbeddingStoreIngestor ingestor EmbeddingStoreIngestor.builder() .documentSplitter(splitter) // 使用我们的分割器 // .embeddingModel(embeddingModel) // 设置嵌入模型 .embeddingStore(embeddingStore) .build(); // 7. 摄取文档 ingestor.ingest(document); System.out.println(文档已智能分割并存入向量库共生成 segments.size() 个带上下文的文本片段。); // 现在你可以使用 embeddingStore 进行基于语义的检索了。 } }通过这个管道你的 Markdown 文档不再是扁平的一堆文字而是变成了一个结构清晰、带有丰富层级信息的“知识图谱”的素材。当进行检索时你不仅可以做语义匹配还可以结合元数据进行过滤比如“只检索‘安装’章节下的内容”这能极大提升问答系统的准确性和可控性。6. 性能优化与边界情况处理在实际项目中投入使用后你可能会遇到一些需要进一步优化和处理的边界情况。性能考量对于超大型文档数万行逐行扫描的算法是 O(n) 复杂度完全可接受。主要的开销在于字符串操作和元数据对象的创建。如果遇到性能瓶颈可以考虑对连续的非标题文本行进行缓冲区的批量追加而不是逐行拼接。不过在我的经验里对于常规的技术文档这个实现已经足够快。处理非标准 Markdown有些文档编写者可能不规范在#和标题文字之间没有空格或者使用了非#的标题格式如 Setext 风格的或---。目前的实现是基于#前缀的。要支持 Setext 风格需要扩展标题检测逻辑。一个简单的增强方法是在检测到一行仅由或-组成且其上一行不为空时将上一行识别为标题。这需要你根据实际处理的文档源来决定是否增加这部分代码。元数据键冲突我们的实现中元数据键如section的值会被最新遇到的标题覆盖。这在大多数情况下是符合预期的因为一个文本块只属于一个特定的章节。但如果你需要保留完整的历史路径例如想要section_path: [安装, Windows 安装]这样的数组就需要修改HeaderInfo和元数据更新的逻辑将值存储为列表。空内容块如果两个标题紧挨着中间没有内容可能会产生一个空的内容块。目前的实现在if (!buffer.isEmpty())的判断下会避免添加空块。这是一个合理的行为。实现这个MarkdownHeaderTextSplitter的过程让我深刻体会到在 AI 应用开发中对原始数据的预处理和结构化往往比模型本身的选择更重要。一个好的分割策略能让下游的检索和生成任务事半功倍。这个组件我已经在几个内部知识库项目中稳定使用它显著提升了问答的准确率。如果你也在用 LangChain4j 处理 Markdown 文档不妨试试这个思路或者直接采用这段代码相信它能帮你解决文档切割的痛点。