tc9011

RAG 不是外挂知识库:而是在给 Agent 补长期知识通路

16 min

当 Agent 开始接入真实业务后,Tool Calling 很快就会暴露它的边界:系统可以去做事,但它不一定知道该基于哪些私有信息做判断。比如用户问“我上次说过最喜欢谁的歌”“OpenClaw 是什么项目”“我们内部那个产品文档在哪里说过这个约束”,这些都不属于模型参数里稳定存在的知识。

这时候很多人会说“给它上个知识库”。这个说法不算错,但太容易把 RAG 讲成一个神秘外挂。更准确的理解是:RAG 不是把知识永久灌进模型,而是在回答前先做一次检索,把相关材料临时补进当前上下文。

这个差别非常关键。因为一旦你把 RAG 误会成“给模型装知识库插件”,后面在索引、检索、阈值、无答案策略这些真正决定效果的地方,通常都会做错。

为什么到了这一步,问题会从“会不会做”切到“知道什么”

前面几篇文章已经把另外几块拼出来了:

  • Stateless 说明模型默认不保留状态
  • Chat loop 说明连续对话来自 history 重放
  • Tool Calling 说明模型可以请求外部动作

但真实系统里还有一类需求,既不是继续聊天,也不是执行动作,而是:

  • 问企业私有文档
  • 问用户长期偏好
  • 问项目资料、历史决策、内部规则
  • 问模型参数里本来就不应该知道的事实

这些问题的核心,不是“怎么调用工具”,而是“怎么在正确的时候,把正确知识送进当前上下文”。

为什么这种实现方式重要

因为它让你第一次清楚地区分两件经常被混在一起的事:

  • 模型会不会做推理
  • 系统有没有先把需要的材料找出来

RAG 的价值不在于让模型更聪明,而在于让系统在回答前先做相关性过滤。后面无论你接 Pinecone、Chroma、pgvector 还是自己做搜索层,骨架其实都差不多:

  1. 离线建索引
  2. 在线检索
  3. 把命中的片段补进 prompt
  4. 再让模型生成回答

完整代码

// 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 链路:

  1. 准备一批模型原本不知道的文本
  2. 预先把这些文本做 embedding,形成可检索索引
  3. 把用户问题也向量化
  4. 按相似度找回最相关片段
  5. 把片段作为本轮上下文补进去
  6. 再让模型基于上下文生成回答

以后你换向量库、换检索算法、换排序器,这个骨架都不会变。

先看一个真实运行结果

当前版本运行 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. 先把知识库做索引
  2. 再根据问题做相似度检索
  3. 最后才让模型基于命中的上下文回答

也就是说,模型不是“自己想起来了”,而是系统先把相关材料找出来,再临时补进本轮上下文。

按实现流和职责边界拆代码

1. knowledgeBase:先把“不该靠模型瞎猜”的东西显式列出来

const knowledgeBase = [
  "tc9011 (Theon) 的生日是 1月。",
  "tc9011目前在 OPENAI 担任 CEO。",
  "tc9011最喜欢的歌手是周杰伦。",
  "OpenClaw 是一个基于 Node.js 的 AI Agent 框架。",
  "tc9011的 MBTI 人格是 INTJ (建筑师)。"
];

这段示例很有代表性,因为它先帮你划清了一个边界:

这些不是当前会话里的短期消息,也不是模型参数里可靠存在的公共知识,而是外部知识源。在真实项目里,它们可能来自:

  • 企业 Wiki
  • 用户档案
  • 产品文档
  • 项目历史纪要
  • CRM、工单系统、代码仓库说明

RAG 的第一步,不是上向量库,而是先承认:这些知识本来就不应该靠模型自己“知道”。

2. embeddingModelchatModel:检索与生成是两种不同职责

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,只是这里为了易懂,用了内存数组来保存索引结果。它做了三件事:

  1. 遍历知识源
  2. 把每条文本转成向量
  3. 保存“原文 + 向量”的映射

更复杂的系统只是在这条链路上继续加: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 才开始具备一个更像样的雏形:既能做事,也能基于私有知识回答问题。接下来真正会卡人的,不再是原理本身,而是当这些能力越来越多时,系统控制流该怎么写,才不会迅速烂成一团。这也是工程化框架该上场的时候。

  • 本文作者: tc9011
  • 本文链接: https://tc9011.com/posts/2026/04-rag-不是外挂知识库-而是给-agent-补上长期记忆/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!