首页
看点啥
插画图片
首页 看点啥 Document 组件:把文件喂给 AI 之前:必须先做这三步

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

2026-07-01 0

读完这篇你会知道


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

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

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。


三、Loader:把文件搬进来

接口定义在 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))
}

四、Parser:把格式转成纯文字

接口定义在 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(Go 版 jQuery)操作 DOM。

// 源码:eino-ext/components/document/parser/html/html.go
htmlParser, _ := html.NewParser(ctx, &html.Config{
    Selector: gptr.Of("body"),  // 用 CSS 选择器只抠出 body 内容
})

解析后自动提取 meta 信息写入 MetaData:

_title       <-  标签内容
_description <- <meta <span class="hljs-attr">name</span>=<span class="hljs-string">"description"</span>> 内容
_language    <- <html <span class="hljs-attr">lang</span>=<span class="hljs-string">"..."</span>> 属性
_charset     <- 字符编码
_source      <- 来源 URL
</code></pre>
<p>安全上用 <strong>bluemonday UGC 策略</strong>过滤危险 HTML 标签,防止把恶意脚本当文本存进知识库。</p>
<h4>ExtParser:按扩展名自动派活</h4>
<p>如果你要处理多种格式:</p>
<pre><code class="hljs language-go" lang="go"><span class="hljs-comment">// 源码:eino/components/document/parser/ext_parser.go</span>
extParser, _ := parser.NewExtParser(ctx, &parser.ExtParserConfig{
    Parsers: <span class="hljs-keyword">map</span>[<span class="hljs-type">string</span>]parser.Parser{
        <span class="hljs-string">".html"</span>: htmlParser,
        <span class="hljs-string">".pdf"</span>:  pdfParser,
        <span class="hljs-string">".docx"</span>: docxParser,
    },
    FallbackParser: parser.TextParser{},  <span class="hljs-comment">// 其他格式兜底</span>
})<span class="hljs-comment">// 关键:必须传 URI,否则 ExtParser 不知道用哪个 Parser</span>
docs, _ := extParser.Parse(ctx, file, parser.WithURI(<span class="hljs-string">"./report.html"</span>))
</code></pre>
<p>eino-ext 目前支持的格式:HTML、PDF(逐页或合并)、Word(docx,可按节切分)、Excel(xlsx,逐行转 Document)。</p>
<hr>
<h3>五、<code>Transformer</code>:切片</h3>
<p>一篇文章几万字,必须切成小块才能存入向量数据库。<code>Transformer</code> 干这个:</p>
<pre><code class="hljs language-go" lang="go"><span class="hljs-comment">// 源码:eino/components/document/interface.go</span>
<span class="hljs-keyword">type</span> Transformer <span class="hljs-keyword">interface</span> {
    Transform(ctx context.Context, src []*schema.Document, opts ...TransformerOption) ([]*schema.Document, <span class="hljs-type">error</span>)
}
</code></pre>
<p>输入一批 Document,输出更多更小的 Document。eino-ext 提供四种切片策略。</p>
<h4>策略 1:RecursiveSplitter(通用首选)</h4>
<p>源码:<code>eino-ext/components/document/transformer/splitter/recursive/recursive.go</code></p>
<p>按分隔符<strong>递归</strong>切分。先按 <code>n</code> 切,块还是太大就换 <code>.</code> 试,再不够就换 <code>?</code>……直到块足够小。</p>
<pre><code class="hljs language-go" lang="go">splitter, _ := recursive.NewSplitter(ctx, &recursive.Config{
    ChunkSize:   <span class="hljs-number">1500</span>,   <span class="hljs-comment">// 每块最多 1500 字符</span>
    OverlapSize: <span class="hljs-number">300</span>,    <span class="hljs-comment">// 相邻块重叠 300 字符,保留边界上下文</span>
    Separators:  []<span class="hljs-type">string</span>{<span class="hljs-string">"n"</span>, <span class="hljs-string">"."</span>, <span class="hljs-string">"?"</span>, <span class="hljs-string">"!"</span>},
    KeepType:    recursive.KeepTypeNone,  <span class="hljs-comment">// 分隔符本身丢弃</span>
})
</code></pre>
<p><code>OverlapSize</code> 是关键:切块边界处的内容会在相邻两块都出现,防止一句话被切断后两边都看不懂。</p>
<pre><code class="hljs language-go" lang="go"><span class="hljs-comment">// 一行示例(源码:recursive/examples/main.go)</span>
data, _ := os.ReadFile(<span class="hljs-string">"./document.md"</span>)
docs, _ := splitter.Transform(ctx, []*schema.Document{{Content: <span class="hljs-type">string</span>(data)}})
fmt.Printf(<span class="hljs-string">"切成了 %d 块n"</span>, <span class="hljs-built_in">len</span>(docs))
</code></pre>
<h4>策略 2:MarkdownHeaderSplitter(结构化文档)</h4>
<p>源码:<code>eino-ext/components/document/transformer/splitter/markdown/header.go</code></p>
<p>按 Markdown 标题层级切,每块<strong>继承父级标题</strong>写入 MetaData:</p>
<pre><code class="hljs language-go" lang="go">splitter, _ := markdown.NewHeaderSplitter(ctx, &markdown.HeaderConfig{
    Headers: <span class="hljs-keyword">map</span>[<span class="hljs-type">string</span>]<span class="hljs-type">string</span>{
        <span class="hljs-string">"#"</span>:  <span class="hljs-string">"chapter"</span>,   <span class="hljs-comment">// 一级标题 -> metadata key "chapter"</span>
        <span class="hljs-string">"##"</span>: <span class="hljs-string">"section"</span>,   <span class="hljs-comment">// 二级标题 -> metadata key "section"</span>
    },
    TrimHeaders: <span class="hljs-literal">true</span>,  <span class="hljs-comment">// 切出来的块里不包含标题行本身</span>
})
</code></pre>
<p>切出的 Document 带结构化 MetaData:</p>
<pre><code class="hljs language-json" lang="json"><span class="hljs-punctuation">{</span>
  <span class="hljs-attr">"content"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"Go 的并发模型基于 CSP..."</span><span class="hljs-punctuation">,</span>
  <span class="hljs-attr">"meta_data"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span>
    <span class="hljs-attr">"chapter"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"第三章 并发编程"</span><span class="hljs-punctuation">,</span>
    <span class="hljs-attr">"section"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"3.1 Goroutine 基础"</span>
  <span class="hljs-punctuation">}</span>
