RAG 不是外挂知识库:而是在给 Agent 补长期知识通路
当 Agent 开始接入真实业务后,Tool Calling 很快就会暴露它的边界:系统可以去做事,但它不一定知道该基于哪些私有信息做判断。比如用户问“我上次说过最喜欢谁的歌”“OpenClaw 是什么项目”“我们内部那个产品文档在哪里说过这个约束”,这些都不属于模型参数里稳定存在的知识。
这时候很多人会说“给它上个知识库”。这个说法不算错,但太容易把 RAG 讲成一个神秘外挂。更准确的理解是:RAG 不是把知识永久灌进模型,而是在回答前先做一次检索,把相关材料临时补进当前上下文。
这个差别非常关键。因为一旦你把 RAG 误会成“给模型装知识库插件”,后面在索引、检索、阈值、无答案策略这些真正决定效果的地方,通常都会做错。
为什么到了这一步,问题会从“会不会做”切到“知道什么”
前面几篇文章已经把另外几块拼出来了:
- Stateless 说明模型默认不保留状态
- Chat loop 说明连续对话来自 history 重放
- Tool Calling 说明模型可以请求外部动作
但真实系统里还有一类需求,既不是继续聊天,也不是执行动作,而是:
- 问企业私有文档
- 问用户长期偏好
- 问项目资料、历史决策、内部规则
- 问模型参数里本来就不应该知道的事实
这些问题的核心,不是“怎么调用工具”,而是“怎么在正确的时候,把正确知识送进当前上下文”。
为什么这种实现方式重要
因为它让你第一次清楚地区分两件经常被混在一起的事:
- 模型会不会做推理
- 系统有没有先把需要的材料找出来
RAG 的价值不在于让模型更聪明,而在于让系统在回答前先做相关性过滤。后面无论你接 Pinecone、Chroma、pgvector 还是自己做搜索层,骨架其实都差不多:
- 离线建索引
- 在线检索
- 把命中的片段补进 prompt
- 再让模型生成回答
完整代码
// 04-rag-basic.js
// 目标:实现 RAG (Retrieval-Augmented Generation) - 也就是“长期记忆”
// 原理:
// 1. 知识库 (Knowledge Base): 一堆文本。
// 2. 向量化 (Embedding): 把文本变成数字向量 (Vectors),语义相似的文本向量距离近。
// 3. 检索 (Retrieval): 用户提问 -> 变成向量 -> 在数据库中找最相似的片段。
// 4. 生成 (Generation): 把找到的片段作为 Context 喂给 LLM。
import { GoogleGenerativeAI } from '@google/generative-ai';
import dotenv from 'dotenv';
dotenv.config();
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
// 1. 模拟一个“知识库” (实际上通常存在 Vector DB 里,如 Pinecone/Chroma)
// 这些是 LLM 原生不知道的私有数据
const knowledgeBase = [
"tc9011 (Theon) 的生日是 1月。",
"tc9011目前在 OPENAI 担任 CEO。",
"tc9011最喜欢的歌手是周杰伦。",
"OpenClaw 是一个基于 Node.js 的 AI Agent 框架。",
"tc9011的 MBTI 人格是 INTJ (建筑师)。"
];
// 存储向量化的知识库
let vectorStore = [];
// 获取 Embedding 模型 (使用你的 key 支持的唯一 embedding 模型)
const embeddingModel = genAI.getGenerativeModel({ model: "gemini-embedding-001" });
const chatModel = genAI.getGenerativeModel({ model: "gemini-flash-latest" });
// 计算余弦相似度 (Cosine Similarity)
// 这是一个数学公式,用来判断两个向量有多像 (1 = 完全一样, 0 = 完全无关)
function cosineSimilarity(vecA, vecB) {
let dotProduct = 0;
let magnitudeA = 0;
let magnitudeB = 0;
for (let i = 0; i < vecA.length; i++) {
dotProduct += vecA[i] * vecB[i];
magnitudeA += vecA[i] * vecA[i];
magnitudeB += vecB[i] * vecB[i];
}
return dotProduct / (Math.sqrt(magnitudeA) * Math.sqrt(magnitudeB));
}
async function initKnowledgeBase() {
console.log("🔄 正在构建向量索引 (Indexing)...");
for (const text of knowledgeBase) {
const result = await embeddingModel.embedContent(text);
const vector = result.embedding.values;
vectorStore.push({ text, vector });
console.log(` - Embedded: "${text}"`);
}
console.log("✅ 索引构建完成。\n");
}
async function retrieve(query) {
// 1. 把用户的问题也变成向量
const result = await embeddingModel.embedContent(query);
const queryVector = result.embedding.values;
// 2. 在数据库中寻找最相似的 Top 1
// (在真实场景中,Vector DB 会用 ANN 算法加速这一步)
const sorted = vectorStore.map(item => ({
text: item.text,
score: cosineSimilarity(queryVector, item.vector)
})).sort((a, b) => b.score - a.score); // 分数高的排前面
console.log(`🔍 检索结果 (Query: "${query}"):`);
console.log(` - Top Match: "${sorted[0].text}" (Score: ${sorted[0].score.toFixed(4)})`);
// 我们只取最相关的一条作为 Context
return sorted[0].text;
}
async function ask(question) {
console.log(`\nUser Question: ${question}`);
// 1. Retrieve: 找相关资料
const context = await retrieve(question);
// 2. Augment: 把资料塞进 Prompt
const prompt = `
你是一个助手。请根据以下上下文回答用户的问题。
如果上下文里没有答案,就说不知道,不要瞎编。
[Context]
${context}
[Question]
${question}
`;
// 3. Generate: 让 LLM 回答
const result = await chatModel.generateContent(prompt);
console.log(`AI Answer: ${result.response.text()}`);
}
async function main() {
await initKnowledgeBase();
// 测试 1: 问简历相关
await ask("tc9011在哪里工作?");
// 测试 2: 问个人喜好
await ask("他喜欢听谁的歌?");
// 测试 3: 问无关问题 (测试 RAG 的边界)
// 虽然会检索到最接近的(可能并不相关),但 LLM 应该判断出无法回答
await ask("明天股票会涨吗?");
}
main();先看这段代码真正跑通了什么
它不是在“往模型脑子里装资料”,而是在跑一条最小 RAG 链路:
- 准备一批模型原本不知道的文本
- 预先把这些文本做 embedding,形成可检索索引
- 把用户问题也向量化
- 按相似度找回最相关片段
- 把片段作为本轮上下文补进去
- 再让模型基于上下文生成回答
以后你换向量库、换检索算法、换排序器,这个骨架都不会变。
先看一个真实运行结果
当前版本运行 node 04-rag-basic.js 时,你会先看到索引构建过程,然后看到一次典型的检索 + 生成输出。效果大致像这样:
🔄 正在构建向量索引 (Indexing)...
- Embedded: "tc9011 (Theon) 的生日是 1月。"
- Embedded: "OpenClaw 是一个基于 Node.js 的 AI Agent 框架。"
✅ 索引构建完成。
User Question: tc9011在哪里工作?
🔍 检索结果 (Query: "tc9011在哪里工作?"):
- Top Match: "tc9011目前在 OPENAI 担任 CEO。" (...)
AI Answer: ...这段输出很关键,因为它把 RAG 的三件事直接暴露了出来:
- 先把知识库做索引
- 再根据问题做相似度检索
- 最后才让模型基于命中的上下文回答
也就是说,模型不是“自己想起来了”,而是系统先把相关材料找出来,再临时补进本轮上下文。
按实现流和职责边界拆代码
1. knowledgeBase:先把“不该靠模型瞎猜”的东西显式列出来
const knowledgeBase = [
"tc9011 (Theon) 的生日是 1月。",
"tc9011目前在 OPENAI 担任 CEO。",
"tc9011最喜欢的歌手是周杰伦。",
"OpenClaw 是一个基于 Node.js 的 AI Agent 框架。",
"tc9011的 MBTI 人格是 INTJ (建筑师)。"
];这段示例很有代表性,因为它先帮你划清了一个边界:
这些不是当前会话里的短期消息,也不是模型参数里可靠存在的公共知识,而是外部知识源。在真实项目里,它们可能来自:
- 企业 Wiki
- 用户档案
- 产品文档
- 项目历史纪要
- CRM、工单系统、代码仓库说明
RAG 的第一步,不是上向量库,而是先承认:这些知识本来就不应该靠模型自己“知道”。
2. embeddingModel 和 chatModel:检索与生成是两种不同职责
const embeddingModel = genAI.getGenerativeModel({ model: "gemini-embedding-001" });
const chatModel = genAI.getGenerativeModel({ model: "gemini-flash-latest" });这里用了两个模型:
embeddingModel负责把文本映射到向量空间chatModel负责基于上下文组织自然语言回答
这一步很关键,因为初学者最容易把“检索”和“生成”混成一件事。embedding 不负责回答问题,它负责让“语义相近的东西更容易被找到”;生成模型不负责建索引,它负责基于取回的材料做推理和表达。
3. cosineSimilarity():把“语义相似”落到可计算的比较上
function cosineSimilarity(vecA, vecB) {
let dotProduct = 0;
let magnitudeA = 0;
let magnitudeB = 0;
for (let i = 0; i < vecA.length; i++) {
dotProduct += vecA[i] * vecB[i];
magnitudeA += vecA[i] * vecA[i];
magnitudeB += vecB[i] * vecB[i];
}
return dotProduct / (Math.sqrt(magnitudeA) * Math.sqrt(magnitudeB));
}真实项目里你大概率不会手写这段,但理解它在做什么很重要:
- 把问题向量和知识向量做相似度比较
- 分数越高,说明语义越接近
- 系统据此决定哪些文本值得带回模型
RAG 能工作,不是因为模型突然“记住了知识”,而是因为系统在回答前先做了一次相关性过滤。
4. initKnowledgeBase():这是一个最小离线建索引流程
async function initKnowledgeBase() {
console.log("🔄 正在构建向量索引 (Indexing)...");
for (const text of knowledgeBase) {
const result = await embeddingModel.embedContent(text);
const vector = result.embedding.values;
vectorStore.push({ text, vector });
console.log(` - Embedded: "${text}"`);
}
console.log("✅ 索引构建完成。\n");
}这段很像真实生产系统里的 indexing job,只是这里为了易懂,用了内存数组来保存索引结果。它做了三件事:
- 遍历知识源
- 把每条文本转成向量
- 保存“原文 + 向量”的映射
更复杂的系统只是在这条链路上继续加:chunking、metadata、批量入库、增量更新、失败重试、索引版本管理。
5. retrieve(query):真正决定回答上限的,往往是这一层
async function retrieve(query) {
const result = await embeddingModel.embedContent(query);
const queryVector = result.embedding.values;
const sorted = vectorStore.map(item => ({
text: item.text,
score: cosineSimilarity(queryVector, item.vector)
})).sort((a, b) => b.score - a.score);
console.log(`🔍 检索结果 (Query: "${query}"):`);
console.log(` - Top Match: "${sorted[0].text}" (Score: ${sorted[0].score.toFixed(4)})`);
return sorted[0].text;
}这里最值得盯住的一点是:RAG 的关键不是把所有资料都塞给模型,而是先筛出那一小部分最相关的内容。
如果这一步做不好,后面模型再强也没用。因为生成质量的上限,很多时候就是被检索质量卡死的。
6. ask(question):RAG 本质上是在补本轮上下文
const prompt = `
你是一个助手。请根据以下上下文回答用户的问题。
如果上下文里没有答案,就说不知道,不要瞎编。
[Context]
${context}
[Question]
${question}
`;
const result = await chatModel.generateContent(prompt);这一段的重点不是模板字符串,而是那句约束:
如果上下文里没有答案,就说不知道,不要瞎编。
这其实是在明确回答边界:模型应该依赖这次检索回来的材料,而不是放飞式补全。也正因此,RAG 更准确地叫 Retrieval-Augmented Generation——检索增强生成,而不是“知识永久注入”。
RAG 和 Tool Calling 解决的不是同一类问题
这两个概念经常一起出现,但它们的职责不同:
- Tool Calling:当系统需要执行动作时,去调外部能力
- RAG:当系统缺知识时,去找相关材料
一个偏“做什么”,一个偏“知道什么”。
真实 Agent 经常需要两条链路协同工作:
- 先检索文档,再调用工具完成动作
- 先调用工具拿到状态,再结合知识库解释结果
- 或者同时把历史消息、工具输出、检索片段一起组装进当前上下文
这份示例故意保留了几个很真实的工程边界
1. 只取 Top 1,方便理解,但不够应付复杂问题
很多真实问题需要多段上下文拼接,甚至还需要 rerank。
2. 没有相似度阈值,会导致“总能找回一条最像的”
即使问题完全无关,也会被迫拿回一条看似最接近的内容。生产系统通常要补 score threshold 和 no-answer 策略。
3. 知识真假不由 RAG 保证
示例里“tc9011目前在 OPENAI 担任 CEO。”明显是 mock 数据。它在提醒你:RAG 可以提升可得性,但不自动保证真实性。
4. 线性扫描只适合教学和超小规模数据
数据一大,就必须引入向量库或 ANN 索引,不然延迟和成本都会很快失控。
收尾
RAG 最容易被误读成“给模型外挂一个知识库”,但真正的工程意义在于:它为 Agent 建立了一条按需接入外部知识的通路。模型没有被重新训练,也没有永久获得这些知识;它只是被系统在本轮回答前,临时补齐了必要上下文。
走到这里,Agent 才开始具备一个更像样的雏形:既能做事,也能基于私有知识回答问题。接下来真正会卡人的,不再是原理本身,而是当这些能力越来越多时,系统控制流该怎么写,才不会迅速烂成一团。这也是工程化框架该上场的时候。

