Document组件文件预处理:AI投喂前的三步必做清单
读完本文你将掌握
先明确几个关键判断。想让AI精准回答企业内部知识库问题,这个愿景很诱人,但存在一个现实瓶颈:AI的上下文窗口有限,无法一次性消化整本手册。
工程上如何突破?标准解法是 RAG(检索增强生成),核心逻辑并不复杂:
- 建库:将文档分割成小块,计算向量,存入向量数据库
- 检索:用户提问 → 系统召回最相关的几个片段 → AI基于这些片段生成答案
第一步“建库”正是 Document 组件的职责:加载 → 解析 → 切片。下面逐步拆解。
一、Document 的数据结构
源码位于 eino/schema/document.go,结构体精简到只有三个字段:
type Document struct {
ID string // 该片段的唯一标识
Content string // 实际文本内容
MetaData map[string]any // 关联的元数据
}
Content 存储正文,MetaData 记录来源、分数、向量等附加信息。
举例说明,从HTML页面解析出的Document示例:
{
"id": "doc-001",
"content": "Go 是 Google 开发的编程语言,设计目标是简洁、高效...",
"meta_data": {
"_source": "https://example.com/go-intro.html",
"_title": "Go 语言简介",
"_language": "zh"
}
}
MetaData并非普通map——它提供了一系列封装好的方法用于访问内部数据:
doc.Score() // 获取检索相关性分数(无需手动从map中提取)
doc.DenseVector() // 获取向量
doc.ExtraInfo() // 获取附加说明
doc.SubIndexes() // 获取多分区路由索引
这些值底层存储在MetaData的保留key中(_score、_dense_vector 等),但对外暴露的是封装方法,避免直接操作原始map。这种设计清晰分离了内部实现与外部接口。
二、Loader:负责将原始文件搬运到系统
接口定义在 eino/components/document/interface.go:
type Loader interface {
Load(ctx context.Context, src Source, opts ...LoaderOption) ([]*schema.Document, error)
}type Source struct {
URI string // 文件路径或 URL
}
接口极简,职责单一。eino-ext提供了三种现成实现:
| Loader | 适用场景 | URI 示例 |
|---|---|---|
file.FileLoader | 读取本地文件 | /path/to/file.md |
url.Loader | 抓取网页内容 | https://... |
s3.Loader | 读取 AWS S3 对象存储 | s3://bucket/key |
这里有一个重要设计原则:Loader 不关心文件格式。它只负责读取字节流,至于格式解析工作交由下一层 Parser 完成。两者解耦带来明确优势:更换文件格式无需修改Loader,更换数据源也无需改动Parser。
// FileLoader 内部核心逻辑示意:
func (f *FileLoader) Load(ctx context.Context, src Source, opts ...LoaderOption) ([]*schema.Document, error) {
file, _ := os.Open(src.URI)
defer file.Close()
// 将文件流交给 Parser,扩展名通过 URI 传递
return f.parser.Parse(ctx, file, parser.WithURI(src.URI))
}
三、Parser:负责将不同格式转换为纯文本
接口定义在 eino/components/document/parser/interface.go:
type Parser interface {
Parse(ctx context.Context, reader io.Reader, opts ...Option) ([]*schema.Document, error)
}
接受字节流,返回 Document 列表。
TextParser:最基础的纯文本解析器
直接将整个流读为字符串,生成一个 Document。适用于 .txt、.md 等无格式纯文本文件。
HTMLParser:专为网页设计
来自 eino-ext,底层基于 goquery(Go语言的jQuery)操作 DOM。
// 源码:eino-ext/components/document/parser/html/html.go
htmlParser, _ := html.NewParser(ctx, &html.Config{
Selector: gptr.Of("body"), // 使用 CSS 选择器仅提取 body 内容
})
解析完成后,自动从页面中提取 meta 信息并写入 MetaData:
_title <- 标签内容
_description <- name="description"> 内容
_language <- lang="..."> 属性
_charset <- 字符编码
_source <- 来源 URL
安全方面,采用 bluemonday UGC 策略过滤危险HTML标签,有效防范XSS攻击,避免恶意脚本被存入知识库。
ExtParser:按扩展名自动路由
当需要处理多种格式文件时:
// 源码:eino/components/document/parser/ext_parser.go
extParser, _ := parser.NewExtParser(ctx, &parser.ExtParserConfig{
Parsers: map[string]parser.Parser{
".html": htmlParser,
".pdf": pdfParser,
".docx": docxParser,
},
FallbackParser: parser.TextParser{}, // 未匹配格式时采用默认解析
})// 注意:必须传入 URI,否则 ExtParser 无法确定采用哪个 Parser
docs, _ := extParser.Parse(ctx, file, parser.WithURI("./report.html"))
eino-ext 目前支持的格式:HTML、PDF(支持逐页或合并)、Word(docx,可按节切分)、Excel(xlsx,逐行转为Document)。
四、Transformer:负责将大块文档切分为小块
一篇几万字的文章,不切片根本无法喂入向量数据库。Transformer 就是完成这个切割任务的组件:
// 源码:eino/components/document/interface.go
type Transformer interface {
Transform(ctx context.Context, src []*schema.Document, opts ...TransformerOption) ([]*schema.Document, error)
}
输入一批Document,输出更多更小的Document。eino-ext 提供了四种切片策略,适用不同场景。
策略 1:RecursiveSplitter(通用首选)
源码:eino-ext/components/document/transformer/splitter/recursive/recursive.go
按分隔符递归切分。先尝试 \n,块过大则换 .,再过大则换 ?……直到每个块都符合大小要求为止。
splitter, _ := recursive.NewSplitter(ctx, &recursive.Config{
ChunkSize: 1500, // 每块最多 1500 字符
OverlapSize: 300, // 相邻块重叠 300 字符,保留边界上下文
Separators: []string{"n", ".", "?", "!"},
KeepType: recursive.KeepTypeNone, // 分隔符本身不保留
})
OverlapSize 是重要参数:切块边界处的内容会在相邻两块中重复,有效避免句子被切断后语义丢失。
// 示例(源码:recursive/examples/main.go)
data, _ := os.ReadFile("./document.md")
docs, _ := splitter.Transform(ctx, []*schema.Document{{Content: string(data)}})
fmt.Printf("切成了 %d 块n", len(docs))
策略 2:MarkdownHeaderSplitter(结构化文档利器)
源码:eino-ext/components/document/transformer/splitter/markdown/header.go
按照Markdown标题层级进行切分,每块自动继承父级标题信息写入MetaData:
splitter, _ := markdown.NewHeaderSplitter(ctx, &markdown.HeaderConfig{
Headers: map[string]string{
"#": "chapter", // 一级标题映射到 metadata.key "chapter"
"##": "section", // 二级标题映射到 metadata.key "section"
},
TrimHeaders: true, // 切分后块中不包含标题行本身
})
切分后的Document携带结构化MetaData:
{
"content": "Go 的并发模型基于 CSP...",
"meta_data": {
"chapter": "第三章 并发编程",
"section": "3.1 Goroutine 基础"
}
}
检索时可按章节过滤,而非全量搜索,精准度大幅提升。
策略 3:HTMLHeaderSplitter
源码:eino-ext/components/document/transformer/splitter/html/header.go
与MarkdownHeaderSplitter思路一致,但处理HTML的 ~ 标签。适用于爬取的结构化网页文档,通过DFS递归遍历DOM树,追踪标题层级进行切分。
策略 4:SemanticSplitter(质量最高但速度最慢)
源码:eino-ext/components/document/transformer/splitter/semantic/semantic.go
前三种策略按字符或结构切分,不关心语义完整性。SemanticSplitter 采用更高级的做法:先将文本embed成向量,计算相邻段落的余弦距离,在语义发生跳跃的位置切断:
splitter, _ := semantic.NewSplitter(ctx, &semantic.Config{
Embedding: myEmbedder, // 必须接入 Embedding 模型
Percentile: 0.9, // 距离超过第 90 百分位时才切断
BufferSize: 1, // 对比时考虑前后各 1 句话的上下文
MinChunkSize: 100, // 小于该值的块直接丢弃
})
工作流程如下:
- 先用分隔符粗切成句子
- 每个句子附带前后BufferSize句话的上下文拼接在一起
- 整体embed成向量
- 计算相邻向量的余弦距离
- 距离超过Percentile阈值的位置,表明语义出现转折,在此处真正切断
代价明显:每次切片都需要调用Embedding API,速度远低于前三种策略。仅在质量要求极高、且能承受API调用开销时使用。
五、将三步串联为一条完整流水线
单独使用每个组件固然可行,但 eino 真正的价值在于通过 compose.Graph 将它们编排成一条端到端流水线。
下面是 eino-examples 中 quickstart/eino_assistant 的知识入库流水线,注释已做优化:
// 源码:eino-examples/quickstart/eino_assistant/eino/knowledgeindexing/orchestration.go
func BuildKnowledgeIndexing(ctx context.Context) (compose.Runnable[document.Source, []string], error) {
g := compose.NewGraph[document.Source, []string]() // 节点 1:读取文件(本地 Markdown)
fileLoader, _ := file.NewFileLoader(ctx, &file.FileLoaderConfig{})
g.AddLoaderNode("Loader", fileLoader) // 节点 2:按 Markdown 标题切片
splitter, _ := markdown.NewHeaderSplitter(ctx, &markdown.HeaderConfig{
Headers: map[string]string{"#": "title", "##": "section"},
})
g.AddDocumentTransformerNode("Splitter", splitter) // 节点 3:存入向量数据库(返回存储 ID 列表)
indexer, _ := newVectorIndexer(ctx)
g.AddIndexerNode("Indexer", indexer) // 连线:START -> Loader -> Splitter -> Indexer -> END
g.AddEdge(compose.START, "Loader")
g.AddEdge("Loader", "Splitter")
g.AddEdge("Splitter", "Indexer")
g.AddEdge("Indexer", compose.END) return g.Compile(ctx, compose.WithGraphName("KnowledgeIndexing"))
}
运行方式简洁直接:
pipeline, _ := BuildKnowledgeIndexing(ctx)
ids, _ := pipeline.Invoke(ctx, document.Source{URI: "/docs/manual.md"})
fmt.Printf("已存入 %d 个知识块n", len(ids))
这种流水线设计带来的好处:
- 节点可独立测试:例如单独测试Splitter的切片效果,无需依赖Loader
- 可观测性:通过插入callback,监控每一步的耗时和输出块数
- 可替换性:将
RecursiveSplitter替换为MarkdownHeaderSplitter,其他节点完全无需改动
六、一个重要原则:MetaData 只能增加不能删除
这一点值得特别强调。Transformer 切片时,必须将原 Document 的 MetaData 完整复制给每个切片,只能追加新key,绝不能删除已有key。
原因很清晰:Document 的溯源信息(来源文件、章节、时间戳)在流水线最前端由 Loader/Parser 打下。如果 Splitter 丢弃了这些信息,下游将无法追溯“这条知识从哪儿来”——出问题时无法排错,用户问“你的依据出自哪里?”也无法回答。
eino-ext 的各 Splitter 实现均遵守此规则,切片时执行 deep copy(原 MetaData) + 追加新key。
小结
原始文件 (PDF / HTML / MD / Word)
↓ Loader(搬运工)
字节流
↓ Parser(翻译官,TextParser / HTMLParser / ExtParser)
[Document] ← 完整文档,可能数万字
↓ Transformer(切割机)
[Doc, Doc, Doc...] ← 每块 1000~2000 字
↓ Indexer
向量数据库
如何选择 Splitter?一张表格帮你决策:
| 场景 | 推荐选择 |
|---|---|
| 通用文本,不关心层级结构 | RecursiveSplitter |
| 带标题层级的 Markdown 文档 | MarkdownHeaderSplitter |
| 爬取的结构化网页 | HTMLHeaderSplitter |
| 质量优先,不介意 API 调用成本 | SemanticSplitter |
Document 组件是 RAG 体系的地基。地基质量直接影响检索精度:块切得太大,会超出上下文窗口限制;切得太小,可能导致上下文丢失;切错位置,语义断裂。这个环节值得投入时间仔细选型与调优。