13知识入库从原始文档到可检索索引《Eino 实战用 Go 构建 AI 应用与智能体》· 第 13 篇关键词Document Loader、Transformer、Embedding、Indexer、切分、元数据前置条件已完成 11Tool 与 ToolsNode。从这一篇起进入「工具与知识」的第二条线让模型能参考企业自己的资料。大模型不知道你公司的处理规范、内部流程和产品细节。要让它“知道”最实用的办法不是重新训练模型而是在提问时把相关资料一并交给它。这就是检索增强生成RAG。RAG 分两半数据侧把资料整理成可检索的形式和查询侧提问时召回并组装上下文。这一篇讲数据侧。很多 RAG 效果差问题并不在模型而在这一半没做好。一、四条流水线数据处理通常分四步每一步对应一个组件抽象原始文档 ↓ Loader 加载从文件、网页、数据库读取内容 文档 ↓ Transformer 转换清洗、切分、补充元数据 片段chunks ↓ Embedding 向量化把文本变成可比较的向量 向量 文本 ↓ Indexer 入库写入可检索的索引 可检索索引四个抽象的接口都很简单组件接口说明LoaderLoad(ctx, Source) ([]*Document, error)从来源读取TransformerTransform(ctx, []*Document) ([]*Document, error)转换、切分EmbedderEmbedStrings(ctx, texts) ([][]float64, error)批量向量化IndexerStore(ctx, docs) ([]string, error)写入索引返回 ID注意 Loader 和 Transformer 的输入输出都是[]*schema.Document而Document只有三个字段typeDocumentstruct{IDstringContentstringMetaDatamap[string]any}结构简单是有意为之元数据是携带来源信息的地方后面会重点讲。二、切分最容易做坏的一步文档通常太长不能整篇塞进模型上下文所以要切分成片段chunk。切分策略直接决定检索质量。常见的切分方式策略做法适用固定长度按字符数或 Token 数切结构松散的文本按结构按标题、段落、列表切有清晰层级的文档重叠切分相邻片段保留一部分重叠避免句子被拦腰截断固定长度切分有个经典毛病会产生无意义的碎片。下面这段程序会当场暴露它本文写作时已实机运行packagemainimport(contextfmtlogstringsgithub.com/cloudwego/eino/components/documentgithub.com/cloudwego/eino/components/embeddinggithub.com/cloudwego/eino/components/indexergithub.com/cloudwego/eino/schema)// ---- 1. Loader从数据源读取原始文档 ----typememoryLoaderstruct{docsmap[string]string}func(l*memoryLoader)Load(_context.Context,src document.Source,_...document.LoaderOption)([]*schema.Document,error){content,ok:l.docs[src.URI]if!ok{returnnil,fmt.Errorf(未找到来源: %s,src.URI)}return[]*schema.Document{{ID:src.URI,Content:content,// 保留来源信息便于后续引用与追溯。MetaData:map[string]any{source:src.URI},}},nil}// ---- 2. Transformer切分文档并保留元数据 ----typefixedSplitterstruct{sizeint}func(s*fixedSplitter)Transform(_context.Context,src[]*schema.Document,_...document.TransformerOption)([]*schema.Document,error){varout[]*schema.Documentfor_,doc:rangesrc{runes:[]rune(strings.TrimSpace(doc.Content))fori,chunk:0,0;ilen(runes);i,chunkis.size,chunk1{end:is.sizeifendlen(runes){endlen(runes)}// 复制元数据避免多个分片共享同一个 map。meta:make(map[string]any,len(doc.MetaData)2)fork,v:rangedoc.MetaData{meta[k]v}meta[chunk_index]chunk outappend(out,schema.Document{ID:fmt.Sprintf(%s#%d,doc.ID,chunk),Content:string(runes[i:end]),MetaData:meta,})}}returnout,nil}// ---- 3. Embedder用确定性向量模拟避免依赖外部服务 ----typefakeEmbedderstruct{}func(fakeEmbedder)EmbedStrings(_context.Context,texts[]string,_...embedding.Option)([][]float64,error){vecs:make([][]float64,0,len(texts))for_,t:rangetexts{vecsappend(vecs,[]float64{float64(len([]rune(t))),1,1})}returnvecs,nil}// ---- 4. Indexer保留来源与元数据 ----typememoryIndexerstruct{docs[]*schema.Document}func(idx*memoryIndexer)Store(_context.Context,docs[]*schema.Document,_...indexer.Option)([]string,error){ids:make([]string,0,len(docs))for_,d:rangedocs{idx.docsappend(idx.docs,d)idsappend(ids,d.ID)}returnids,nil}funcmain(){iferr:run();err!nil{log.Fatal(err)}}funcrun()error{ctx:context.Background()loader:memoryLoader{docs:map[string]string{handbook/printer.md:打印机无法联网时先检查网线是否插紧再确认所在网段是否允许办公设备接入。若仍离线可通过工单系统申请网络支持组协助排查。,}}splitter:fixedSplitter{size:30}index:memoryIndexer{}// 串联三步加载 - 切分 - 入库。docs,err:loader.Load(ctx,document.Source{URI:handbook/printer.md})iferr!nil{returnfmt.Errorf(加载失败: %w,err)}fmt.Printf(加载到 %d 篇文档\n,len(docs))chunks,err:splitter.Transform(ctx,docs)iferr!nil{returnfmt.Errorf(切分失败: %w,err)}fmt.Printf(切分为 %d 个片段\n,len(chunks))ids,err:index.Store(ctx,chunks)iferr!nil{returnfmt.Errorf(入库失败: %w,err)}fmt.Printf(入库 %d 个片段\n,len(ids))_fakeEmbedder{}// 示例中向量化留到第 14 篇展开// 检查元数据是否被正确保留来源可追溯分片序号可定位。for_,d:rangeindex.docs{fmt.Printf( id%-24s source%-20s chunk%v 内容%q\n,d.ID,d.MetaData[source],d.MetaData[chunk_index],d.Content)}returnnil}实机输出加载到 1 篇文档 切分为 3 个片段 入库 3 个片段 idhandbook/printer.md#0 sourcehandbook/printer.md chunk0 内容打印机无法联网时先检查网线是否插紧再确认所在网段是否允许 idhandbook/printer.md#1 sourcehandbook/printer.md chunk1 内容办公设备接入。若仍离线可通过工单系统申请网络支持组协助排查 idhandbook/printer.md#2 sourcehandbook/printer.md chunk2 内容。请特别看第三个片段内容只有一个句号。这种片段进入索引后可能因为“长得像任何东西”而被检索出来占用宝贵的上下文位置还可能误导模型。这类噪声不是模型的问题是数据流水线的问题。实践中的改进方向过滤过短片段。设一个长度下限低于阈值的直接丢弃或合并到相邻片段。按语义边界切分。优先在段落、句子、标题处断开。加入重叠。相邻片段保留一部分重叠避免关键信息被切断。切完做一次抽查。打印若干片段人工看一遍比盲目调参有效得多。三、元数据决定你能不能追溯来源示例里每个片段都带了两项元数据source来自哪个文件。chunk_index在原文中的位置。这两项看似简单却是后面几件事的前提元数据支撑的能力来源回答时标注出处、点击查看原文位置定位原文、按需取回上下文分类/标签按部门或权限过滤检索范围版本/时间判断资料是否过期没有元数据的知识库检索得再准也无法让人信任——用户看到答案却不知道从哪来就无法判断该不该采纳。还有一个容易忽略的细节切分时要复制元数据 map而不是让多个片段共享同一个 map。meta:make(map[string]any,len(doc.MetaData)2)fork,v:rangedoc.MetaData{meta[k]v}meta[chunk_index]chunk如果直接复用doc.MetaData再改chunk_index所有片段会指向同一个 map最后全都变成相同的值。这类 bug 很隐蔽因为检索仍能工作只是来源信息错乱。四、向量化与入库真实的向量化由Embedder完成vecs,err:embedder.EmbedStrings(ctx,texts)// vecs[i] 对应 texts[i] 的向量两点提醒Embedding 模型与查询时必须用同一个。入库用一种、检索用另一种向量空间不一致结果会毫无意义。批量调用别逐条调用。EmbedStrings接受切片一次提交多条能显著降低开销。Indexer负责写入ids,err:index.Store(ctx,docs)它会返回分配的 ID 列表顺序与传入的docs对应。注意这里只传docs向量通常通过Document携带或由索引实现内部处理具体方式取决于你使用的索引实现。示例里的fakeEmbedder和memoryIndexer都是为了演示而写的模拟实现。真实项目应接入向量数据库或检索服务——Eino 的eino-ext提供了多种集成。五、增量更新别每次全量重建示例代码是全量重建索引。真实知识库会持续变化新增文档、修订内容、删除过期资料。全量重建代价高也不必要。常见做法用稳定的文档 ID。例如文件路径加内容哈希便于判断“这是同一篇文档的新版本”。内容未变则跳过。计算内容哈希未变化就不重复向量化——Embedding 调用是要花钱的。删除要一致。文档删除或修改后旧片段必须从索引中移除否则会检索到已作废的内容。保留版本元数据。便于排查“为什么检索到了旧版本”。一条实用的原则索引更新的正确性和检索算法同等重要。检索到过期或已删除的内容比检索不到更危险。六、常见问题检索总是召回不相关内容先检查切分质量。片段太长会掺入无关信息太短则缺乏上下文。打印片段人工抽查。检索到单个标点或空白片段切分没有过滤短片段。设置长度下限。来源信息全部相同或错乱切分时共享了元数据 map。为每个片段创建独立的 map。入库后检索不到确认入库与检索用的是同一个 Embedding 模型以及同一个索引。索引更新后仍返回旧内容删除逻辑不完整。修改和删除都要同步更新索引。向量化耗时或费用过高内容未变时重复向量化了。加哈希判断跳过。七、小结数据侧流水线是“加载 → 转换 → 向量化 → 入库”四个组件接口都很简单。切分是效果的关键固定长度会产生无意义碎片需要过滤、按语义边界切分并适度重叠。元数据承载来源与位置是引用、追溯、过滤的基础切分时要复制而非共享。入库与检索必须使用同一个 Embedding 模型。增量更新要处理新增、修订、删除三种情况未变化的内容不必重复向量化。下一篇《RAG 入门构建带来源引用的知识问答》我们把检索接进流程让回答有据可依。参考资料与版本说明资料核对日期为2026 年 10 月 9 日Document Loader 使用说明Document Transformer 使用说明Embedding 使用说明Indexer 使用说明Eino 主仓库本文示例按Eino v0.9.21编写。文中四个组件均使用自定义的模拟实现不依赖外部服务已实机运行正文输出包括那个只有句号的碎片为真实结果。