RAG 最小实现

专栏:Agent 工程 · 第 9 / 18 篇
AgentRAGEmbedding

阶段四开始处理”知识接入”:让 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-transformerspip 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 个片段、每个带 [文件#块号] 前缀,且至少一个确实在讲报销。如果全不相关,先检查文档编码,再换更贴切的查询词——问题出在检索层还是生成分,必须能分开判断。

常见踩坑

  1. 忘了归一化向量——归一化后点积才是余弦相似度,否则分数没有可比性;
  2. overlap 写成了 size——滑窗永远走不完,分块死循环;overlap 取 size 的 10% 左右;
  3. 检索与生成的问题混在一起调——先单独验证 search() 命中,再接 agent;
  4. 块号没有进返回文本——模型无法引用出处,你也无法定位坏块。

embedding 模型怎么选

选择优点缺点适用
本地 sentence-transformers(本篇)免费可复现、数据不出门多语言/领域效果一般学习、原型
商用 embedding API(OpenAI/Voyage/智源)效果好、多语言强成本、数据外发、限速生产
领域微调模型专业术语效果最佳需要 training pair大规模垂直场景

铁律:建索引与查询必须用同一个模型——不同模型的向量空间不互通,混用等于检索随机数。

与循环的接法

图表(rag-minimal.md)

关键点:检索是工具,不是管线。模型自己决定要不要搜、搜什么词、搜几轮(第一轮结果不好会自己改查询词重搜)——这正是第 8 篇”工具即上下文阀门”的应用:检索结果只在需要时占用窗口。

注入格式有讲究:每个片段带 [文件#块号] 出处前缀,让模型(和用户)能引用来源;片段之间用分隔线;并告诉模型”片段不足时明确说不知道”(拒答指令是第 10 篇的深化主题)。

本篇产出

  1. 用你自己的一批 Markdown 文档跑通 MiniRAG + search_knowledge 工具;
  2. 做 10 个问答测试,记录命中/未命中——未命中的 case 就是第 10 篇的优化素材;
  3. 观察模型行为:它会不会自己调整查询词重搜?会在回答里引用出处吗(片段里的 [文件#块] 前缀就是为了这个)?

← 返回文章列表