9. RAG 知识库
知识盲区
前八篇文章构建的 Agent 具备了一个完整系统的多数要素:循环执行、持久记忆、任务规划、能力扩展、团队协作、上下文压缩、安全边界。但从知识获取的角度看,它存在一个结构性短板。
2. 记忆,让 Agent 记得发生了什么 - Memory 记录的是 Agent 的运行时轨迹——它做过什么、调用过哪些工具、返回了什么结果。这是一种纵向记忆:沿时间轴累积,单一线程。
项目文档、API 参考手册、架构设计规范、历史 issue 讨论——这些是横向知识。它们不在 messages 数组里,也不在 system prompt 中。Agent 需要它们来理解项目上下文,但它们的存在方式决定了它们无法被直接注入:一份完整的 API 手册可能有数万字,远超任何模型的上下文窗口。
核心矛盾在于:知识的总量远大于单次推理可承载的上限。让 Agent 每轮都去翻原始文件不是解法——反复的全文读取会线性推高 token 消耗,且检索效率随文件数量增长而急剧衰减。
朴素方案及其失效边界
最直接的思路是全文检索:用户提问 → 关键词匹配文档 → 将匹配结果拼入 system prompt。这一路径在知识量较小时可堪一用,但在三个维度上迅速失效。
其一,关键词匹配存在语义断层。用户提问”认证模块怎么实现的”,关键词匹配只会命中含”认证”的段落。但真正相关的可能是标题为”登录流程”、正文含”JWT token 验证”的章节——它们讲的是同一件事,却没有任何重叠的字符。
其二,搜索结果缺乏相关性排序。关键词命中十条结果,前三条是真正相关的段落,后七条是旁及该词的无关内容。全量拼入 prompt,噪声与信号混杂,LLM 的注意力被稀释。
其三,单条结果的长度不可控。一份 API 文档的某个章节可能本身就长达数千字,一次命中就足以将 prompt 撑至阈值,挤占其他信息的空间。
这三个问题指向同一个结论:朴素检索解决的是”找到东西”的问题,RAG 要解决的是”找到对的东西,并控制找到多少”。
管线拆解
RAG 将知识注入拆为四个独立阶段。每个阶段的输入和输出边界清晰,各自可独立优化。
第一阶段:切片。 将源文档按固定 token 数切分为块,块与块之间保留部分重叠。切分将连续文本离散化为检索单元,重叠防止语义边界恰好落在切分点上。
第二阶段:向量化。 每个文本块通过 embedding 模型映射为一个固定维度的浮点数向量。向量在高维空间中的位置编码了文本的语义信息——含义相近的文本块在向量空间中彼此靠近。
第三阶段:检索。 用户提问同样经 embedding 模型编码为向量,在向量空间中查找与之距离最近的 K 个文本块。距离度量通常采用余弦相似度。
第四阶段:注入。 检索到的 K 个文本块拼接为上下文前缀,附上来源标注,与用户提问一同送入 LLM。LLM 将其视为参考资料进行推理和综合。
代码实现
以下实现采用 sqlite-vec 作为向量存储后端。选择 sqlite-vec 的理由有三:它是一个单文件的 SQLite 扩展,无需独立服务进程;向量存储与元数据存储在同一张 SQLite 表中,查询与过滤无需跨系统调用;对于 Agent 原型阶段的知识量(数千到数万条文本块),其检索性能完全够用。
索引文档
import Database from "better-sqlite3";
import * as vec from "sqlite-vec";
import { OpenAI } from "openai";
const db = new Database("knowledge.db");
vec.load(db); // 加载 sqlite-vec 扩展
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
});
// 创建向量表,维度匹配 embedding 模型
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS chunks USING vec0(
id INTEGER PRIMARY KEY,
content TEXT,
source TEXT,
embedding FLOAT[1536]
);
`);
async function ingestDocument(path: string) {
const content = readFileSync(path, "utf-8");
const chunks = splitIntoChunks(content, { chunkSize: 800, overlap: 120 });
const insertStmt = db.prepare(
"INSERT INTO chunks(content, source, embedding) VALUES (?, ?, ?)"
);
for (const chunk of chunks) {
const resp = await openai.embeddings.create({
model: "text-embedding-3-small",
input: chunk,
});
const embedding = resp.data[0].embedding; // number[]
insertStmt.run(chunk, path, JSON.stringify(embedding));
}
}
function splitIntoChunks(
text: string,
opts: { chunkSize: number; overlap: number }
): string[] {
const chunks: string[] = [];
let start = 0;
while (start < text.length) {
const end = Math.min(start + opts.chunkSize, text.length);
chunks.push(text.slice(start, end));
start += opts.chunkSize - opts.overlap;
}
return chunks;
}text-embedding-3-small 输出的向量维度为 1536。切片大小设为 800 字符,相邻切片重叠 120 字符。重叠量约为切片大小的 15%,这一比例来自工程经验——过小则边界信息丢失概率上升,过大则索引冗余度增加。
检索与注入
async function retrieve(query: string, topK = 5) {
// 将查询向量化
const resp = await openai.embeddings.create({
model: "text-embedding-3-small",
input: query,
});
const queryEmbedding = JSON.stringify(resp.data[0].embedding);
// sqlite-vec 使用 vec_distance 函数进行相似度搜索
const results = db
.prepare(
`SELECT content, source, vec_distance(embedding, ?) AS distance
FROM chunks
ORDER BY distance ASC
LIMIT ?`
)
.all(queryEmbedding, topK);
return results;
}
function augmentPrompt(userMessage: string): ChatMessage[] {
const relevantChunks = retrieve(userMessage);
const context = relevantChunks
.map((c, i) => `[来源 ${i + 1}: ${c.source}]\n${c.content}`)
.join("\n\n");
return [
{
role: "system",
content: `你是一个代码助手。以下是可能与当前问题相关的项目文档片段:
${context}
请基于以上参考资料回答问题。引用时标注来源编号。如果参考资料不足以回答问题,请明确说明。`,
},
{ role: "user", content: userMessage },
];
}检索结果通过 ORDER BY distance ASC 按相似度排序。每条结果标注来源文件路径,LLM 引用时可追溯,用户可自行核实。system prompt 中的”参考资料不足以回答时请说明”是一条关键的防御式提示——它让 LLM 在检索失败时暴露不确定性,而非基于不完整的上下文强行推断。
经验参数与设计约束
RAG 管线的行为由一组参数控制。这些参数的选择直接影响检索质量,且彼此之间存在约束关系。
切片大小。 切片过小(< 400 字符),单个块包含的语义信息不足,检索时即使命中相似度也很低。切片过大(> 1200 字符),单个块承载了过多话题,检索精度随块内语义稀释而下降。800 字符是工程上的经验甜点区,适用于中英文技术文档。
重叠窗口。 重叠量通常取切片大小的 10% 到 20%。太小的重叠意味着更多语义信息恰好落在切片边界上,检索时无论命中哪一块都会丢失一部分上下文。太大的重叠增加了索引的存储开销,且相邻切片之间的相似度过高,检索结果中出现大量冗余。
top-K。 K 值决定了注入 prompt 的文本总量。K 太小,可能遗漏关键信息。K 太大,噪声稀释信号,且 prompt 长度膨胀推高推理成本。典型配置为 3 到 8,视切片大小和目标 prompt 预算而定。
来源标注。 这不是可选功能。检索结果必须携带文件路径,在可能的情况下还应标注行号或段落号。LLM 的推理不可完全信赖——用户需要一条独立的验证路径来确认它引用的内容是否确实存在于源文档中。
与 Memory 的分工
Memory 和 RAG 都向 prompt 注入信息,但注入的内容和时机不同。
Memory 是纵向的:它沿时间轴记录 Agent 自己的行为轨迹——上一轮做了什么、出了什么错、当前进展到哪一步。它回答的问题是”刚才发生了什么”。
RAG 是横向的:它索引项目中的静态知识——文档规范、API 定义、架构设计。它回答的问题是”这个项目是什么”。
两者在 prompt 中汇合,构成 Agent 的完整认知上下文:Memory 提供运行时状态,RAG 提供领域知识。Agent 不需要知道信息来自哪个系统——它只看到一段连续的上下文前缀。
从系统设计角度看,Memory 和 RAG 的分工还对应着不同的更新频率。Memory 随每一轮对话实时追加,属于高频写入。RAG 的索引在文档变更时重建,属于低频批量更新。两者的读写模式不同,不应塞入同一存储层。
方案辩证:Markdown 知识库与 RAG 的适用边界
在详细讨论了 RAG 的实现之后,有必要回到一个更基本的问题:你的项目真的需要它吗。
观察当前主流 Agent 项目的做法:Claude Code 依赖 CLAUDE.md 作为项目级指令注入,OpenClaw 通过 rules/ 和 skills/ 目录加载行为约束,Cursor 以 .cursorrules 文件配置代码风格。这些方案的本质都是 Markdown 知识库——人主动撰写、Agent 启动时全量注入。没有一个是默认上向量库的。
第 4 篇讨论的 Rules 和 Skills 正属于这个范畴。它们在知识量有限时表现得足够好,且拥有一项 RAG 不具备的优势:确定性。人改了什么,Agent 就看到什么。不存在检索遗漏、相似度阈值调参、向量索引过期这些失败模式。
RAG 的适用条件较为狭窄:知识量大到人无法精炼为短文本,且需要按需检索而非全量注入。典型场景是已有大量技术文档、API 手册或历史 issue 讨论,这些原始材料不适合也不值得花人力去浓缩。
一个可操作的判断标准:如果你的知识可以全部浓缩到一个不超过几千字的 CLAUDE.md 里,就不要引入向量数据库。RAG 是当浓缩不再可行时的退路,而非默认选项。
与生产方案的差距
上述实现约两百行代码,覆盖了 RAG 的基本管线。生产级系统在此基础上做了若干关键增强。
语义切片。 按固定字符数切片是最朴素的分割策略,不感知文档结构。生产系统通常按语义边界切分——代码按函数和类切,文档按章节和段落切,表格按行切。语义切片使每个块成为一个完整的逻辑单元,检索时命中的是”一个函数”而非”半个函数加下半个函数的开头”。
混合检索与重排序。 纯向量检索擅长语义匹配,但弱于精确关键词匹配。搜索一个确切的函数名或错误码时,关键词匹配的精度高于语义搜索。生产系统通常采用混合检索:BM25 做关键词召回,向量做语义召回,两路结果合并后经 reranker 模型精排。粗筛保证召回率,精排保证准确率。
增量索引。 当前实现是手动触发全量重建。生产系统通过文件监听实现增量更新——文档变更时,仅重新切片和向量化变更的部分,而非整个知识库。这对大型代码库尤其关键:一次 git pull 可能只改了三个文件,重建整个索引既浪费计算也引入不必要的窗口期。
多模态分割。 项目知识不仅仅是文本。代码文件、架构图、数据表结构各有不同的语义密度和最佳切片策略。生产系统通常为每种模态定义专用的分割器和检索策略。
核心思路没有发生变化:检索的质量上限决定了回答的质量上限。提升检索精度,比提升 LLM 的推理能力对最终效果的影响更大。
设计哲学
RAG 引入了一条新的责任边界:代码负责检索和排序,LLM 负责理解和综合。
这一边界与 SubAgent 那篇中的”代码提供能力,LLM 控制流程”同源,但作用域不同。在那条边界上,代码提供工具菜单,LLM 决定何时调用、以什么参数调用。在 RAG 的边界上,代码决定哪些信息值得让 LLM 看到,LLM 决定这些信息如何用于推理。
这一分工的隐含前提是:检索系统的判断力直接决定了 LLM 推理的信息基础。检索漏掉了关键段落,LLM 即使推理能力再强,也是在残缺信息上构建答案。检索混入了噪声段落,LLM 需要额外消耗推理资源来甄别其相关性。在这个意义上,RAG 管线的质量不是 LLM 能力的补充,而是其前置条件。
下一篇:10. Markdown 知识库