RAG 检索增强生成

要点

  • 到这里,前面几篇已经把材料都准备好了
  • 最小可用版本只有三步
  • 向量库检索 负责找资料
  • 如果直接把 Document[] 丢给 Agent,不太方便
  • 下面这段代码把检索和回答收成了一个统一入口,写起来会更接近项目里的样子

内容

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

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

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

这一篇做的事情很简单:

把这些步骤真正接起来,变成一条能给 Agent 用的检索回答链。

也就是说,用户发来一个问题以后,不再是直接让 Agent 硬答,而是先走一圈:

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

这就是我们这里要实现的 RAG。

Canvas actions81%Exit zen mode

Drawing canvas

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

最小可用版本只有三步:

  1. 检索
  2. 组上下文
  3. 交给 Agent
// rag-minimal.ts
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: '退款政策:购买后 30 天内可申请无条件退款。超过 30 天需要提供商品质量问题证明。',
 
      metadata: { source: 'policy.pdf' },
 
    }),
 
    new Document({
 
      pageContent: '配送说明:标准配送 3 到 5 个工作日,加急配送 1 到 2 个工作日。',
 
      metadata: { source: 'policy.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,不太方便。

通常会先做一层很薄的格式化。

// format-docs.ts
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 检索链

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

// rag-agent.ts
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: '退款政策:购买后 30 天内可申请无条件退款。超过 30 天需要提供商品质量问题证明。',
 
      metadata: { source: 'policy.pdf', topic: 'refund' },
 
    }),
 
    new Document({
 
      pageContent: '会员等级:消费满 1000 元升银卡,满 5000 元升金卡。',
 
      metadata: { source: 'policy.pdf', topic: 'membership' },
 
    }),
 
    new Document({
 
      pageContent: '客服工作时间:周一到周五 9 点到 18 点。',
 
      metadata: { source: 'policy.pdf', topic: 'support' },
 
    }),
 
  ],
 
  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: `你是公司的 AI 助手。
 
回答问题时,只能优先依据下面的参考资料。
 
如果资料不足,就明确说资料里没有,不要自己补。
 
参考资料:
 
${context}`,
 
      },
 
      {
 
        // 这里还是用户的原始提问,不做改写。
 
        role: 'user',
 
        content: question,
 
      },
 
    ],
 
  })
 
  return {
 
    answer: result.messages.at(-1)?.text ?? '',
 
    docs,
 
  }
 
}
 
const result = await runRAG('退款超过 30 天怎么办?')
 
// answer 是最终回答,docs 是这次检索真正命中的资料。
 
console.log(result.answer)

这段代码里,最有用的结构其实就一个:

  • runRAG(question) 作为统一入口

里面虽然做了好几步,但对外只暴露一个函数。

后面你想加日志、缓存、查询重写、来源引用,都会比较顺。

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

到了 AI 伴侣或者客服场景,用户很少每次都把问题说完整。

比如:

  • 第一轮:退款政策是什么
  • 第二轮:那超过 30 天呢

第二句里根本没有“退款”两个字。

如果直接拿它去检索,向量库不一定能稳稳命中对的资料。

这时候更顺手的做法是先做一步查询重写

也就是结合前面的对话,把这句补完整,再去检索。

// rewrite-query.ts
import { ChatOpenAI } from '@langchain/openai'
 
const model = new ChatOpenAI({
 
  model: 'deepseek-chat',
 
  apiKey: process.env.DEEPSEEK_API_KEY,
 
  configuration: {
 
    baseURL: process.env.DEEPSEEK_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(...) 前面:

// rag-with-rewrite.ts
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 不是先堆功能,而是先把链路拉直。