Skip to content

RAG 检索增强生成

要点

  • 前面几篇已经准备好了:读文档、切块、embedding、向量库查询。这一篇把它们接成一条能给 Agent 用的检索回答链。
  • 最小可用版本只有三步:检索、组上下文、交给 Agent。
  • 向量库检索负责找资料,Agent 负责基于资料组织回答,两者要刻意分开。
  • 检索结果最好先格式化,把来源信息带进去,方便后续做引用展示。
  • 多轮对话里,追问常常要先做查询重写,补成完整问题后再检索。
  • RAG 不是先堆功能,而是先把链路拉直。

1. 背景:前面几篇,终于要接成一条线了

到这里,前面几篇已经把材料都准备好了:

  • 文档已经读进来了。
  • 文档已经切成块了。
  • 块已经做了 embedding。
  • 向量库也能查了。

这一篇做的事情很简单:把这些步骤真正接起来,变成一条能给 Agent 用的检索回答链。也就是说,用户发来一个问题以后,不再是直接让 Agent 硬答,而是先走一圈:

  1. 用问题去检索资料。
  2. 把检索结果整理成上下文。
  3. 再把这段上下文交给 Agent。

这就是这里要实现的 RAG。

2. 最小可用版本,其实就三步

最小可用版本只有三步:

  1. 检索。
  2. 组上下文。
  3. 交给 Agent。
typescript
import { createAgent } from "langchain";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
import { OpenAIEmbeddings } from "@langchain/openai";
import { Document } from "@langchain/core/documents";

const embeddings = new OpenAIEmbeddings({
  // 先用最常见的小模型把流程跑通。
  model: "text-embedding-3-small",
});

// 这里先准备一份最小知识库。
// 正式项目里,这些 Document 往往来自前面几篇的 Loader + Splitter。
const vectorStore = await MemoryVectorStore.fromDocuments(
  [
    new Document({
      pageContent: "错误码 1001:请求参数缺失。需要检查必填字段是否完整。",
      metadata: { source: "error-code.pdf" },
    }),
    new Document({
      pageContent:
        "错误码 1002:签名验证失败。需要确认 appKey、timestamp 和 secret 是否正确。",
      metadata: { source: "error-code.pdf" },
    }),
  ],
  embeddings,
);

const agent = createAgent({
  model: "openai:gpt-4.1-mini",
  tools: [],
});

async function answer(question: string) {
  // 1. 先从向量库里找最相关的资料。
  const docs = await vectorStore.similaritySearch(question, 2);

  // 2. 再把资料整理成一段上下文,交给后面的 Agent 使用。
  const context = docs.map((doc) => doc.pageContent).join("\n\n");

  const result = await agent.invoke({
    messages: [
      {
        // system 负责告诉 Agent:下面这段内容是外部资料,不要脱离资料乱答。
        role: "system",
        content: `你是技术文档助手。

请优先根据下面的参考资料回答用户问题。

如果资料里没有明确答案,就直接说不知道,不要编造。

参考资料:

${context}`,
      },
      {
        // user 仍然保留用户原始问题。
        role: "user",
        content: question,
      },
    ],
  });

  // 3. 最后一条消息就是这一轮回答。
  return result.messages.at(-1)?.text ?? "";
}

const answerText = await answer("签名验证失败是错误码多少?");

// 这里拿到的已经是「检索 + 回答」合在一起后的最终结果。
console.log(answerText);

这就是一条完整的 RAG 链了。虽然很短,但已经把「先查,再答」这件事跑通了。

3. 检索这一步,和 Agent 不是一回事

这一点要刻意分开。在这条链里:

  • 向量库检索 负责找资料。
  • Agent 负责基于资料组织回答。

很多人第一次写 RAG 时,会把这两件事糊成一团,最后变成:Agent 既要理解问题,又要自己决定查什么,还要自己整理资料,最后还要回答。一上来这么堆,很容易乱。

所以这里先用最直接的分工:

  1. 检索层先返回 Document[]
  2. 再把这些文档拼成 context
  3. 最后统一交给 Agent。

这样出了问题更容易查:

  • 找不到资料,是检索问题。
  • 资料找到了但回答不对,是 Prompt 或 Agent 问题。

4. 把检索结果格式化一下

如果直接把 Document[] 丢给 Agent,不太方便。通常会先做一层很薄的格式化。

typescript
import { Document } from "@langchain/core/documents";

function formatDocs(docs: Document[]) {
  return docs
    .map((doc, index) => {
      // 来源信息提前拼进去,后面如果要做引用展示会方便很多。
      const source = doc.metadata.source ?? "unknown";
      return `资料 ${index + 1}(来源:${source})\n${doc.pageContent}`;
    })
    .join("\n\n");
}

这一层看起来很小,但很好用。它至少解决了两件事:

  • 让上下文更好读。
  • 顺手把来源信息带进去。

后面如果你想在回答里标注「这段内容来自哪里」,这一层就已经把材料准备好了。

5. 接回一条完整的 Agent 检索链

下面这段代码把检索和回答收成了一个统一入口,写起来会更接近项目里的样子。

typescript
import { createAgent } from "langchain";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
import { OpenAIEmbeddings } from "@langchain/openai";
import { Document } from "@langchain/core/documents";