<span class="hljs-punctuation">}</span>
</code></pre>
<p>检索时可按章节过滤,不只是全文搜。</p>
<h4>策略 3:HTMLHeaderSplitter</h4>
<p>源码:<code>eino-ext/components/document/transformer/splitter/html/header.go</code></p>
<p>和 MarkdownHeaderSplitter 同理,但处理 HTML 的 <code><h1></code>~<code><h6></code> 标签。适合爬下来的结构化网页文档,用 DFS 递归遍历 DOM 树,追踪标题层级。</p>
<h4>策略 4:SemanticSplitter(高质量,慢)</h4>
<p>源码:<code>eino-ext/components/document/transformer/splitter/semantic/semantic.go</code></p>
<p>前三种按字符或结构切,不管语义。SemanticSplitter 先把文本 embed 成向量,计算相邻段落的余弦距离,<strong>在语义跳跃处切</strong>:</p>
<pre><code class="hljs language-go" lang="go">splitter, _ := semantic.NewSplitter(ctx, &semantic.Config{
    Embedding:  myEmbedder,   <span class="hljs-comment">// 必须接入 Embedding 模型</span>
    Percentile: <span class="hljs-number">0.9</span>,          <span class="hljs-comment">// 距离超过第 90 百分位才切</span>
    BufferSize: <span class="hljs-number">1</span>,            <span class="hljs-comment">// 对比时考虑前后各 1 句话的上下文</span>
    MinChunkSize: <span class="hljs-number">100</span>,        <span class="hljs-comment">// 过小的块丢弃</span>
})
</code></pre>
<p>工作流程:</p>
<ol>
<li>先用 Separators 粗切成句子</li>
<li>每句话附带前后 BufferSize 句话的上下文拼在一起</li>
<li>整体 embed 成向量</li>
<li>计算相邻向量的余弦距离</li>
<li>距离超过 Percentile 阈值的地方真正切断</li>
</ol>
<p>代价:每次切片都要调 Embedding API,比前三种慢很多。对质量要求极高时用。</p>
<hr>
<h3>六、把三步串成流水线</h3>
<p>单独用每个组件没问题。eino 真正的价值在于用 <code>compose.Graph</code> 把它们连成流水线。</p>
<p>下面是 eino-examples 里 <code>quickstart/eino_assistant</code> 的知识入库流水线,改了注释:</p>
<pre><code class="hljs language-go" lang="go"><span class="hljs-comment">// 源码:eino-examples/quickstart/eino_assistant/eino/knowledgeindexing/orchestration.go</span>
<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">BuildKnowledgeIndexing</span><span class="hljs-params">(ctx context.Context)</span></span> (compose.Runnable[document.Source, []<span class="hljs-type">string</span>], <span class="hljs-type">error</span>) {
    g := compose.NewGraph[document.Source, []<span class="hljs-type">string</span>]()    <span class="hljs-comment">// 节点 1:读文件(本地 Markdown)</span>
    fileLoader, _ := file.NewFileLoader(ctx, &file.FileLoaderConfig{})
    g.AddLoaderNode(<span class="hljs-string">"Loader"</span>, fileLoader)    <span class="hljs-comment">// 节点 2:按 Markdown 标题切片</span>
    splitter, _ := markdown.NewHeaderSplitter(ctx, &markdown.HeaderConfig{
        Headers: <span class="hljs-keyword">map</span>[<span class="hljs-type">string</span>]<span class="hljs-type">string</span>{<span class="hljs-string">"#"</span>: <span class="hljs-string">"title"</span>, <span class="hljs-string">"##"</span>: <span class="hljs-string">"section"</span>},
    })
    g.AddDocumentTransformerNode(<span class="hljs-string">"Splitter"</span>, splitter)    <span class="hljs-comment">// 节点 3:存入向量数据库(返回存储 ID 列表)</span>
    indexer, _ := newVectorIndexer(ctx)
    g.AddIndexerNode(<span class="hljs-string">"Indexer"</span>, indexer)    <span class="hljs-comment">// 连线:START -> Loader -> Splitter -> Indexer -> END</span>
    g.AddEdge(compose.START, <span class="hljs-string">"Loader"</span>)
    g.AddEdge(<span class="hljs-string">"Loader"</span>, <span class="hljs-string">"Splitter"</span>)
    g.AddEdge(<span class="hljs-string">"Splitter"</span>, <span class="hljs-string">"Indexer"</span>)
    g.AddEdge(<span class="hljs-string">"Indexer"</span>, compose.END)    <span class="hljs-keyword">return</span> g.Compile(ctx, compose.WithGraphName(<span class="hljs-string">"KnowledgeIndexing"</span>))
}
</code></pre>
<p>运行:</p>
<pre><code class="hljs language-go" lang="go">pipeline, _ := BuildKnowledgeIndexing(ctx)
ids, _ := pipeline.Invoke(ctx, document.Source{URI: <span class="hljs-string">"/docs/manual.md"</span>})
fmt.Printf(<span class="hljs-string">"已存入 %d 个知识块n"</span>, <span class="hljs-built_in">len</span>(ids))
</code></pre>
<p>流水线的好处:</p>
<ul>
<li><strong>单节点可测</strong>:用 <code>Splitter</code> 单独测切片效果,不依赖 Loader</li>
<li><strong>可观测</strong>:插入 callback 监控每步耗时、输出块数</li>
<li><strong>可替换</strong>:换 <code>RecursiveSplitter</code> 替代 <code>MarkdownHeaderSplitter</code>,其他节点不动</li>
</ul>
<hr>
<h3>七、一个必须记住的原则:MetaData 只能增不能减</h3>
<p><code>Transformer</code> 切片时,<strong>必须把原 Document 的 MetaData 完整复制给每个切片</strong>,只能追加新 key,不能删除已有 key。</p>
<p>原因:Document 的溯源信息(来源文件、章节、时间戳)在流水线最开始由 Loader/Parser 打上。如果 Splitter 把这些信息丢掉,下游就无法追溯"这条知识来自哪里"——出了问题没法排查,用户问"你说的这个依据从哪来?"也答不上。</p>
<p>eino-ext 的几个 Splitter 实现都遵守这条规则,切片时做的是 <code>deep copy(原 MetaData) + 追加新 key</code>。</p>
<hr>
<h3>小结</h3>
<pre><code class="hljs language-css" lang="css">原始文件 (PDF / <span class="hljs-selector-tag">HTML</span> / MD / Word)
    ↓  Loader(搬运工)
