系列「企业级 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,结构体只有三个字段:
typeDocumentstruct{IDstring// 这块内容的唯一编号Contentstring// 实际文字内容MetaDatamap[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:
typeLoaderinterface{Load(ctx context.Context,src Source,opts...LoaderOption)([]*schema.Document,error)}typeSourcestruct{URIstring// 文件路径或 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)deferfile.Close()// 把文件流交给 Parser,扩展名由 URI 携带returnf.parser.Parse(ctx,file,parser.WithURI(src.URI))}四、Parser:把格式转成纯文字
接口定义在eino/components/document/parser/interface.go:
typeParserinterface{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.gohtmlParser,_:=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.goextParser,_:=parser.NewExtParser(ctx,&parser.ExtParserConfig{Parsers:map[string]parser.Parser{".html":htmlParser,".pdf":pdfParser,".docx":docxParser,},FallbackParser:parser.TextParser{},// 其他格式兜底})// 关键:必须传 URI,否则 ExtParser 不知道用哪个 Parserdocs,_:=extParser.Parse(ctx,file,parser.WithURI("./report.html"))eino-ext 目前支持的格式:HTML、PDF(逐页或合并)、Word(docx,可按节切分)、Excel(xlsx,逐行转 Document)。
五、Transformer:切片
一篇文章几万字,必须切成小块才能存入向量数据库。Transformer干这个:
// 源码:eino/components/document/interface.gotypeTransformerinterface{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,// 过小的块丢弃})工作流程:
- 先用 Separators 粗切成句子
- 每句话附带前后 BufferSize 句话的上下文拼在一起
- 整体 embed 成向量
- 计算相邻向量的余弦距离
- 距离超过 Percentile 阈值的地方真正切断
代价:每次切片都要调 Embedding API,比前三种慢很多。对质量要求极高时用。
六、把三步串成流水线
单独用每个组件没问题。eino 真正的价值在于用compose.Graph把它们连成流水线。
下面是 eino-examples 里quickstart/eino_assistant的知识入库流水线,改了注释:
// 源码:eino-examples/quickstart/eino_assistant/eino/knowledgeindexing/orchestration.gofuncBuildKnowledgeIndexing(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 -> ENDg.AddEdge(compose.START,"Loader")g.AddEdge("Loader","Splitter")g.AddEdge("Splitter","Indexer")g.AddEdge("Indexer",compose.END)returng.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 的地基。地基的质量直接影响检索精度:块切得太大,塞不进上下文;切得太小,丢失上下文;切错地方,语义断裂。值得认真选型。