Skip to main content

概述

LLMs(大型语言模型)带来的最强大的应用之一是复杂的问答(Q&A)聊天机器人。这些应用能够回答关于特定信息源的问题。这些应用使用一种称为检索增强生成(RAG)的技术,或称RAG 本教程将展示如何在一个非结构化文本数据源上构建一个简单的问答应用。我们将演示:
  1. 一个使用简单工具执行搜索的RAG 智能体。这是一个很好的通用实现。
  2. 一个两步RAG ,每个查询仅使用一次LLM调用。这是处理简单查询的一种快速有效的方法。

概念

我们将介绍以下概念:
  • 索引:从源数据中摄取数据并对其进行索引的管道。这通常在单独的进程中发生。
  • 检索与生成:实际的RAG过程,在运行时获取用户查询,从索引中检索相关数据,然后将这些数据传递给模型。
一旦我们索引了我们的数据,我们将使用一个智能体作为我们的编排框架来实施检索和生成步骤。
本教程的索引部分将主要遵循语义搜索教程如果您的数据已经可用于搜索(即您有一个执行搜索的函数),或者您对那个教程的内容感到满意,请随意跳转到检索和生成部分

预览

在本指南中,我们将构建一个应用,用于回答有关网站内容的疑问。我们将使用的特定网站是Lilian Weng的LLM驱动的自主智能体博客文章,该文章允许我们询问关于文章内容的疑问。 我们可以创建一个简单的索引管道和RAG链,用大约40行代码就能完成这个任务。下面是完整的代码片段:
import "cheerio";
import { createAgent, tool } from "langchain";
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import * as z from "zod";

// Load and chunk contents of blog
const pTagSelector = "p";
const cheerioLoader = new CheerioWebBaseLoader(
  "https://lilianweng.github.io/posts/2023-06-23-agent/",
  {
    selector: pTagSelector
  }
);

const docs = await cheerioLoader.load();

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000,
  chunkOverlap: 200
});
const allSplits = await splitter.splitDocuments(docs);

// Index chunks
await vectorStore.addDocuments(allSplits)

// Construct a tool for retrieving context
const retrieveSchema = z.object({ query: z.string() });

const retrieve = tool(
  async ({ query }) => {
    const retrievedDocs = await vectorStore.similaritySearch(query, 2);
    const serialized = retrievedDocs
      .map(
        (doc) => `Source: ${doc.metadata.source}\nContent: ${doc.pageContent}`
      )
      .join("\n");
    return [serialized, retrievedDocs];
  },
  {
    name: "retrieve",
    description: "Retrieve information related to a query.",
    schema: retrieveSchema,
    responseFormat: "content_and_artifact",
  }
);

const agent = createAgent({ model: "openai:gpt-5", tools: [retrieve] });
let inputMessage = `What is Task Decomposition?`;

let agentInputs = { messages: [{ role: "user", content: inputMessage }] };

for await (const step of await agent.stream(agentInputs, {
  streamMode: "values",
})) {
  const lastMessage = step.messages[step.messages.length - 1];
  prettyPrint(lastMessage);
  console.log("-----\n");
}
查看LangSmith跟踪记录

设置

安装

本教程需要以下 LangChain 依赖项:
npm i langchain @langchain/community @langchain/textsplitters
有关详细信息,请参阅我们的安装指南

LangSmith

许多您使用LangChain构建的应用程序将包含多个步骤和多次调用LLM。随着这些应用程序变得更加复杂,能够检查您的链或智能体内部究竟发生了什么变得至关重要。最佳方式是使用LangSmith 在您通过上述链接注册后,请确保设置环境变量以开始记录跟踪信息:
export LANGSMITH_TRACING="true"
export LANGSMITH_API_KEY="..."

组件

我们需要从LangChain的集成套件中选择三个组件。 选择聊天模型:
👉 Read the OpenAI chat model integration docs
npm install @langchain/openai
import { initChatModel } from "langchain";

process.env.OPENAI_API_KEY = "your-api-key";

const model = await initChatModel("openai:gpt-4.1");
选择嵌入模型:
npm i @langchain/openai
import { OpenAIEmbeddings } from "@langchain/openai";

