RAG 最小实现
专栏:Agent 工程 · 第 9 / 18 篇阶段四开始处理”知识接入”:让 agent 能回答不在它训练数据、也不在工作目录里的问题——你的私有文档、笔记、聊天记录。标准答案是 RAG(检索增强生成)。这篇先跑通最小实现。
:::info 学习目标 完成本篇后你能够:解释 RAG 管线的四个环节(分块/向量化/检索/生成);跑通一个本地可复现的 MiniRAG 并接成 agent 工具;说清为什么 coding agent 反而不内置 RAG。 前置:第 4 篇的 mini-agent 可用。预计时长:60 分钟。 :::
:::note 本章术语速查(新手建议先读)
- RAG(检索增强生成):先从你的文档里”搜出相关片段”,再让模型基于片段回答——解决模型不知道你私有信息的问题。
- Embedding(向量化):把一段文字变成一串数字(向量),语义相近的文字向量也相近——“意思接近”变成了可以计算的”距离接近”。
- 分块(Chunking):把长文档切成小段再建索引,检索时只取相关的小段。
- 余弦相似度:衡量两个向量方向是否一致的数值(1 最像,0 无关),RAG 用它挑出最相关的片段。 :::
一个反直觉的起点
动手前先看两个解剖对象的反直觉选择:Codex 和 dsh 都不内置 RAG。它们处理私有知识的方式是给模型 grep/read/glob 工具,让模型自己去翻文件。为什么?
- coding agent 的”知识库”(代码库)天然结构化:文件名、符号、目录就是索引,grep 就是语义检索;
- RAG 的检索质量高度依赖语料形态(分块、embedding 模型),对代码这种强结构文本反而不如精确匹配。
所以正确的认知是:RAG 是”接知识”的众多手段之一,与工具化(第 8 篇)互补——无结构、海量、无文件形态的知识(聊天记录、PDF 手册、网页存档)才轮到 RAG 上场。
100 行最小实现
向量化用本地的 sentence-transformers(pip install sentence-transformers)——可复现、不要 API key;生产上换成任何 embedding API 只需替换 embed() 一个函数。
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2") # 多语言小模型
def embed(texts: list[str]) -> np.ndarray:
"""批量把文本变成向量(已归一化,点积即余弦相似度)。"""
v = model.encode(texts, normalize_embeddings=True)
return np.array(v)
def chunk(text: str, size=500, overlap=50) -> list[str]:
"""按长度滑窗分块。第一版越简单越好。"""
chunks, start = [], 0
while start < len(text):
chunks.append(text[start:start + size])
start += size - overlap
return chunks or [""]
class MiniRAG:
def __init__(self, docs: dict[str, str]): # {路径: 全文}
self.chunks = []
for path, text in docs.items():
for i, c in enumerate(chunk(text)):
self.chunks.append((path, i, c))
self.vecs = embed([c for _, _, c in self.chunks])
def search(self, query: str, k=4) -> list[str]:
q = embed([query])[0]
scores = self.vecs @ q # 余弦相似度(已归一化)
top = np.argsort(scores)[::-1][:k]
return [f"[{self.chunks[i][0]}#{self.chunks[i][1]}] {self.chunks[i][2]}"
for i in top]
rag = MiniRAG({"handbook.md": open("handbook.md").read(),
"notes.md": open("notes.md").read()})
接入 agent 只需一个工具:
def search_knowledge(query: str) -> str:
"""搜索私有知识库(团队手册与笔记)。用于回答产品规范、内部流程等问题。"""
return "\n---\n".join(rag.search(query, k=4))
就这么多了:分块 → 向量化 → 余弦检索 → 作为工具结果回流。第一版到此为止,不要加更多。
检查点:直接跑 print(rag.search("报销需要哪些材料")),应返回 4 个片段、每个带 [文件#块号] 前缀,且至少一个确实在讲报销。如果全不相关,先检查文档编码,再换更贴切的查询词——问题出在检索层还是生成分,必须能分开判断。
常见踩坑:
- 忘了归一化向量——归一化后点积才是余弦相似度,否则分数没有可比性;
- overlap 写成了 size——滑窗永远走不完,分块死循环;overlap 取 size 的 10% 左右;
- 检索与生成的问题混在一起调——先单独验证
search()命中,再接 agent; - 块号没有进返回文本——模型无法引用出处,你也无法定位坏块。
embedding 模型怎么选
| 选择 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 本地 sentence-transformers(本篇) | 免费可复现、数据不出门 | 多语言/领域效果一般 | 学习、原型 |
| 商用 embedding API(OpenAI/Voyage/智源) | 效果好、多语言强 | 成本、数据外发、限速 | 生产 |
| 领域微调模型 | 专业术语效果最佳 | 需要 training pair | 大规模垂直场景 |
铁律:建索引与查询必须用同一个模型——不同模型的向量空间不互通,混用等于检索随机数。
与循环的接法
关键点:检索是工具,不是管线。模型自己决定要不要搜、搜什么词、搜几轮(第一轮结果不好会自己改查询词重搜)——这正是第 8 篇”工具即上下文阀门”的应用:检索结果只在需要时占用窗口。
注入格式有讲究:每个片段带 [文件#块号] 出处前缀,让模型(和用户)能引用来源;片段之间用分隔线;并告诉模型”片段不足时明确说不知道”(拒答指令是第 10 篇的深化主题)。
本篇产出
- 用你自己的一批 Markdown 文档跑通 MiniRAG +
search_knowledge工具; - 做 10 个问答测试,记录命中/未命中——未命中的 case 就是第 10 篇的优化素材;
- 观察模型行为:它会不会自己调整查询词重搜?会在回答里引用出处吗(片段里的
[文件#块]前缀就是为了这个)?