Document 组件:把文件喂给 AI 之前,必须先做这三步

系列「企业级 AI Agent 实现拆解」E12 篇。上一篇 E11 讲了 Embedding------把文字变成向量。但向量化的前提是先有干净的文本 。问题来了:你的内容在 PDF 里、在网页里、在 Word 文档里,格式五花八门,长度动辄几万字。这篇拆 Document 组件:从原始文件到能被 AI 消化的知识块,中间发生了什么。

读完这篇你会知道

  • Document 结构体是什么:三个字段背后藏着哪些能力
  • Loader 怎么把文件"搬进来":为什么 Loader 不管格式
  • Parser 怎么把 HTML/PDF/Word 转成纯文字:以 HTMLParser 为例
  • 四种切片策略怎么选:RecursiveSplitterMarkdownHeaderSplitterHTMLHeaderSplitterSemanticSplitter
  • 怎么用 compose.Graph 把三步串成一条流水线,一行代码跑完全程

一、先说为什么要"处理文档"

你想让 AI 回答公司内部知识库里的问题。AI 的记忆是有限的------你不可能把整本手册塞进去。

工程上的解法叫 RAG(检索增强生成):

  1. 建库:把文档切成小块,算向量,存进数据库
  2. 用时:用户提问 → 捞出最相关的几块 → 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,        // 过小的块丢弃
})

工作流程:

  1. 先用 Separators 粗切成句子
  2. 每句话附带前后 BufferSize 句话的上下文拼在一起
  3. 整体 embed 成向量
  4. 计算相邻向量的余弦距离
  5. 距离超过 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 的地基。地基的质量直接影响检索精度:块切得太大,塞不进上下文;切得太小,丢失上下文;切错地方,语义断裂。值得认真选型。

相关推荐
孟健1 小时前
Fable 5 被暂停后,我反而更确定:不要把生产流程押在单一最强模型上
ai编程
卡卡罗特AI1 小时前
有了 DESIGN.md 后,大家也能写出高颜值的网站了!
ai编程·vibecoding
赫媒派3 小时前
Claude Code 多任务隔离方案:Git Worktrees 入门
ai编程
言川9453 小时前
【万字长文】手搓一个Agent教程
agent
92year3 小时前
MCP 协议最大改版:去掉了 Session,你的 Server 要改 6 个地方
aigc
用户5191495848453 小时前
Flex QR Code Generator 漏洞利用工具 CVE-2025-10041
人工智能·aigc
阿里云云原生3 小时前
只有 Prompt 没用!多 Agent 协作落地,你需要一套类似 K8s 的控制治理平面
agent
蝎子莱莱爱打怪4 小时前
AI Agent 相关知识扫盲:16 个概念+11张图+38个开源项目推荐
人工智能·github·agent
甲维斯4 小时前
Fable+Codex 《坦克大战3D》双端发布了!
人工智能·ai编程·游戏开发