系列「企业级 AI Agent 实现拆解」E12 篇。上一篇 E11 讲了 Embedding------把文字变成向量。但向量化的前提是先有干净的文本 。问题来了:你的内容在 PDF 里、在网页里、在 Word 文档里,格式五花八门,长度动辄几万字。这篇拆 Document 组件:从原始文件到能被 AI 消化的知识块,中间发生了什么。
读完这篇你会知道
Document结构体是什么:三个字段背后藏着哪些能力Loader怎么把文件"搬进来":为什么 Loader 不管格式Parser怎么把 HTML/PDF/Word 转成纯文字:以 HTMLParser 为例- 四种切片策略怎么选:
RecursiveSplitter、MarkdownHeaderSplitter、HTMLHeaderSplitter、SemanticSplitter- 怎么用
compose.Graph把三步串成一条流水线,一行代码跑完全程
一、先说为什么要"处理文档"
你想让 AI 回答公司内部知识库里的问题。AI 的记忆是有限的------你不可能把整本手册塞进去。
工程上的解法叫 RAG(检索增强生成):
- 建库:把文档切成小块,算向量,存进数据库
- 用时:用户提问 → 捞出最相关的几块 → AI 看着这几块回答
第一步"建库"就是 Document 组件负责的事:加载 → 解析 → 切片。
二、Document 是什么
源码在 eino/schema/document.go,结构体只有三个字段:
go
type Document struct {
ID string // 这块内容的唯一编号
Content string // 实际文字内容
MetaData map[string]any // 附带的额外信息
}
Content 是正文,MetaData 存来源、分数、向量等附加信息。
从 HTML 页面解析出来的 Document 大概长这样:
json
{
"id": "doc-001",
"content": "Go 是 Google 开发的编程语言,设计目标是简洁、高效...",
"meta_data": {
"_source": "https://example.com/go-intro.html",
"_title": "Go 语言简介",
"_language": "zh"
}
}
MetaData 不是普通 map------有一批专属方法:
go
doc.Score() // 取检索相关性分数(不用手动从 map 挖)
doc.DenseVector() // 取向量
doc.ExtraInfo() // 取附加说明
doc.SubIndexes() // 取多分区路由索引
这些值底层都存在 MetaData 的保留 key 里(_score、_dense_vector 等),但对外暴露方法,不让你直接操 map。
三、Loader:把文件搬进来
接口定义在 eino/components/document/interface.go:
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。
go
// 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:
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。
go
// 源码:eino-ext/components/document/parser/html/html.go
htmlParser, _ := html.NewParser(ctx, &html.Config{
Selector: gptr.Of("body"), // 用 CSS 选择器只抠出 body 内容
})
解析后自动提取 meta 信息写入 MetaData:
ini
_title <- <title> 标签内容
_description <- <meta name="description"> 内容
_language <- <html lang="..."> 属性
_charset <- 字符编码
_source <- 来源 URL
安全上用 bluemonday UGC 策略过滤危险 HTML 标签,防止把恶意脚本当文本存进知识库。
ExtParser:按扩展名自动派活
如果你要处理多种格式:
go
// 源码: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 干这个:
go
// 源码: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 切,块还是太大就换 . 试,再不够就换 ?......直到块足够小。
go
splitter, _ := recursive.NewSplitter(ctx, &recursive.Config{
ChunkSize: 1500, // 每块最多 1500 字符
OverlapSize: 300, // 相邻块重叠 300 字符,保留边界上下文
Separators: []string{"\n", ".", "?", "!"},
KeepType: recursive.KeepTypeNone, // 分隔符本身丢弃
})
OverlapSize 是关键:切块边界处的内容会在相邻两块都出现,防止一句话被切断后两边都看不懂。
go
// 一行示例(源码: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:
go
splitter, _ := markdown.NewHeaderSplitter(ctx, &markdown.HeaderConfig{
Headers: map[string]string{
"#": "chapter", // 一级标题 -> metadata key "chapter"
"##": "section", // 二级标题 -> metadata key "section"
},
TrimHeaders: true, // 切出来的块里不包含标题行本身
})
切出的 Document 带结构化 MetaData:
json
{
"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 成向量,计算相邻段落的余弦距离,在语义跳跃处切:
go
splitter, _ := semantic.NewSplitter(ctx, &semantic.Config{
Embedding: myEmbedder, // 必须接入 Embedding 模型
Percentile: 0.9, // 距离超过第 90 百分位才切
BufferSize: 1, // 对比时考虑前后各 1 句话的上下文
MinChunkSize: 100, // 过小的块丢弃
})
工作流程:
- 先用 Separators 粗切成句子
- 每句话附带前后 BufferSize 句话的上下文拼在一起
- 整体 embed 成向量
- 计算相邻向量的余弦距离
- 距离超过 Percentile 阈值的地方真正切断
代价:每次切片都要调 Embedding API,比前三种慢很多。对质量要求极高时用。
六、把三步串成流水线
单独用每个组件没问题。eino 真正的价值在于用 compose.Graph 把它们连成流水线。
下面是 eino-examples 里 quickstart/eino_assistant 的知识入库流水线,改了注释:
go
// 源码: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"))
}
运行:
go
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。
小结
css
原始文件 (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 的地基。地基的质量直接影响检索精度:块切得太大,塞不进上下文;切得太小,丢失上下文;切错地方,语义断裂。值得认真选型。