const embeddings = new OpenAIEmbeddings({
  model: "text-embedding-3-small",
});

// 知识库里的内容先放进向量库。
// 这一层只负责「存」和「查」,还没有开始回答。
const vectorStore = await MemoryVectorStore.fromDocuments(
  [
    new Document({
      pageContent: "错误码 1001:请求参数缺失。需要检查必填字段是否完整。",
      metadata: { source: "error-code.pdf", topic: "validation" },
    }),
    new Document({
      pageContent:
        "错误码 1002:签名验证失败。需要确认 appKey、timestamp 和 secret 是否正确。",
      metadata: { source: "error-code.pdf", topic: "auth" },
    }),
    new Document({
      pageContent: "默认每秒最多 100 次请求,超限返回 429。",
      metadata: { source: "error-code.pdf", topic: "rate-limit" },
    }),
  ],
  embeddings,
);

const agent = createAgent({
  model: "openai:gpt-4.1-mini",
  tools: [],
});

function formatDocs(docs: Document[]) {
  return docs
    .map((doc, index) => {
      // 这里把检索结果转成更适合拼进 system 的格式。
      return `资料 ${index + 1}:${doc.pageContent}`;
    })
    .join("\n\n");
}

async function runRAG(question: string) {
  // 1. 先检索最相关的资料块。
  const docs = await vectorStore.similaritySearch(question, 3);

  // 2. 再把检索结果转成 Agent 更容易消费的上下文。
  const context = formatDocs(docs);

  const result = await agent.invoke({
    messages: [
      {
        // 这层 system 的作用,是把回答范围限制在检索结果里。
        role: "system",
        content: `你是技术文档助手。

回答问题时,只能优先依据下面的参考资料。

如果资料不足,就明确说资料里没有,不要自己补。

参考资料:

${context}`,
      },
      {
        // 这里还是用户的原始提问,不做改写。
        role: "user",
        content: question,
      },
    ],
  });

  return {
    answer: result.messages.at(-1)?.text ?? "",
    docs,
  };
}

const result = await runRAG("返回 1002 是什么意思?");

// answer 是最终回答,docs 是这次检索真正命中的资料。
console.log(result.answer);

这段代码里,最有用的结构其实就一个:runRAG(question) 作为统一入口。里面虽然做了好几步,但对外只暴露一个函数。后面你想加日志、缓存、查询重写、来源引用,都会比较顺。

6. 多轮对话里,问题常常要先补全

到了技术助手场景,用户很少每次都把问题说完整。比如:

  • 第一轮:错误码 1001 是什么?
  • 第二轮:那 1002 呢?

第二句里根本没有「错误码」三个字。如果直接拿它去检索,向量库不一定能稳稳命中对的资料。这时候更顺手的做法是先做一步查询重写。也就是结合前面的对话,把这句补完整,再去检索。

typescript
import { ChatOpenAI } from "@langchain/openai";

const model = new ChatOpenAI({
  model: "deepseek-chat",
  apiKey: process.env.MODEL_API_KEY,
  configuration: {
    baseURL: process.env.MODEL_BASE_URL ?? "https://api.deepseek.com/v1",
  },
});

async function rewriteQuery(history: string[], question: string) {
  // 这里不让模型直接回答,只让它补全检索问题。
  const prompt = `请根据对话历史,把用户最后一句补成一个独立完整的检索问题。

对话历史:

${history.join("\n")}

用户最后一句:

${question}

只输出改写后的问题。`;

  const result = await model.invoke(prompt);

  // 只保留改写后的那一句查询文本。
  return result.text.trim();
}

然后把它放回 runRAG(...) 前面:

typescript
async function runRAGWithRewrite(history: string[], question: string) {
  // 1. 先把追问补完整。
  const rewrittenQuestion = await rewriteQuery(history, question);

  // 2. 再拿完整问题去检索。
  // 这里检索用的是重写后的问题,不是用户原话。
  const docs = await vectorStore.similaritySearch(rewrittenQuestion, 3);

  const context = formatDocs(docs);

  const result = await agent.invoke({
    messages: [
      {
        // system 继续吃检索结果,不直接吃 rewrittenQuestion。
        role: "system",
        content: `参考资料:\n${context}`,
      },
      {
        // Agent 看到的 user 仍然是用户原话,这样回答会更自然。
        role: "user",
        content: question,
      },
    ],
  });

  return result.messages.at(-1)?.text ?? "";
}

这里要注意,重写后的问题只用来检索。真正交给 Agent 的用户消息,还是用户原话。这样回答的语气会更自然一些。

7. 先把最小版本跑通,再往上加

RAG 很容易一开始就写得很复杂,比如:

  • 查询重写。
  • 重排。
  • 多路检索。
  • 引用标注。
  • 回答后校验。

这些都可以加,但不要一上来全上。对大多数项目来说,更稳的顺序是:

  1. 先做最小检索链。先做到「能找到资料,再基于资料回答」。
  2. 再看检索是否稳定。如果追问经常找偏,再补查询重写。
  3. 再看回答是否重复或发散。这时再去调 k、上下文格式、Prompt。

也就是说,RAG 不是先堆功能,而是先把链路拉直。

基于 MIT 协议开源