字节流
    ↓  Parser(翻译官,TextParser / HTMLParser / ExtParser)
<span class="hljs-selector-attr">[Document]</span>          ← 完整文档,可能几万字
    ↓  Transformer(切割机)
<span class="hljs-selector-attr">[Doc, Doc, Doc...]</span>  ← 每块 <span class="hljs-number">1000</span>~<span class="hljs-number">2000</span> 字
    ↓  Indexer
向量数据库
</code></pre>
<p><strong>选哪个 Splitter?</strong></p><table><thead><tr><th>场景</th><th>推荐</th></tr></thead><tbody><tr><td>通用文本,不在乎结构</td><td><code>RecursiveSplitter</code></td></tr><tr><td>有标题层级的 Markdown 文档</td><td><code>MarkdownHeaderSplitter</code></td></tr><tr><td>爬下来的结构化网页</td><td><code>HTMLHeaderSplitter</code></td></tr><tr><td>质量优先,不差 API 调用钱</td><td><code>SemanticSplitter</code></td></tr></tbody></table>
<p>Document 组件是 RAG 的地基。地基的质量直接影响检索精度:块切得太大,塞不进上下文;切得太小,丢失上下文;切错地方,语义断裂。值得认真选型。</p>
            </div>
            <!--  -->
    <div class="content_btn_item clearfix">
        <span>喜欢(0)</span>
    </div>
    <!--  -->
    <div class="content_page">
                            <a class="img_box" href="/news/3374526.html">
                <div class="bg">
                    <p class="page">上一篇</p>
                    <p class="info text_overflow_2">Flex QR Code Generator 漏洞利用程序 CVE-2025-10041</p>
                </div>
                                    <img src="https://www.0405.net/uploads/20260701/logo_6a445d294ca5d1.webp" alt="Flex QR Code Generator 漏洞利用程序 CVE-2025-10041">
                            </a>
                                    <a class="img_box" href="/news/3374528.html">
                <div class="bg">
                    <p class="page">下一篇</p>
                    <p class="info text_overflow_2">短剧《产检醒来,我肚子里的孩子消失了》剧情介绍</p>
                </div>
                                    <img src="https://www.0405.net/uploads/20260701/logo_6a445d648c6f81.webp" alt="短剧《产检醒来,我肚子里的孩子消失了》剧情介绍">
                            </a>
            </div>
