概述

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

概念

我们将涵盖以下概念:
  • 索引:用于从源摄取数据并对其进行索引的管道。这通常在单独的过程中发生。
  • 检索和生成:实际的 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: "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");
}
Check out the LangSmith trace.

Setup

Installation

This tutorial requires these langchain dependencies:
npm i langchain @langchain/community @langchain/textsplitters
有关更多详细信息,请参阅我们的安装指南

LangSmith

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

组件

我们需要从 LangChain 的集成套件中选择三个组件。 选择一个聊天模型:
  • OpenAI
  • Anthropic
  • Azure
  • Google Gemini
  • Bedrock Converse
👉 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("gpt-4.1");
选择一个嵌入模型:
  • OpenAI
  • Azure
  • AWS
  • VertexAI
  • MistralAI
  • Cohere
npm i @langchain/openai
import { OpenAIEmbeddings } from "@langchain/openai";

const embeddings = new OpenAIEmbeddings({
  model: "text-embedding-3-large"
});
选择一个向量存储:
  • Memory
  • Chroma
  • FAISS
  • MongoDB
  • PGVector
  • Pinecone
  • Qdrant
npm i @langchain/classic
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";

const vectorStore = new MemoryVectorStore(embeddings);

1. Indexing

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

加载文档

我们首先需要加载博客文章内容。我们可以为此使用文档加载器,这些对象从源加载数据并返回 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+ 个集成可供选择。
  • BaseLoader:基础接口的 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. 每个查询仅使用一次 LLM 调用的两步 RAG 。这是用于简单查询的快速有效方法。

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",
  }
);
这里我们将 responseFormat 指定为 content_and_artifact,以配置工具将原始文档作为工件附加到每个 ToolMessage。这将让我们在应用程序中访问文档元数据,与发送给模型的字符串化表示分开。
有了我们的工具,我们可以构建智能体:
import { createAgent } from "langchain";

const tools = [retrieve];
const systemPrompt = new SystemMessage(
    "您可以访问一个从博客文章中检索上下文的工具。" +
    "使用该工具帮助回答用户查询。"
)

const agent = createAgent({ model: "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...
-----
Note that the agent:
  1. Generates a query to search for a standard method for task decomposition;
  2. Receiving the answer, generates a second query to search for common extensions of it;
  3. Having received all necessary context, answers the question.
We can see the full sequence of steps, along with latency and other metadata, in the LangSmith trace.
You can add a deeper level of control and customization using the LangGraph framework directly— for example, you can add steps to grade document relevance and rewrite search queries. Check out LangGraph’s Agentic RAG tutorial for more advanced formulations.

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");

        // 构建系统消息
        const systemMessage = new SystemMessage(
        `您是一个有用的助手。在您的响应中使用以下上下文:\n\n${docsContent}`
        );

        // 返回系统消息 + 现有消息
        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");
}
In the LangSmith trace we can see the retrieved context incorporated into the model prompt. This is a fast and effective method for simple queries in constrained settings, when we typically do want to run user queries through semantic search to pull additional context.
The above RAG chain incorporates retrieved context into a single system message for that run.As in the agentic RAG formulation, we sometimes want to include raw source documents in the application state to have access to document metadata. We can do this for the two-step chain case by:
  1. Adding a key to the state to store the retrieved documents
  2. Adding a new node via a pre-model hook to populate that key (as well as inject the context).
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: `使用以下上下文回答查询:\n\n${docsContent}` }
    ]

    // 下面我们用上下文增强每个输入消息,但我们也可以
    // 像以前一样只修改系统消息。
    return {
      messages: [{
        ...lastMessage,
        content: augmentedMessageContent,
      }]
      context: retrievedDocs,
    }
  },
});

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

后续步骤

现在我们已经通过 @[create_agent] 实现了一个简单的 RAG 应用程序,我们可以轻松地合并新功能并深入:
Connect these docs programmatically to Claude, VSCode, and more via MCP for real-time answers.