系列「企业级 AI Agent 实现拆解」E52 篇,Part 13 RAG 篇第一章。上一篇 讲了 ToolsNode 怎么让 Agent「动手」。这篇解决另一个问题:Agent 不知道你公司的规章制度,怎么让它读一份 PDF 再回答问题。
读完这篇你会得到
一个能跑的 Go 程序:PDF → 切片 → 向量化 → 检索 → 回答,五步走完 不用懂数学,也能明白「向量检索」到底在干什么 Eino 的四个接口:Loader / Transformer / Embedder / Retriever 各管一段 三个真实踩到的坑(都有实测输出,不是我编的) 从「内存版向量库」换成 Redis / Qdrant 要改几行
先说清楚:RAG 是什么
假设你们公司有本《员工手册》,PDF,60 页。你问 AI:「请假要提前几天申请?」
AI 答不上来。它训练的时候没见过你们公司的手册。
有两条路:
路 A:整本塞给它。 60 页大概 5 万字,每次提问都塞一遍。贵,慢,而且很多模型根本塞不下。更麻烦的是——资料越长,模型越容易看走眼,答非所问。
路 B:只塞相关的那两三段。 提问前先「翻书」,找出跟「请假天数」有关的三小段,连问题一起交给模型。
路 B 就是 RAG(Retrieval-Augmented Generation,检索增强生成)。
说白了就是开卷考试:不用背下整本书,考试时翻到相关那页照着答就行。
有个认知很关键,很多人一开始会搞错:
Agent 并没有「记住」这份 PDF。 它每次回答前,都是被临时塞了几段原文。塞完就忘。
所以 RAG 的全部工程量,都花在「怎么把书翻到正确的那一页」上。
五步全景
┌──────────┐│ 员工手册 │ 一份 PDF│ .pdf │└────┬─────┘│ ① 加载 Loader + Parser▼┌──────────────────────────┐│ "第一条 员工请假需提前..." │ 一大坨文字└────┬─────────────────────┘│ ② 切片 Transformer▼┌────┐ ┌────┐ ┌────┐ ┌────┐│片1 │ │片2 │ │片3 │ │... │ 每片 300~500 字└─┬──┘ └─┬──┘ └─┬──┘ └─┬──┘│ │ │ │ ③ 向量化 Embedder▼ ▼ ▼ ▼[0.1, [0.8, [0.3, [...] 每片变成一串数字0.7, 0.2, 0.9,...] ...] ...]│ │ │ │ ④ 存起来 + 查回来 Indexer / Retriever└──────┴───┬──┴──────┘│问题:"请假要提前几天?"│ 也变成一串数字,跟上面逐个比对▼┌──────────────┐│ 最像的 3 片 │└──────┬───────┘│ ⑤ 连同问题交给大模型▼┌──────────────┐│ "需提前三天" │└──────────────┘
Eino 把这五步定义成了四个接口,一步一个,互不打扰:
document.Loader | ||
document.Transformer | ||
embedding.Embedder | ||
indexer.Indexer | ||
retriever.Retriever |
接口拆得这么细,好处后面第 4 步会看到:内存版换成 Qdrant,上层代码一行不用改。
准备工作
你需要:
- Go 1.21+
( go version验证) - 一个 PDF
,随便什么都行,最好是中文的 - 阿里云百炼(DashScope)API Key
:去 bailian.console.aliyun.com 开通,新用户有免费额度
为什么用百炼?国内直连不用代理,向量模型和对话模型一个 Key 全搞定,而且接口是 OpenAI 兼容格式——将来想换 OpenAI、DeepSeek、本地 Ollama,改个 BaseURL 就行。
建项目:
mkdir simple-rag && cd simple-raggo mod init simple-raggo get github.com/cloudwego/einogo get github.com/cloudwego/eino-ext/components/document/loader/filego get github.com/cloudwego/eino-ext/components/document/parser/pdfgo get github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursivego get github.com/cloudwego/eino-ext/components/embedding/dashscopego get github.com/cloudwego/eino-ext/components/model/openai
eino 是框架本体(只有接口和编排),eino-ext 是各种具体实现(PDF 解析器、切片器、各家模型驱动)。这个分法很清爽:换实现不动框架。
设置 Key:
export DASHSCOPE_API_KEY=sk-你的key第 1 步:把 PDF 变成文字
// PDF 解析器:负责认识 PDF 这种格式pdfParser, err := pdf.NewPDFParser(ctx, &pdf.Config{ToPages: false})// 扩展名分发器:.pdf 交给 pdfParser,其他的按纯文本读extParser, err := parser.NewExtParser(ctx, &parser.ExtParserConfig{Parsers: map[string]parser.Parser{".pdf": pdfParser},FallbackParser: parser.TextParser{},})// 文件加载器:负责打开文件、读字节loader, err := file.NewFileLoader(ctx, &file.FileLoaderConfig{UseNameAsID: true,Parser: extParser,})docs, err := loader.Load(ctx, document.Source{URI: "./handbook.pdf"})
三层套娃,各管一件事:
FileLoader管打开文件( os.Open,还会拦住你误传目录)ExtParser管按扩展名分发( .pdf走 PDF 解析,.md走纯文本)PDFParser管认识 PDF 格式
ToPages: false 表示整份 PDF 合成一个 Document;改成 true 就是一页一个。
跑一下,输出长这样(这是我用 eino-ext 仓库自带的测试 PDF 真跑出来的):
ID="test_pdf.pdf"MetaData=map[_extension:.pdf _file_name:test_pdf.pdf _source:/path/to/test_pdf.pdf]Content="\ntest\na\nnew\npdf.\na\nnew\nline\nwith\n中文。\n\n尝试\n一些\n样式。\n"
MetaData 里那三个 key 是 FileLoader 自动塞的,后面要做「答案出自哪个文件第几段」的溯源,全靠它。
坑 1:PDF 解析出来的文字是碎的
看清楚上面的 Content:每个词之间是 \n,空格全没了。
这不是 bug,是 PDF 这种格式的原罪——它存的是「第几个字符画在第几个坐标」,本来就没有「空格」和「换行」的概念,全靠解析器猜。
eino-ext 的源码注释写得很直白:
// Attention: This is in alpha stage, and may not support all PDF use cases well enough.// For example, it will not preserve whitespace and new line for now.
对中文影响不大(中文本来不靠空格断词),对英文和排版复杂的 PDF 影响很大。
实用建议:
中文合同、制度、手册 → 够用 带表格、多栏排版、扫描件 → 别指望,考虑先转 Markdown 再喂(或者上 OCR) 结论:先把 PDF 读出来打印一遍看看,别直接进下一步。这一步糊了,后面全是白干。
第 2 步:切片
为什么要切?两个原因,第二个更重要:
- 塞不下
:模型的上下文是有限的 - 切得越准,答得越准
:你问「请假几天」,塞给模型半页纸的「请假条例」,比塞 60 页全文准得多
sp, err := recursive.NewSplitter(ctx, &recursive.Config{ChunkSize: 500,OverlapSize: 80,Separators: []string{"\n\n", "。", "!", "?", "\n"},LenFunc: func(s string) int { return len([]rune(s)) }, // 关键!IDGenerator: func(_ context.Context, id string, i int) string {return fmt.Sprintf("%s#%d", id, i)},})chunks, err := sp.Transform(ctx, docs)
recursive 这个切片器的算法,用大白话讲就三句:
从 Separators里挑第一个在文中出现过的分隔符,把文本切碎把碎片按顺序粘回去,粘到快满 ChunkSize就封口,开下一片如果某个碎片自己就超过 ChunkSize,换下一个分隔符递归再切
OverlapSize: 80 是「重叠」:下一片会把上一片末尾的 80 个字带上。为什么要重叠?因为切片是机械的,很可能正好从一句话中间劈开:
片1: ...员工请假需提前三天片2: 提交书面申请,经直属主管...
单看哪一片都答不全。留个重叠,两边都有完整语义的概率就高多了。
坑 2:ChunkSize 默认按字节算,中文直接缩水到 1/3
Config.LenFunc 不填的话,默认是 Go 的 len()——算字节数。一个汉字 UTF-8 占 3 字节。
所以你写 ChunkSize: 500,以为是 500 个字,实际只有 166 个汉字。
实测对比(同一段中文,ChunkSize 都是 30):
原文:31 个字 / 93 字节ChunkSize=30,默认 LenFunc(按字节) → 3 片[0] "员工请假需提前三天申请"[1] "病假须附医院证明"[2] "年假当年有效不结转。"ChunkSize=30,改成按字数 → 1 片[0] "员工请假需提前三天申请。病假须附医院证明。年假当年有效不结转"
同样的配置,一个切成 3 片,一个 1 片没切。差了 3 倍。
处理中文,永远记得加这一行:
LenFunc: func(s string) int { return len([]rune(s)) },坑 3:分隔符的顺序不是随便写的
切片器是从前往后挑第一个能用的分隔符,顺序 = 优先级。
回头看第 1 步的输出——PDF 解析出来 \n 满天飞,每个词后面都是。如果你把 "\n" 写在前面:
Separators: []string{"\n", "。"}, // 对 PDF 来说很糟切片器一看「文中有 \n」,立刻用它切,结果就是按单词切碎,"。" 根本轮不上(除非碎片还超 ChunkSize)。虽然后面 merge 会粘回来,但断点位置就完全不受你控制了。
正确顺序是从大到小:段落 → 句子 → 兜底。
Separators: []string{"\n\n", "。", "!", "?", "\n"},顺带一提,切开时分隔符会被丢掉(默认 KeepTypeNone),但粘回去的时候 mergeSplits 会用同一个分隔符重新连上,所以句号不会真的消失——只有每片最末尾那个会掉。想保留就用 KeepTypeEnd。
第 3 步:向量化
embedder, err := dashscope.NewEmbedder(ctx, &dashscope.EmbeddingConfig{APIKey: os.Getenv("DASHSCOPE_API_KEY"),Model: "text-embedding-v3",})vectors, err := embedder.EmbedStrings(ctx, []string{"员工请假需提前三天申请"})// vectors[0] = [0.023, -0.117, 0.088, ... ] 共 1024 个数
接口只有一个方法,干净得不像话:
type Embedder interface {EmbedStrings(ctx context.Context, texts []string, opts ...Option) ([][]float64, error)}
一串数字有什么用?
把它想成地图坐标。北京的坐标和天津的坐标很近,和纽约的很远。向量干的是同一件事,只不过坐标不是二维(经纬度),而是 1024 维;衡量的也不是地理距离,而是意思的距离。
「请假要提前几天」和「休假申请提前期」→ 坐标挨得很近 「请假要提前几天」和「食堂几点开饭」→ 离得很远
这就是向量检索比关键词搜索强的地方:**用户问的词和文档里的词完全不一样,也能对上。**关键词搜索里,「请假」搜不到只写了「休假」的段落。
两个必须知道的事实:
- 存和查必须用同一个模型。
存的时候用 text-embedding-v3,查的时候换成 v2,坐标系不一样,算出来的「距离」纯属胡说八道。Eino 的接口注释也专门警告了这点。 - 向量化只做一次。
PDF 入库时算好存下来,之后每次提问只需要算「问题」这一条的向量。所以这一步的开销是一次性的,不用心疼。
DashScope 的 text-embedding-v3 默认输出 1024 维,也可以配成 768 或 512(维度低一点,省存储、快一点、精度略降)。
第 4 步:存进去,查回来
到这一步,正常教程会让你 docker run 一个 Qdrant 或者 Milvus。
先别装。几百上千个切片,用一个 Go 切片存在内存里就够了,还能顺便看清「向量检索」到底是什么——一共 40 行:
type memStore struct {embedder embedding.Embedderdocs []*schema.Document}// 同时实现 Eino 的两个官方接口var _ indexer.Indexer = (*memStore)(nil)var _ retriever.Retriever = (*memStore)(nil)// 存:批量算向量,挂到 Document 上func(m *memStore) Store(ctx context.Context, docs []*schema.Document,_ ...indexer.Option) ([]string, error) {const batch = 10 // 云端 embedding 接口对单次条数有上限,分批发最稳ids := make([]string, 0, len(docs))for i := 0; i < len(docs); i += batch {end := min(i+batch, len(docs))texts := make([]string, 0, end-i)for _, d := range docs[i:end] {texts = append(texts, d.Content)}vectors, err := m.embedder.EmbedStrings(ctx, texts)if err != nil {return nil, fmt.Errorf("embed chunk[%d:%d]: %w", i, end, err)}for j, d := range docs[i:end] {m.docs = append(m.docs, d.WithDenseVector(vectors[j]))ids = append(ids, d.ID)}}return ids, nil}// 查:问题也算成向量,跟每一片比一比,排序取前 Kfunc(m *memStore) Retrieve(ctx context.Context, query string,opts ...retriever.Option) ([]*schema.Document, error) {o := retriever.GetCommonOptions(&retriever.Options{TopK: ptr(5)}, opts...)qv, err := m.embedder.EmbedStrings(ctx, []string{query})if err != nil {return nil, fmt.Errorf("embed query: %w", err)}hits := make([]*schema.Document, len(m.docs))for i, d := range m.docs {hits[i] = d.WithScore(cosine(qv[0], d.DenseVector()))}sort.Slice(hits, func(i, j int) bool { return hits[i].Score() > hits[j].Score() })return hits[:min(*o.TopK, len(hits))], nil}
比较相似度用的是余弦相似度,本质是「两个箭头的夹角」:
funccosine(a, b []float64) float64 {var dot, na, nb float64for i := range a {dot += a[i] * b[i]na += a[i] * a[i]nb += b[i] * b[i]}if na == 0 || nb == 0 {return 0}return dot / (math.Sqrt(na) * math.Sqrt(nb))}
夹角越小越像,结果越接近 1;完全无关约等于 0。为什么用夹角而不是直线距离? 因为只看方向、不看长度——一段话说得长还是短,不影响它「讲的是什么」。
注意 Document 上那几个方法:WithDenseVector / DenseVector 存向量,WithScore / Score 存分数。它们其实都是往 MetaData 这个 map[string]any 里塞值,只是包了一层带类型的方法,省得你到处写 d.MetaData["_dense_vector"] 这种魔法字符串。
什么时候该换成真的向量库
内存版有两个硬伤:程序一重启全没了;每次查询要跟所有片挨个算一遍(几万片以上就明显卡了)。
好消息是——因为你实现的是 Eino 的标准接口,换库只改构造函数那几行,上层调用一个字不动:
// 内存版store := &memStore{embedder: embedder}// 换 Redis(redis-stack 自带向量检索)idx, _ := redisIndexer.NewIndexer(ctx, &redisIndexer.IndexerConfig{...})rtr, _ := redisRetriever.NewRetriever(ctx, &redisRetriever.RetrieverConfig{...})
eino-ext 现成的实现有一排:Redis、Qdrant、Milvus、Elasticsearch(7/8/9)、OpenSearch、火山 VikingDB。
我的建议是别一上来就上重家伙:
- 几百到几千片
(一本手册、一个项目的文档)→ 内存版够用,甚至可以把向量存成 JSON 文件,启动时读回来 - 上万片、要持久化、多实例共享
→ 上 Redis 或 Qdrant, docker run一条命令的事
第 5 步:把命中的片喂给大模型
cm, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",APIKey: os.Getenv("DASHSCOPE_API_KEY"),Model: "qwen-plus",})var ctxText stringfor _, h := range hits {ctxText += "---\n" + h.Content + "\n"}answer, err := cm.Generate(ctx, []*schema.Message{schema.SystemMessage("你是公司制度问答助手。只依据【参考资料】回答;资料里没有就说“手册里没写”。"),schema.UserMessage(fmt.Sprintf("【参考资料】\n%s\n【问题】%s", ctxText, question)),})
这段代码里最值钱的是那句 system prompt:
只依据【参考资料】回答;资料里没有就说"手册里没写"。
不加这句,模型检索不到相关内容时会自己编一个听起来很合理的答案——这就是所谓的「幻觉」。加了这句不能根治,但能挡掉大部分。
RAG 的责任边界要拎清楚:检索负责「找对材料」,prompt 负责「别瞎发挥」。找错了材料,prompt 写得再好也救不回来。
完整代码
拼起来就是能跑的完整程序:
package mainimport ("context""fmt""log""math""os""sort""github.com/cloudwego/eino-ext/components/document/loader/file""github.com/cloudwego/eino-ext/components/document/parser/pdf""github.com/cloudwego/eino-ext/components/document/transformer/splitter/recursive""github.com/cloudwego/eino-ext/components/embedding/dashscope""github.com/cloudwego/eino-ext/components/model/openai""github.com/cloudwego/eino/components/document""github.com/cloudwego/eino/components/document/parser""github.com/cloudwego/eino/components/embedding""github.com/cloudwego/eino/components/indexer""github.com/cloudwego/eino/components/retriever""github.com/cloudwego/eino/schema")funcmain() {ctx := context.Background()question := "请假需要提前几天申请?"// ── 第 1 步:把 PDF 读成 Document ──────────────────────────pdfParser, err := pdf.NewPDFParser(ctx, &pdf.Config{ToPages: false})must(err)extParser, err := parser.NewExtParser(ctx, &parser.ExtParserConfig{Parsers: map[string]parser.Parser{".pdf": pdfParser},FallbackParser: parser.TextParser{},})must(err)loader, err := file.NewFileLoader(ctx, &file.FileLoaderConfig{UseNameAsID: true,Parser: extParser,})must(err)docs, err := loader.Load(ctx, document.Source{URI: "./handbook.pdf"})must(err)fmt.Printf("[1/5] 读到 %d 篇文档,共 %d 个字\n", len(docs), len([]rune(docs[0].Content)))// ── 第 2 步:切片 ────────────────────────────────────────sp, err := recursive.NewSplitter(ctx, &recursive.Config{ChunkSize: 500,OverlapSize: 80,Separators: []string{"\n\n", "。", "!", "?", "\n"},LenFunc: func(s string) int { return len([]rune(s)) }, // 按字数,不是字节数IDGenerator: func(_ context.Context, id string, i int) string {return fmt.Sprintf("%s#%d", id, i)},})must(err)chunks, err := sp.Transform(ctx, docs)must(err)fmt.Printf("[2/5] 切成 %d 片\n", len(chunks))// ── 第 3 步:准备向量化模型 ──────────────────────────────embedder, err := dashscope.NewEmbedder(ctx, &dashscope.EmbeddingConfig{APIKey: os.Getenv("DASHSCOPE_API_KEY"),Model: "text-embedding-v3",})must(err)// ── 第 4 步:存进"向量库",再查回来 ───────────────────────store := &memStore{embedder: embedder}ids, err := store.Store(ctx, chunks)must(err)fmt.Printf("[3/5][4/5] 已向量化并入库 %d 片\n", len(ids))hits, err := store.Retrieve(ctx, question, retriever.WithTopK(3))must(err)for i, h := range hits {fmt.Printf(" 命中 %d(%.3f):%s\n", i+1, h.Score(), preview(h.Content, 40))}// ── 第 5 步:把命中的片喂给 LLM ──────────────────────────cm, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",APIKey: os.Getenv("DASHSCOPE_API_KEY"),Model: "qwen-plus",})must(err)var ctxText stringfor _, h := range hits {ctxText += "---\n" + h.Content + "\n"}answer, err := cm.Generate(ctx, []*schema.Message{schema.SystemMessage("你是公司制度问答助手。只依据【参考资料】回答;资料里没有就说“手册里没写”。"),schema.UserMessage(fmt.Sprintf("【参考资料】\n%s\n【问题】%s", ctxText, question)),})must(err)fmt.Printf("[5/5] 回答:%s\n", answer.Content)}// ── 最简"向量库":一个切片 + 余弦相似度 ─────────────────────type memStore struct {embedder embedding.Embedderdocs []*schema.Document}var _ indexer.Indexer = (*memStore)(nil)var _ retriever.Retriever = (*memStore)(nil)func(m *memStore) Store(ctx context.Context, docs []*schema.Document,_ ...indexer.Option) ([]string, error) {const batch = 10 // 云端 embedding 接口对单次条数有上限,分批发最稳ids := make([]string, 0, len(docs))for i := 0; i < len(docs); i += batch {end := min(i+batch, len(docs))texts := make([]string, 0, end-i)for _, d := range docs[i:end] {texts = append(texts, d.Content)}vectors, err := m.embedder.EmbedStrings(ctx, texts)if err != nil {return nil, fmt.Errorf("embed chunk[%d:%d]: %w", i, end, err)}for j, d := range docs[i:end] {m.docs = append(m.docs, d.WithDenseVector(vectors[j]))ids = append(ids, d.ID)}}return ids, nil}func(m *memStore) Retrieve(ctx context.Context, query string,opts ...retriever.Option) ([]*schema.Document, error) {o := retriever.GetCommonOptions(&retriever.Options{TopK: ptr(5)}, opts...)qv, err := m.embedder.EmbedStrings(ctx, []string{query})if err != nil {return nil, fmt.Errorf("embed query: %w", err)}hits := make([]*schema.Document, len(m.docs))for i, d := range m.docs {hits[i] = d.WithScore(cosine(qv[0], d.DenseVector()))}sort.Slice(hits, func(i, j int) bool { return hits[i].Score() > hits[j].Score() })return hits[:min(*o.TopK, len(hits))], nil}funccosine(a, b []float64) float64 {var dot, na, nb float64for i := range a {dot += a[i] * b[i]na += a[i] * a[i]nb += b[i] * b[i]}if na == 0 || nb == 0 {return 0}return dot / (math.Sqrt(na) * math.Sqrt(nb))}funcptr[Tany](v T) *T { return &v }funcpreview(s string, n int) string {r := []rune(s)if len(r) <= n {return s}return string(r[:n]) + "…"}funcmust(err error) {if err != nil {log.Fatal(err)}}
跑:
go run .输出形如:
[1/5] 读到 1 篇文档,共 18432 个字[2/5] 切成 47 片[3/5][4/5] 已向量化并入库 47 片命中 1(0.712):第十二条 员工因私事请假,须提前三个工作日提交…命中 2(0.658):第十三条 病假须于当日 10:00 前电话告知直属主管…命中 3(0.591):请假审批权限:三天以内由部门主管审批,三天以上…[5/5] 回答:需提前三个工作日提交书面申请。
三个坑,一次说完
ChunkSize 按字节 | LenFunc: func(s string) int { return len([]rune(s)) } | |
| 分隔符顺序反了 | "\n\n" → "。" → "\n" | |
| PDF 文字是碎的 |
还有一个不算坑但很多人栽过的:存和查必须用同一个 embedding 模型。换模型 = 整库重新向量化,没有捷径。
这套东西的天花板在哪
这 100 行是能跑的最小 RAG。够用,但离「好用」还有距离,短板很实在:
- 切片是机械的
:按字数硬切,一张表格、一条完整条款可能被劈成两半 → E68 讲切片策略怎么选 - 只查一次
:用户问得含糊(「那个流程是啥来着」),向量对不上就是对不上 → E74 讲多查询改写 + 重排序 - 只认字面语义
:具体的产品型号、工单号,向量检索反而不如关键词精确 → 混合检索 - 内存存储
:重启就没,也扛不住几万片
但这些优化都建立在同一个骨架上——加载、切片、向量化、检索、生成,五步一步都不会少。骨架先立住,后面每一篇都是往上面挂零件。
小结
RAG = 开卷考试。Agent 不「记住」文档,只是提问前被塞了几段原文 五步:Loader 读 → Transformer 切 → Embedder 转数字 → Indexer/Retriever 存查 → ChatModel 答 Eino 把每步定义成独立接口,所以内存版换 Qdrant,上层代码不用动 中文务必换 LenFunc,分隔符从大到小排,PDF 读完先打印一遍别一上来就 docker run向量库,几千片以内内存版更省事
下一篇 E67,拆 Document 组件的源码:Loader、Transformer、Parser 三个接口为什么这么分,ExtParser 的分发逻辑,以及 Eino 的 callback 是怎么挂到加载流程上的。
代码状态说明:本文所有代码在
eino v0.9.13+eino-ext(2026-07-24 版本)下go build和go vet均通过。第 1 步的 PDF 解析输出、第 2 步的切片对比输出,都是真机跑出来的原样粘贴。第 5 步的运行结果需要你自己的 API Key 和 PDF,文中示例为格式示意。
夜雨聆风