Document 组件: 把文件喂给 AI 之前 必须先做这三步
要让AI有效回应公司内部知识库的查询,必须预先处理文档。AI的记忆容量有限,无法直接加载整本手册,因此需要Document组件进行加载、解析与切片。

工程实践中的解决方案称为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。
二、Document 是什么
接口定义在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))
}
三、Loader:把文件搬进来
接口定义在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操作DOM。
// 源码:eino-ext/components/document/parser/html/html.go
htmlParser, _ := html.NewParser(ctx, &html.Config{
Selector: gptr.Of("body"), // 通过CSS选择器仅提取body内容
})
解析后自动提取meta信息并写入MetaData:
_title <- <title> 标签内容
_description <- <meta name="description"> 内容
_language <- <html lang="..."> 属性
_charset <- 字符编码
_source <- 来源URL
安全方面采用bluemonday UGC策略过滤危险HTML标签,防止恶意脚本存入知识库。
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)。
四、Parser:把格式转成纯文字
一篇文章数万字,必须切分为小块才能存入向量数据库。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的<h1>至<h6>标签。通过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,速度远慢于前三者。适用于对质量要求极高的场景。
五、Transformer:切片
单独使用各个组件毫无问题。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 - 可观测性:插入回调监控每步耗时和输出块数
- 可替换性:将
RecursiveSplitter替换为MarkdownHeaderSplitter,其他节点无需调整
六、把三步串成流水线
Transformer在切分时,必须将原始Document的MetaData完整复制到每个切片中,仅可追加新key,不得删除已有key。
原因在于:Document的溯源信息(如来源文件、章节、时间戳)在流水线初期由Loader/Parser写入。若Splitter丢弃这些信息,下游将无法追溯知识来源,问题排查困难,也无法回答用户对依据的疑问。
eino-ext中的多个Splitter实现均遵循此规则,采用deep copy(原MetaData) + 追加新key的方式操作。
七、一个必须记住的原则:MetaData只能增不能减
原始文件 (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的地基,其切片质量直接影响检索精度:块过大难以放入上下文,块过小导致上下文丢失,切错位置会破坏语义连贯性。因此,选型值得审慎考量。