乐于分享
好东西不私藏

最简 RAG:让 Agent 读懂一个 PDF(第66篇-E52)

最简 RAG:让 Agent 读懂一个 PDF(第66篇-E52)

系列「企业级 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
从文件/URL 里把字抠出来
② 切片
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 步:切片

为什么要切?两个原因,第二个更重要:

  1. 塞不下
    :模型的上下文是有限的
  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 这个切片器的算法,用大白话讲就三句:

  1. 从 Separators 里挑第一个在文中出现过的分隔符,把文本切碎
  2. 把碎片按顺序粘回去,粘到快满 ChunkSize 就封口,开下一片
  3. 如果某个碎片自己就超过 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) ([][]float64error)}

一串数字有什么用?

把它想成地图坐标。北京的坐标和天津的坐标很近,和纽约的很远。向量干的是同一件事,只不过坐标不是二维(经纬度),而是 1024 维;衡量的也不是地理距离,而是意思的距离

  • 「请假要提前几天」和「休假申请提前期」→ 坐标挨得很近
  • 「请假要提前几天」和「食堂几点开饭」→ 离得很远

这就是向量检索比关键词搜索强的地方:**用户问的词和文档里的词完全不一样,也能对上。**关键词搜索里,「请假」搜不到只写了「休假」的段落。

两个必须知道的事实:

  1. 存和查必须用同一个模型。
     存的时候用 text-embedding-v3,查的时候换成 v2,坐标系不一样,算出来的「距离」纯属胡说八道。Eino 的接口注释也专门警告了这点。
  2. 向量化只做一次。
     PDF 入库时算好存下来,之后每次提问只需要算「问题」这一条的向量。所以这一步的开销是一次性的,不用心疼。

DashScope 的 text-embedding-v3 默认输出 1024 维,也可以配成 768 或 512(维度低一点,省存储、快一点、精度略降)。


第 4 步:存进去,查回来

到这一步,正常教程会让你 docker run 一个 Qdrant 或者 Milvus。

先别装。几百上千个切片,用一个 Go 切片存在内存里就够了,还能顺便看清「向量检索」到底是什么——一共 40 行:

type memStore struct {    embedder embedding.Embedder    docs     []*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) ([]stringerror) {    const batch = 10 // 云端 embedding 接口对单次条数有上限,分批发最稳    ids := make([]string0len(docs))    for i := 0; i < len(docs); i += batch {        end := min(i+batch, len(docs))        texts := make([]string0, 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 float64    for 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 string    for _, 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.Embedder    docs     []*schema.Document}var _ indexer.Indexer = (*memStore)(nil)var _ retriever.Retriever = (*memStore)(nil)func(m *memStore) Store(ctx context.Context, docs []*schema.Document,    _ ...indexer.Option) ([]stringerror) {    const batch = 10 // 云端 embedding 接口对单次条数有上限,分批发最稳    ids := make([]string0len(docs))    for i := 0; i < len(docs); i += batch {        end := min(i+batch, len(docs))        texts := make([]string0, 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 float64    for 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 片  命中 10.712):第十二条 员工因私事请假,须提前三个工作日提交…  命中 20.658):第十三条 病假须于当日 10:00 前电话告知直属主管…  命中 30.591):请假审批权限:三天以内由部门主管审批,三天以上…[5/5] 回答:需提前三个工作日提交书面申请。

三个坑,一次说完

症状
修法
ChunkSize 按字节
中文切片只有你以为的 1/3 大
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 组件的源码:LoaderTransformerParser 三个接口为什么这么分,ExtParser 的分发逻辑,以及 Eino 的 callback 是怎么挂到加载流程上的。


代码状态说明:本文所有代码在 eino v0.9.13 + eino-ext(2026-07-24 版本)下 go build 和 go vet 均通过。第 1 步的 PDF 解析输出、第 2 步的切片对比输出,都是真机跑出来的原样粘贴。第 5 步的运行结果需要你自己的 API Key 和 PDF,文中示例为格式示意。