
系列「企业级 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.gotypeLoaderinterface{Load(ctx context.Context,src Source,opts...LoaderOption)([]*schema.Document,error)}typeSourcestruct{URIstring// 文件路径或 URL}接口极薄。eino-ext 提供了三种现成实现Loader用途URI 格式file.FileLoader读本地文件/path/to/file.mdurl.Loader抓网页https://...s3.Loader读 AWS S3s3://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.gotypeParserinterface{Parse(ctx context.Context,reader io.Reader,opts...Option)([]*schema.Document,error)}接受字节流返回 Document 列表。TextParser最简单直接把整个流读成字符串返回一个 Document。处理.txt、.md这类纯文本够用。HTMLParser解析网页来自 eino-ext底层用 goqueryGo 版 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 namedescription 内容 _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逐页或合并、Worddocx可按节切分、Excelxlsx逐行转 Document。五、Transformer切片一篇文章几万字必须切成小块才能存入向量数据库。Transformer干这个// 源码eino/components/document/interface.gotypeTransformerinterface{Transform(ctx context.Context,src[]*schema.Document,opts...TransformerOption)([]*schema.Document,error)}输入一批 Document输出更多更小的 Document。eino-ext 提供四种切片策略。策略 1RecursiveSplitter通用首选源码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.godata,_:os.ReadFile(./document.md)docs,_:splitter.Transform(ctx,[]*schema.Document{{Content:string(data)}})fmt.Printf(切成了 %d 块\n,len(docs))策略 2MarkdownHeaderSplitter结构化文档源码eino-ext/components/document/transformer/splitter/markdown/header.go按 Markdown 标题层级切每块继承父级标题写入 MetaDatasplitter,_: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 基础}}检索时可按章节过滤不只是全文搜。策略 3HTMLHeaderSplitter源码eino-ext/components/document/transformer/splitter/html/header.go和 MarkdownHeaderSplitter 同理但处理 HTML 的h1h6标签。适合爬下来的结构化网页文档用 DFS 递归遍历 DOM 树追踪标题层级。策略 4SemanticSplitter高质量慢源码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读文件本地 MarkdownfileLoader,_: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...] ← 每块 10002000 字 ↓ Indexer 向量数据库选哪个 Splitter场景推荐通用文本不在乎结构RecursiveSplitter有标题层级的 Markdown 文档MarkdownHeaderSplitter爬下来的结构化网页HTMLHeaderSplitter质量优先不差 API 调用钱SemanticSplitterDocument 组件是 RAG 的地基。地基的质量直接影响检索精度块切得太大塞不进上下文切得太小丢失上下文切错地方语义断裂。值得认真选型。