</div>

<!-- 列表 -->
<div style="padding-bottom: 10px;">
    <div class="px15" style="font-weight: bold;font-size: .36rem;padding-top: .3rem;">猜你喜欢</div>
    <ul class="px15 news_box">
                                    <li>
                    <a class="clearfix" href="/news/3375546.html">
                                                    <span class="img_item">
                                <img lazy-src="https://www.0405.net/uploads/20260701/logo_6a44a991929ee1.webp">
                            </span>
                                                <div class="content">
                            <p class="title text_overflow_2">蛋仔派对四周年庆活动大全:蛋仔派对周年庆典玩法与福利汇总</p>
                            <p class="text"><span class="icon time">2026-07-01</span> <span class="icon heart">3375546</span></p>
                        </div>
                    </a>
                </li>
                            <li>
                    <a class="clearfix" href="/news/3375545.html">
                                                    <span class="img_item">
                                <img lazy-src="https://www.0405.net/uploads/20260701/logo_6a44a98a82cfa1.webp">
                            </span>
                                                <div class="content">
                            <p class="title text_overflow_2">斗兽战场巨兽图鉴大全斗兽战场高性价比巨兽推荐及实战解析</p>
                            <p class="text"><span class="icon time">2026-07-01</span> <span class="icon heart">3375545</span></p>
                        </div>
                    </a>
                </li>
                            <li>
                    <a class="clearfix" href="/news/3375543.html">
                                                    <span class="img_item">
                                <img lazy-src="https://www.0405.net/uploads/20260701/logo_6a44a957238391.webp">
                            </span>
                                                <div class="content">
                            <p class="title text_overflow_2">修仙路上添新途!《问道》官方邀你线上登峰 线下伴游</p>
                            <p class="text"><span class="icon time">2026-07-01</span> <span class="icon heart">3375543</span></p>
                        </div>
                    </a>
                </li>
                            <li>
                    <a class="clearfix" href="/news/3375542.html">
                                                    <span class="img_item">
                                <img lazy-src="https://www.0405.net/uploads/20260701/logo_6a44a94f554bd1.webp">
                            </span>
                                                <div class="content">
                            <p class="title text_overflow_2">时空猎人觉醒女帝玩法攻略</p>
                            <p class="text"><span class="icon time">2026-07-01</span> <span class="icon heart">3375542</span></p>
                        </div>
                    </a>
                </li>
                            <li>
                    <a class="clearfix" href="/news/3375541.html">
                                                    <span class="img_item">
                                <img lazy-src="https://www.0405.net/uploads/20260701/logo_6a44a922580131.webp">
                            </span>
                                                <div class="content">
                            <p class="title text_overflow_2">蓝色星原旅谣宠物攻略蓝色星原旅谣新手必读宠物选择与培养指南</p>
                            <p class="text"><span class="icon time">2026-07-01</span> <span class="icon heart">3375541</span></p>
                        </div>
                    </a>
                </li>
                        </ul>
