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

时间:2026-07-04 08:30:42 来源:互联网

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

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

工程实践中的解决方案称为RAG(检索增强生成):

  1. 建库:将文档拆分为小块,计算向量,并存入数据库
  2. 用时:用户提出问题,系统检索出最相关的小块,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 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)
    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,        // 丢弃过小的块
})

工作流程:

  1. 使用分隔符粗切为句子
  2. 每句话拼接前后BufferSize句话的上下文
  3. 整体embed为向量
  4. 计算相邻向量的余弦距离
  5. 在距离超过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))

流水线的优势:

  1. 单节点可独立测试:可单独使用Splitter测试切片效果,无需依赖Loader
  2. 可观测性:插入回调监控每步耗时和输出块数
  3. 可替换性:将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的地基,其切片质量直接影响检索精度:块过大难以放入上下文,块过小导致上下文丢失,切错位置会破坏语义连贯性。因此,选型值得审慎考量。