const embeddings = new OpenAIEmbeddings({
  model: "text-embedding-3-large"
});
选择向量存储:
npm i @langchain/classic
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";

const vectorStore = new MemoryVectorStore(embeddings);

1. 索引

本节是语义搜索教程内容的简略版。如果您的数据已经索引并可用于搜索(即您有一个执行搜索的函数),或者您对文档加载器嵌入向量存储感到舒适,请自由跳转到下一节,关于检索和生成的内容。
索引通常按以下方式工作:
  1. 加载:首先,我们需要加载我们的数据。这通过文档加载器来完成。
  2. 分割文本分割器将大的Documents分割成更小的块。这对于索引数据和将其传递给模型都很有用,因为大块更难搜索,而且不会适合模型有限的上下文窗口。
  3. 存储:我们需要一个地方来存储和索引我们的分割,以便以后可以搜索它们。这通常使用向量存储嵌入模型来完成。
index_diagram

加载文档

我们需要首先加载博客文章内容。我们可以使用DocumentLoaders来完成这项任务,这些对象从源加载数据并返回一个Document对象的列表。
import "cheerio";
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";

const pTagSelector = "p";
const cheerioLoader = new CheerioWebBaseLoader(
  "https://lilianweng.github.io/posts/2023-06-23-agent/",
  {
    selector: pTagSelector,
  }
);

const docs = await cheerioLoader.load();

console.assert(docs.length === 1);
console.log(`Total characters: ${docs[0].pageContent.length}`);
Total characters: 22360
console.log(docs[0].pageContent.slice(0, 500));
Building agents with LLM (large language model) as its core controller is...
深入探索 DocumentLoader:从源加载数据为Documents列表的对象
  • 集成: 可选择超过160种集成。
  • 界面: 基础界面的API参考。

分割文档

我们的加载文档超过42k个字符,这对于许多模型的上下文窗口来说太长了,无法全部适应。即使是那些能够将整个帖子放入其上下文窗口的模型,在处理非常长的输入时也可能难以找到信息。 为了处理这个问题,我们将Document分割成块以进行嵌入和向量存储。这应该有助于我们在运行时仅检索博客文章中最相关的部分。 语义搜索教程中所述,我们使用一个RecursiveCharacterTextSplitter,它会递归地使用常见的分隔符(如换行符)将文档分割,直到每个块达到适当的大小。这是通用文本用例中推荐的文本分割器。
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";

const splitter = new RecursiveCharacterTextSplitter({
  chunkSize: 1000,
  chunkOverlap: 200,
});
const allSplits = await splitter.splitDocuments(docs);
console.log(`Split blog post into ${allSplits.length} sub-documents.`);
Split blog post into 29 sub-documents.

存储文档

现在我们需要对66个文本块进行索引,以便在运行时进行搜索。根据语义搜索教程,我们的方法是嵌入每个文档分割的内容,并将这些嵌入插入到向量存储中。给定一个输入查询,然后我们可以使用向量搜索来检索相关文档。 我们可以使用在教程开始时选择的向量存储和嵌入模型,通过单个命令嵌入和存储所有文档拆分。
await vectorStore.addDocuments(allSplits);
深入探索 Embeddings:围绕文本嵌入模型的外包装,用于将文本转换为嵌入。
  • 集成: 提供超过30种集成供您选择。
  • 界面: 基础界面的API参考。
VectorStore:围绕向量数据库的包装器,用于存储和查询嵌入。
  • 集成: 提供超过40种集成供您选择。
  • 界面: 基础界面的API参考。
这完成了管道中的索引部分。到这一点,我们有一个可查询的向量存储,其中包含我们博客文章的块状内容。给定一个用户问题,我们理想上应该能够返回回答问题的博客文章片段。

2. 检索与生成

RAG应用程序通常按以下方式工作:
  1. 检索:给定用户输入,使用检索器从存储中检索相关分割。
  2. 生成:一个模型使用包含检索数据的提问提示生成答案。
