手把手搞懂 RAG:从 PDF 上传到精准回答的全链路拆解
📌 本文是《从零吃透企业级 AI 平台:元景万悟源码学习手记》系列第一季·源码学习篇的第 3 篇
🎯 读完本文你将:① 理解 RAG 完整 Pipeline 的每个环节 ② 能在万悟平台上构建知识库并调优 ③ 能自己实现一个 30 行的 Mini RAG
⏱️ 预计阅读时间:30 分钟 | 动手实践:60 分钟
💻 前置要求:已完成第 1、2 篇,万悟平台可正常访问且已接入模型
一、这篇文章要解决什么问题?
想象你新入职一家公司,公司有 500 页的员工手册。你问 HR:"出差报销流程是什么?"HR 说:"你自己翻手册。"
如果有一个 AI,你直接问它,它翻完 500 页后精准告诉你答案——这就是 RAG(Retrieval-Augmented Generation,检索增强生成) 要做的事。
但是,直接把 500 页手册全文塞给大模型?不行:
- 上下文窗口有限:即使 128K token 也塞不下 500 页 PDF
- 成本爆炸:每次提问都传全文,token 费用扛不住
- 幻觉严重:大模型在海量无关信息中容易"编造"答案
所以 RAG 的核心思路是:先检索出最相关的几段文字,再让大模型基于这几段文字回答。
传统 LLM:用户问题 → 大模型(凭记忆回答)→ 可能胡说八道
RAG: 用户问题 → 检索相关文档片段 → 注入 Prompt → 大模型(有据可依地回答)
今天这篇,我们把万悟 RAG 的每一个环节都拆开看:文档怎么解析?文本怎么切块?向量怎么生成?检索怎么做?结果怎么融合?Prompt 怎么组装?
二、核心概念:用大白话讲清楚
2.1 Embedding:给文字发"身份证号"
💡 类比:把每段文字想象成一个人。Embedding 模型就是"户籍警",给每个人发一个 768 维的身份证号(浮点数数组)。意思相近的文字,身份证号也相近;意思无关的文字,身份证号差很远。
"出差报销需在返回后7天内提交" → [0.12, -0.34, 0.56, ..., 0.78] (768个数)
"差旅费用报销期限为返程一周内" → [0.11, -0.33, 0.55, ..., 0.77] ← 非常接近!
"今天天气真好适合出去散步" → [-0.89, 0.45, -0.12, ..., 0.03] ← 差很远
检索时,把你的问题也变成身份证号,然后计算余弦相似度,找最像的那几个。
2.2 文本分块(Chunking):为什么不能按页切?
PDF 一页可能有 3 个不相关的段落。按页切会导致:
- 一个 chunk 里混了多个话题,向量表示"模糊"
- 检索时召回了无关内容
万悟支持多种分块策略:
| 策略 | 原理 | 适用场景 |
|---|---|---|
| 固定长度 | 每 512 token 切一刀 | 通用,简单粗暴 |
| 递归字符 | 按 \n\n → \n → . → 空格 逐级尝试切分 |
保留段落完整性 |
| 语义分块 | 用 Embedding 判断相邻句子是否属于同一话题 | 效果最好,但慢 |
| Markdown 标题 | 按 # ## ### 切分 |
结构化文档 |
💡 经验值:chunk 大小通常 256~1024 token,overlap(重叠)10%~20%。太小丢失上下文,太大引入噪声。
2.3 混合检索:为什么单靠向量不够?
| 检索方式 | 擅长 | 不擅长 |
|---|---|---|
| 向量检索 | 语义相似("报销流程" ↔ "费用申领步骤") | 精确匹配(工号、专有名词、代码) |
| 关键词检索(BM25) | 精确匹配("ERR-4012"、"张三") | 同义词、 paraphrase |
混合检索 = 两路并行 + 融合排序。万悟默认使用 RRF(Reciprocal Rank Fusion) 算法:
RRF_score(d) = Σ 1/(k + rank_i(d))其中 k=60(常数),rank_i(d) 是文档 d 在第 i 路检索中的排名
💡 类比:向量检索像"找长得像的人",关键词检索像"查身份证号码"。两个警察各出一份嫌疑人名单,RRF 就是把两份名单合并,同时在两份名单上都靠前的人排前面。
2.4 RAG 完整 Pipeline 全景图
▲ RAG 完整数据流(建议对照此图阅读下文)
【离线索引阶段】
文档上传 → MinerU 解析(PDF→Markdown) → 文本分块 → Embedding 向量化→ 写入向量数据库 + 关键词索引 → 标记"可用"【在线检索阶段】
用户提问 → 问题向量化 → ┬→ 向量检索(语义) ─┐└→ 关键词检索(BM25)─┘→ RRF 融合排序 → Top-K 文本块→ Prompt 组装 → LLM 生成 → 流式返回
三、万悟是怎么实现的?(源码篇)
3.1 定位源码
internal/rag-service/
├── handler/
│ ├── document.go # 文档上传/删除接口
│ └── search.go # 检索接口(核心入口)
├── service/
│ ├── ingest.go # 索引构建编排(解析→分块→向量化→存储)
│ └── search.go # 检索编排(向量+关键词→融合→组装Prompt)
├── repository/
│ ├── vector.go # 向量数据库操作
│ ├── keyword.go # 关键词索引操作
│ └── document.go # 文档元数据 MySQL 操作
├── parser/
│ └── mineru.go # MinerU 文档解析适配器
├── chunker/
│ ├── fixed.go # 固定长度分块
│ ├── recursive.go # 递归字符分块
│ └── semantic.go # 语义分块
└── embedding/└── client.go # Embedding 模型调用封装
3.2 索引构建流程(ingest.go)
// 文件:internal/rag-service/service/ingest.go// IngestDocument 是文档索引的主入口
// 一个文档从上传到可检索,全部由这个函数编排
func (s *Service) IngestDocument(ctx context.Context, docID string) error {// Step 1: 从 MinIO 下载原始文件fileBytes, err := s.minioClient.GetObject(ctx, docID)if err != nil {return fmt.Errorf("download file failed: %w", err)}// Step 2: 文档解析(PDF/Word/扫描件 → Markdown)// MinerU 是一个开源的文档解析工具,支持表格、公式、图片识别markdown, err := s.parser.Parse(ctx, fileBytes, doc.Metadata.Format)if err != nil {return fmt.Errorf("parse document failed: %w", err)}// Step 3: 文本分块// 根据知识库配置选择分块策略(fixed / recursive / semantic)chunks, err := s.chunker.Split(markdown, ChunkConfig{Size: doc.KnowledgeBase.ChunkSize, // 如 512Overlap: doc.KnowledgeBase.ChunkOverlap, // 如 64Strategy: doc.KnowledgeBase.ChunkStrategy,})if err != nil {return fmt.Errorf("split chunks failed: %w", err)}// Step 4: 批量向量化// 调用 Embedding 模型,将每个 chunk 转为向量vectors, err := s.embeddingClient.BatchEmbed(ctx, chunks)if err != nil {return fmt.Errorf("embedding failed: %w", err)}// Step 5: 写入向量数据库 + 关键词索引// 向量数据库存储:chunk_id, doc_id, vector, text, metadataerr = s.vectorRepo.BulkInsert(ctx, chunks, vectors)// 关键词索引存储:chunk_id, doc_id, text(用于 BM25)err = s.keywordRepo.BulkInsert(ctx, chunks)// Step 6: 更新文档状态为"可用"s.docRepo.UpdateStatus(ctx, docID, StatusReady)return nil
}
📌 关键洞察:整个索引流程是一个串行 Pipeline。每一步失败都会中断并记录错误。万悟在生产环境中还会加上重试、断点续传、进度回调等机制,但核心骨架就是这 6 步。
3.3 检索流程(search.go)
// 文件:internal/rag-service/service/search.go// Search 是在线检索的主入口
func (s *Service) Search(ctx context.Context, req SearchRequest) (*SearchResponse, error) {// Step 1: 将用户问题向量化queryVector, err := s.embeddingClient.Embed(ctx, req.Query)if err != nil {return nil, fmt.Errorf("embed query failed: %w", err)}// Step 2: 两路并行检索// 用 goroutine 并发执行,减少延迟var vectorResults, keywordResults []ChunkScorevar wg sync.WaitGroupwg.Add(2)go func() {defer wg.Done()// 向量检索:ANN(近似最近邻),返回 Top-KvectorResults, _ = s.vectorRepo.Search(ctx, VectorSearchParams{Vector: queryVector,TopK: req.TopK * 2, // 多召回一些,留给融合排序筛选Filter: req.Filters, // 租户隔离、标签过滤等})}()go func() {defer wg.Done()// 关键词检索:BM25 算法keywordResults, _ = s.keywordRepo.Search(ctx, KeywordSearchParams{Query: req.Query,TopK: req.TopK * 2,})}()wg.Wait() // 等待两路都完成// Step 3: RRF 融合排序merged := rrfMerge(vectorResults, keywordResults, req.TopK)// Step 4: 返回结果(包含文本、分数、来源文档信息)return &SearchResponse{Chunks: merged}, nil
}// rrfMerge 实现 Reciprocal Rank Fusion 算法
func rrfMerge(vectorRes, keywordRes []ChunkScore, topK int) []ChunkScore {const k = 60 // RRF 常数scores := make(map[string]float64)chunkMap := make(map[string]ChunkScore)// 累加向量检索的 RRF 分数for rank, item := range vectorRes {scores[item.ID] += 1.0 / float64(k+rank+1)chunkMap[item.ID] = item}// 累加关键词检索的 RRF 分数for rank, item := range keywordRes {scores[item.ID] += 1.0 / float64(k+rank+1)chunkMap[item.ID] = item}// 按融合分数降序排列,取 Top-Ksorted := sortByScore(scores)if len(sorted) > topK {sorted = sorted[:topK]}result := make([]ChunkScore, len(sorted))for i, id := range sorted {chunk := chunkMap[id]chunk.Score = scores[id] // 用融合分数替换原始分数result[i] = chunk}return result
}
💡 设计亮点:两路检索用
goroutine并发执行,总耗时 ≈ max(向量检索耗时, 关键词检索耗时),而不是两者之和。这在生产环境中对响应速度至关重要。
3.4 Prompt 组装
检索到的文本块不会直接丢给 LLM,而是经过精心组装:
// 文件:internal/assistant-service/service/prompt_builder.gofunc BuildRAGPrompt(query string, chunks []ChunkScore, systemPrompt string) string {// 将检索结果格式化为上下文var context strings.Builderfor i, chunk := range chunks {context.WriteString(fmt.Sprintf("[来源%d] %s\n", i+1, chunk.Text))}// 组装最终 Promptreturn fmt.Sprintf(`%s## 参考资料
%s## 用户问题
%s## 回答要求
- 仅基于上述参考资料回答
- 如果资料中没有相关信息,明确告知用户
- 引用来源时标注 [来源X]`, systemPrompt, context.String(), query)
}
📌 为什么 Prompt 模板很重要? 一个好的 RAG Prompt 能显著减少幻觉。关键要素:① 明确指示"仅基于资料回答" ② 要求标注来源 ③ 允许说"不知道"。
四、动手跑通(实践篇)
4.1 准备测试文档
准备 3-5 篇 PDF,建议选有明确问答关系的文档:
- 公司员工手册(制度类)
- 产品技术文档(API 文档)
- FAQ 文档(问答对)
⚠️ 避免用纯图片扫描版 PDF(除非你确认 MinerU OCR 已配置)。先用文字版 PDF 验证流程。
4.2 创建知识库
- 进入「知识库」→「新建知识库」
- 填写名称,选择 Embedding 模型(和第 2 篇接入的模型对应)
- 分块策略选「递归字符」,大小 512,重叠 64
- 上传文档 → 点击「构建」
- 等待状态变为 ✅ 可用
▲ 知识库构建完成,所有文档状态为"可用"
4.3 测试检索效果
在知识库的「测试」面板中:
问题:出差报销需要在返回后几天内提交?
观察:
- 召回了几个文本块?
- 每个块的相似度分数是多少?
- 最终回答是否准确引用了来源?
4.4 对比实验(核心!)
同一个问题,分别测试三种模式:
| 模式 | 操作 | 预期发现 |
|---|---|---|
| 纯向量检索 | 关闭关键词检索 | 语义问题好,精确匹配差 |
| 纯关键词检索 | 关闭向量检索 | 精确匹配好,同义词差 |
| 混合检索 | 两路都开 | 综合效果最好 |
📌 记录你的发现。比如:"问'ERR-4012 怎么处理'时,纯向量检索召回了无关的错误码说明,混合检索精准命中了 ERR-4012 的处理步骤。" 这就是你博客中最有价值的原创内容。
4.5 调优实验
修改分块参数,观察效果变化:
| 实验 | Chunk Size | Overlap | 预期效果 |
|---|---|---|---|
| A | 256 | 32 | 粒度细,召回多但碎片化 |
| B | 512 | 64 | 平衡(推荐起点) |
| C | 1024 | 128 | 上下文完整,但可能引入噪声 |
五、自己造一个 Mini 版(Deep Dive)
🎯 目标:用 Python 写一个 30 行的最简 RAG
# mini_rag.py
# 依赖:pip install openai numpy
import openai, numpy as np# 1. 准备文档(模拟已分块的文本)
docs = ["出差报销需在返回后7个工作日内提交,逾期不予受理。","年假需提前3个工作日通过OA系统申请,部门负责人审批。","差旅住宿标准:一线城市不超过500元/晚,二线不超过350元/晚。","加班餐补标准为每餐30元,需提供加班审批记录。",
]# 2. 向量化
client = openai.OpenAI(api_key="sk-your-key")def embed(text):resp = client.embeddings.create(input=text, model="text-embedding-3-small")return np.array(resp.data[0].embedding)doc_vectors = [embed(d) for d in docs]# 3. 检索(余弦相似度)
def search(query, top_k=2):q_vec = embed(query)scores = [np.dot(q_vec, dv) / (np.linalg.norm(q_vec) * np.linalg.norm(dv))for dv in doc_vectors]top_idx = np.argsort(scores)[-top_k:][::-1]return [docs[i] for i in top_idx]# 4. 生成
def answer(query):context = "\n".join(f"[来源{i+1}] {c}" for i, c in enumerate(search(query)))resp = client.chat.completions.create(model="gpt-4o-mini",messages=[{"role": "system", "content": f"仅基于以下资料回答,标注来源:\n{context}"},{"role": "user", "content": query}])return resp.choices[0].message.content# 测试
print(answer("出差回来多久之内要报销?"))
# 预期输出:根据[来源1],出差报销需在返回后7个工作日内提交。
🎉 跑通了吗?这 30 行就是万悟 RAG 的核心骨架。万悟在此基础上加了:MinerU 文档解析、多种分块策略、混合检索、RRF 融合、多租户隔离、增量索引、缓存……但灵魂就是你上面看到的这 4 步:分块 → 向量化 → 检索 → 生成。
六、总结 & 延伸阅读
本文要点回顾
- ✅ RAG = 先检索再生成,解决 LLM 上下文限制和幻觉问题
- ✅ Embedding 将文本转为向量,余弦相似度衡量语义距离
- ✅ 分块策略直接影响检索质量,推荐从递归字符 512/64 起步
- ✅ 混合检索(向量 + BM25)+ RRF 融合是当前最佳实践
- ✅ Prompt 模板要明确要求"仅基于资料回答 + 标注来源"
课后作业
- 在万悟平台创建一个知识库,上传至少 3 篇文档
- 完成纯向量 / 纯关键词 / 混合检索的对比实验,记录结果
- 修改分块参数,观察检索效果变化
- 跑通 Mini RAG,理解 4 步核心流程
- 画出 RAG 完整 Pipeline 图(不看本文)
下一篇预告
第 4 篇:当 RAG 遇上知识图谱——GraphRAG 到底强在哪?
我们将深入万悟的 UniAI-GraphRAG 模块,看看知识图谱如何解决传统 RAG 无法处理的"跨文档关联推理"问题。
参考资源
- 元景万悟 GitHub:https://github.com/UnicomAI/wanwu
- MinerU 文档解析:https://github.com/opendatalab/MinerU
- RRF 论文:Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods
- LangChain Chunking 文档:https://python.langchain.com/docs/how_to/#text-splitters
📮 点赞 / 收藏 / 关注,不错过后续更新
🔔 下一篇:《当 RAG 遇上知识图谱——GraphRAG 到底强在哪?》
本文来自博客园,作者:老羅,转载请注明原文链接:https://www.cnblogs.com/laoluo2025/p/22223658