</div>

<!-- 底部 -->
<div class="footer_box">
    <p>Copyright © 2022 漫资讯 · Powered By WordPress</p>
</div>

<div class="back_top" id="back_top"><a href="javascript:;"></a></div>

</body>
<script src="/mob/js/jquery.js" type="text/javascript" charset="utf-8"></script>
<script src="/mob/js/lazy.js" type="text/javascript" charset="utf-8"></script>
<script src="/mob/js/flexible.js" type="text/javascript" charset="utf-8"></script>
<script src="/mob/js/index.js" type="text/javascript" charset="utf-8"></script>
    <script type="text/javascript">
        var _paq = window._paq = window._paq || [];
        _paq.push(['trackPageView']);
        _paq.push(['enableLinkTracking']);

        (function() {
            var u="//tongji.zhangwan.net/";
            _paq.push(['setTrackerUrl', u+'matomo.php']);
            _paq.push(['setSiteId', '9']);

            // Add this code below within the Matomo JavaScript tracker code
            // Important: the tracker url includes the /matomo.php

            var secondaryTrackerUrl = u+'matomo.php';
            var secondaryWebsiteId = 27;

            // Also send all of the tracking data to this other Matomo server, in website ID 77

            _paq.push(['addTracker', secondaryTrackerUrl, secondaryWebsiteId]);

            // That's it!

            var d=document,
                g=d.createElement('script'),
                s=d.getElementsByTagName('script')[0];
                g.type='text/javascript';
                g.async=true;
                g.src=u+'matomo.js';
                s.parentNode.insertBefore(g,s);
        })();
</script>     <script>
var _hmt = _hmt || [];
(function() {
  var hm = document.createElement("script");
  hm.src = "https://hm.baidu.com/hm.js?e9f2069e6989ecb71b4b1c8018eaf5ed";
  var s = document.getElementsByTagName("script")[0]; 
  s.parentNode.insertBefore(hm, s);
})();
</script>   
</html>