retrieval_diagram 现在我们来编写实际的应用逻辑。我们希望创建一个简单的应用程序,该程序接收用户问题,搜索与该问题相关的文档,将检索到的文档和初始问题传递给模型,并返回答案。 我们将演示:
  1. 一个使用简单工具执行搜索的RAG 智能体。这是一个很好的通用实现。
  2. 一个两步RAG ,每个查询仅使用一次LLM调用。这是处理简单查询的一种快速有效的方法。

RAG智能体

一种RAG应用的表述方式是作为一个简单的智能体,它拥有一个检索信息的工具。我们可以通过实现一个工具来封装我们的向量存储,从而组装一个最小的RAG智能体:
import * as z from "zod";
import { tool } from "@langchain/core/tools";

const retrieveSchema = z.object({ query: z.string() });

const retrieve = tool(
  async ({ query }) => {
    const retrievedDocs = await vectorStore.similaritySearch(query, 2);
    const serialized = retrievedDocs
      .map(
        (doc) => `Source: ${doc.metadata.source}\nContent: ${doc.pageContent}`
      )
      .join("\n");
    return [serialized, retrievedDocs];
  },
  {
    name: "retrieve",
    description: "Retrieve information related to a query.",
    schema: retrieveSchema,
    responseFormat: "content_and_artifact",
  }
);
在这里,我们指定从 responseFormatcontent_and_artifactresponseFormat 以配置工具将原始文档作为 工件 附加到每个 工具消息 上。这将使我们能够在我们的应用程序中访问文档元数据,而无需从发送到模型的字符串表示中分离出来。
考虑到我们的工具,我们可以构建智能体: 考虑到我们的工具,我们可以构建智能体:
import { createAgent } from "langchain";

const tools = [retrieve];
const systemPrompt = new SystemMessage(
    "You have access to a tool that retrieves context from a blog post. " +
    "Use the tool to help answer user queries."
)

const agent = createAgent({ model: "openai:gpt-5", tools, systemPrompt });
让我们来测试一下。我们构建一个问题,通常需要一系列迭代检索步骤来回答:
let inputMessage = `What is the standard method for Task Decomposition?
Once you get the answer, look up common extensions of that method.`;

let agentInputs = { messages: [{ role: "user", content: inputMessage }] };

const stream = await agent.stream(agentInputs, {
  streamMode: "values",
});
for await (const step of stream) {
  const lastMessage = step.messages[step.messages.length - 1];
  console.log(`[${lastMessage.role}]: ${lastMessage.content}`);
  console.log("-----\n");
}
[human]: What is the standard method for Task Decomposition?
Once you get the answer, look up common extensions of that method.
-----

[ai]:
Tools:
- retrieve({"query":"standard method for Task Decomposition"})
-----

[tool]: Source: https://lilianweng.github.io/posts/2023-06-23-agent/
Content: hard tasks into smaller and simpler steps...
Source: https://lilianweng.github.io/posts/2023-06-23-agent/
Content: System message:Think step by step and reason yourself...
-----

[ai]:
Tools:
- retrieve({"query":"common extensions of Task Decomposition method"})
-----

[tool]: Source: https://lilianweng.github.io/posts/2023-06-23-agent/
Content: hard tasks into smaller and simpler steps...
Source: https://lilianweng.github.io/posts/2023-06-23-agent/
Content: be provided by other developers (as in Plugins) or self-defined...
-----

[ai]: ### Standard Method for Task Decomposition

The standard method for task decomposition involves...
-----
注意智能体:
  1. 生成一个查询以搜索任务分解的标准方法;
  2. 收到答案后,生成第二个查询以搜索其常见扩展;
  3. 收到所有必要上下文后,回答问题。
我们可以通过LangSmith跟踪信息看到完整的步骤序列,以及延迟和其他元数据。
您可以使用LangGraph框架直接添加更深入的控制和自定义——例如,您可以添加步骤来评估文档的相关性并重写搜索查询。查看LangGraph的Agentic RAG教程以获取更多高级用法。

RAG 链

在上面的智能体RAG公式中,我们允许LLM在生成工具调用以帮助回答用户查询时行使自主权。这是一个很好的通用解决方案,但也有一些权衡:
✅ 优点⚠️ 缺点
仅在需要时进行搜索 – LLM 可以处理问候语、后续问题和简单查询,而不会触发不必要的搜索。两次推理调用 – 当执行搜索时,需要一次调用生成查询,另一次调用生成最终响应。
上下文搜索查询 – 将搜索视为具有 query 输入的工具,LLM 将构建自己的查询,这些查询结合了对话上下文。控制减少 – LLM 可能会跳过实际需要的搜索,或者在不需要时发起额外的搜索。
允许多次搜索 – LLM 可以执行多个搜索以支持单个用户查询。
另一种常见的方法是两步链式方法,其中我们始终运行一次搜索(可能使用原始用户查询),并将结果作为单个LLM查询的上下文。这导致每个查询仅有一个推理调用,以牺牲灵活性为代价换取了降低延迟。 在这个方法中,我们不再在循环中调用模型,而是进行单次遍历。 我们可以通过从智能体中移除工具,并将检索步骤整合到自定义提示中来实现这个链。
import { createAgent, dynamicSystemPromptMiddleware } from "langchain";
import { SystemMessage } from "@langchain/core/messages";

const agent = createAgent({
  model,
  tools: [],
  middleware: [
    dynamicSystemPromptMiddleware(async (state) => {
        const lastQuery = state.messages[state.messages.length - 1].content;

        const retrievedDocs = await vectorStore.similaritySearch(lastQuery, 2);

        const docsContent = retrievedDocs
        .map((doc) => doc.pageContent)
        .join("\n\n");

        // Build system message
        const systemMessage = new SystemMessage(
        `You are a helpful assistant. Use the following context in your response:\n\n${docsContent}`
        );

        // Return system + existing messages
        return [systemMessage, ...state.messages];
    })
  ]
});
让我们试试这个。
let inputMessage = `What is Task Decomposition?`;

let chainInputs = { messages: [{ role: "user", content: inputMessage }] };

const stream = await agent.stream(chainInputs, {
  streamMode: "values",
})
for await (const step of stream) {
  const lastMessage = step.messages[step.messages.length - 1];
  prettyPrint(lastMessage);
  console.log("-----\n");
}
LangSmith 跟踪记录 中,我们可以看到检索到的上下文被整合到模型提示中。 这是一个在受限环境中进行简单查询的快速且有效的方法,在这种情况下,我们通常确实希望将用户查询通过语义搜索来获取更多上下文。
上述 RAG chain 将检索到的上下文整合为该次运行的单一系统消息。agentic RAG公式的表述类似,我们有时希望在应用程序状态中包含原始源文档,以便访问文档元数据。对于两步链的情况,我们可以通过以下方式实现:
  1. 在状态中添加一个键以存储检索到的文档
  2. 通过 预模型钩子 添加一个新节点以填充该键(以及注入上下文)。
import { createMiddleware, Document, createAgent } from "langchain";
import { MessagesZodSchema } from "@langchain/langgraph";

const StateSchema = z.object({
  messages: MessagesZodSchema,
  context: z.array(z.custom<Document>()),
})

const retrieveDocumentsMiddleware = createMiddleware({
  stateSchema: StateSchema,
  beforeModel: async (state) => {
    const lastMessage = state.messages[state.messages.length - 1].content;
    const retrievedDocs = await vectorStore.similaritySearch(lastMessage, 2);

    const docsContent = retrievedDocs
      .map((doc) => doc.pageContent)
      .join("\n\n");

    const augmentedMessageContent = [
        ...lastMessage.content,
        { type: "text", text: `Use the following context to answer the query:\n\n${docsContent}` }
    ]

    // Below we augment each input message with context, but we could also
    // modify just the system message, as before.
    return {
      messages: [{
        ...lastMessage,
        content: augmentedMessageContent,
      }]
      context: retrievedDocs,
    }
  },
});

const agent = createAgent({
  model,
  tools: [],
  middleware: [retrieveDocumentsMiddleware],
});

下一步

现在我们已经通过 @[create_agent] 实现了一个简单的 RAG 应用程序,我们可以轻松地集成新功能并进一步深入: