Skip to main content
大语言模型(LLM)所能实现的最强大应用之一,便是复杂的问答(Q&A)聊天机器人。这类应用能够针对特定来源信息回答问题,其核心技术被称为“检索增强生成”,即 RAG 本教程将展示如何基于非结构化文本数据源构建一个简单的问答应用。我们将演示以下两种方法:
  1. 一个 RAG 代理,通过简单工具执行搜索。这是一种通用性强的实现方式。
  2. 一个两步 RAG ,每次查询仅调用一次 LLM。这是一种针对简单查询快速高效的方法。

概览

典型的 RAG 应用包含两个主要组件: 索引构建:从数据源摄取数据并建立索引的流水线。该过程通常在独立进程中完成。 检索与生成:实际的 RAG 过程,在运行时接收用户查询,从索引中检索相关数据,再将其传递给模型生成答案。 完成数据索引后,我们将使用 代理 作为编排框架,实现检索与生成步骤。
本教程的索引部分将主要遵循 语义搜索教程如果您的数据已可供搜索(即您已有执行搜索的函数),或您已熟悉该教程内容,可直接跳转至 检索与生成 部分。

环境设置

安装

本教程需要以下 LangChain 依赖项:
pip install langchain langchain-text-splitters langchain-community
更多详情,请参阅我们的 安装指南

LangSmith

使用 LangChain 构建的许多应用都包含多个步骤及多次 LLM 调用。随着应用复杂度增加,能够检查链或代理内部具体运行情况变得至关重要。最佳方式是使用 LangSmith 注册上述链接后,请设置环境变量以开始记录追踪:
export LANGSMITH_TRACING="true"
export LANGSMITH_API_KEY="..."
或在 Python 中设置:
import getpass
import os

os.environ["LANGSMITH_TRACING"] = "true"
os.environ["LANGSMITH_API_KEY"] = getpass.getpass()

组件选择

我们需要从 LangChain 的集成套件中选择三个组件: 选择聊天模型:
  • OpenAI
  • Anthropic
  • Azure
  • Google Gemini
  • AWS Bedrock
pip install -U "langchain[openai]"
import os
from langchain.chat_models import init_chat_model

os.environ["OPENAI_API_KEY"] = "sk-..."

llm = init_chat_model("openai:gpt-4.1")
👉 Read the OpenAI integration docs
选择嵌入模型:
  • OpenAI
  • Azure
  • Google Gemini
  • Google Vertex
  • AWS
  • HuggingFace
  • Ollama
  • Cohere
  • MistralAI
  • Nomic
  • NVIDIA
  • Voyage AI
  • IBM watsonx
  • Fake
pip install -U "langchain-openai"
import getpass
import os

if not os.environ.get("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter API key for OpenAI: ")

from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
选择向量数据库:
  • In-memory
  • AstraDB
  • Chroma
  • FAISS
  • Milvus
  • MongoDB
  • PGVector
  • PGVectorStore
  • Pinecone
  • Qdrant
pip install -U "langchain-core"
from langchain_core.vectorstores import InMemoryVectorStore

vector_store = InMemoryVectorStore(embeddings)

预览

本指南将构建一个可回答网站内容相关问题的应用。我们将使用的具体网站是 Lilian Weng 的博客文章《LLM 驱动的自主代理》,从而可针对该文章内容提问。 我们仅需约 40 行代码即可创建一个简单的索引流水线和 RAG 链。完整代码片段如下:
import bs4
from langchain.agents import AgentState, create_agent
from langchain_community.document_loaders import WebBaseLoader
from langchain_core.messages import MessageLikeRepresentation
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 加载并分块博客内容
loader = WebBaseLoader(
    web_paths=("https://lilianweng.github.io/posts/2023-06-23-agent/",),
    bs_kwargs=dict(
        parse_only=bs4.SoupStrainer(
            class_=("post-content", "post-title", "post-header")
        )
    ),
)
docs = loader.load()

text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
all_splits = text_splitter.split_documents(docs)

# 对分块内容建立索引
_ = vector_store.add_documents(documents=all_splits)

# 构建用于检索上下文的工具
@tool(response_format="content_and_artifact")
def retrieve_context(query: str):
    """检索有助于回答查询的信息。"""
    retrieved_docs = vector_store.similarity_search(query, k=2)
    serialized = "\n\n".join(
        (f"来源: {doc.metadata}\n内容: {doc.page_content}")
        for doc in retrieved_docs
    )
    return serialized, retrieved_docs

tools = [retrieve_context]
# 如有需要,可指定自定义指令
prompt = (
    "您可以使用一个工具从博客文章中检索上下文。"
    "请使用该工具帮助回答用户查询。"
)
agent = create_agent(llm, tools, prompt=prompt)
query = "什么是任务分解?"
for step in agent.stream(
    {"messages": [{"role": "user", "content": query}]},
    stream_mode="values",
):
    step["messages"][-1].pretty_print()
================================ 人类消息 =================================

什么是任务分解?
================================== AI 消息 ==================================
工具调用:
  retrieve_context (call_xTkJr8njRY0geNz43ZvGkX0R)
 调用 ID: call_xTkJr8njRY0geNz43ZvGkX0R
  参数:
    query: task decomposition
================================= 工具消息 =================================
名称: retrieve_context

来源: {'source': 'https://lilianweng.github.io/posts/2023-06-23-agent/'}
内容: 任务分解可以通过...

来源: {'source': 'https://lilianweng.github.io/posts/2023-06-23-agent/'}
内容: 组件一:规划...
================================== AI 消息 ==================================

任务分解是指...
查看 LangSmith 追踪记录

详细分步讲解

接下来,我们将逐步解析上述代码,深入理解其工作原理。

1. 索引构建

本节内容是对 语义搜索教程 的简要概括。如果您的数据已建立索引并可供搜索(即您已有执行搜索的函数),或您已熟悉 文档加载器嵌入模型向量数据库,可直接跳转至下一节 检索与生成
索引构建通常包含以下步骤:
  1. 加载:首先需要加载数据,这通过 文档加载器 完成。
  2. 分块文本分块器 将大型 文档 拆分为更小的块。这对数据索引和模型输入均有帮助,因为大块数据更难搜索,且无法适配模型有限的上下文窗口。
  3. 存储:我们需要存储并索引这些分块,以便后续检索。这通常使用 向量数据库嵌入模型 实现。
index_diagram

加载文档

首先需要加载博客文章内容。我们可以使用 文档加载器,这些对象从数据源加载数据并返回 文档 对象列表。 此处我们将使用 WebBaseLoader,它利用 urllib 从网页 URL 加载 HTML,并使用 BeautifulSoup 解析为文本。我们可通过 bs_kwargs 参数自定义 HTML -> 文本解析(参见 BeautifulSoup 文档)。本例中仅需保留 class 为 “post-content”、“post-title” 或 “post-header” 的 HTML 标签,因此移除其他所有标签。
import bs4
from langchain_community.document_loaders import WebBaseLoader

# 仅保留文章标题、头部和内容。
bs4_strainer = bs4.SoupStrainer(class_=("post-title", "post-header", "post-content"))
loader = WebBaseLoader(
    web_paths=("https://lilianweng.github.io/posts/2023-06-23-agent/",),
    bs_kwargs={"parse_only": bs4_strainer},
)
docs = loader.load()

assert len(docs) == 1
print(f"总字符数: {len(docs[0].page_content)}")
总字符数: 43131
print(docs[0].page_content[:500])
      LLM 驱动的自主代理

日期: 2023年6月23日  |  预计阅读时间: 31 分钟  |  作者: Lilian Weng


以 LLM(大语言模型)为核心控制器构建代理是一个很酷的概念。诸如 AutoGPT、GPT-Engineer 和 BabyAGI 等概念验证演示便是鼓舞人心的范例。LLM 的潜力不仅限于生成优美的文案、故事、文章和程序;它可被定位为强大的通用问题解决者。
代理系统概览#

深入探索 文档加载器:从数据源加载数据并返回 文档 对象列表的对象。
  • 集成:160+ 种可选集成。
  • 接口:基础接口的 API 参考。

文档分块

我们加载的文档超过 42,000 字符,对于许多模型而言过长,无法适配其上下文窗口。即使部分模型可容纳整篇文章,它们在很长的输入中也难以有效查找信息。 为此,我们将 文档 分块以便嵌入和向量存储。这有助于在运行时仅检索文章中最相关的部分。 语义搜索教程 所述,我们使用 RecursiveCharacterTextSplitter,它会递归地使用常见分隔符(如换行符)拆分文档,直至每个块达到合适大小。这是通用文本用例推荐的文本分块器。
from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,  # 块大小(字符数)
    chunk_overlap=200,  # 块重叠(字符数)
    add_start_index=True,  # 跟踪原始文档中的索引位置
)
all_splits = text_splitter.split_documents(docs)

print(f"将博客文章拆分为 {len(all_splits)} 个子文档。")
将博客文章拆分为 66 个子文档。
深入探索 文本分块器:将 文档 对象列表拆分为更小块的对象,用于存储和检索。

存储文档

现在我们需要对 66 个文本块建立索引,以便在运行时进行搜索。遵循 语义搜索教程,我们的方法是 嵌入 每个文档块的内容,并将这些嵌入向量插入 向量数据库。给定用户查询时,即可使用向量搜索检索相关文档。 我们可在单条命令中完成所有文档块的嵌入和存储,使用教程 开头选定的向量数据库和嵌入模型
document_ids = vector_store.add_documents(documents=all_splits)

print(document_ids[:3])
['07c18af6-ad58-479a-bfb1-d508033f9c64', '9000bf8e-1993-446f-8d4d-f4e507ba4b8f', 'ba3b5d14-bed9-4f5f-88be-44c88aedc2e6']
深入探索 嵌入模型:文本嵌入模型的封装,用于将文本转换为嵌入向量。
  • 集成:30+ 种可选集成。
  • 接口:基础接口的 API 参考。
向量数据库:向量数据库的封装,用于存储和查询嵌入向量。
  • 集成:40+ 种可选集成。
  • 接口:基础接口的 API 参考。
至此,流水线的 索引构建 部分已完成。此时我们已拥有一个可查询的向量数据库,其中包含博客文章的分块内容。给定用户问题,我们应能返回回答该问题的文章片段。

2. 检索与生成

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

RAG 代理

一种 RAG 应用的实现方式是将其构建为一个简单的代理,并配备一个用于检索信息的工具。我们可以通过实现一个封装向量存储的工具,来组装一个最小化的 RAG 代理:
from langchain_core.tools import tool

@tool(response_format="content_and_artifact")
def retrieve_context(query: str):
    """检索有助于回答查询的信息。"""
    retrieved_docs = vector_store.similarity_search(query, k=2)
    serialized = "\n\n".join(
        (f"来源: {doc.metadata}\n内容: {doc.page_content}")
        for doc in retrieved_docs
    )
    return serialized, retrieved_docs
这里我们使用了 tool 装饰器,将工具配置为将原始文档作为工件附加到每个 ToolMessage 中。这使我们能够在应用程序中访问文档元数据,而无需依赖发送给模型的字符串化表示。
检索工具并不局限于像上面示例中那样仅接受一个字符串 query 参数。你可以通过添加参数强制 LLM 指定额外的搜索条件 —— 例如,添加一个类别参数:
from typing import Literal

def retrieve_context(query: str, section: Literal["beginning", "middle", "end"]):
有了这个工具后,我们就可以构建代理了:
from langchain.agents import create_agent

tools = [retrieve_context]
# 如有需要,可指定自定义指令
prompt = (
    "你拥有一个从博客文章中检索上下文的工具。"
    "请使用该工具帮助回答用户的查询。"
)
agent = create_agent(llm, tools, prompt=prompt)
让我们来测试一下。我们构造一个通常需要多次检索步骤才能回答的问题:
query = (
    "任务分解的标准方法是什么?\n\n"
    "获得答案后,请查找该方法的常见扩展。"
)

for event in agent.stream(
    {"messages": [{"role": "user", "content": query}]},
    stream_mode="values",
):
    event["messages"][-1].pretty_print()
================================ 人类消息 =================================

任务分解的标准方法是什么?

获得答案后,请查找该方法的常见扩展。
================================== AI 消息 ==================================
工具调用:
  retrieve_context (call_d6AVxICMPQYwAKj9lgH4E337)
 调用 ID: call_d6AVxICMPQYwAKj9lgH4E337
  参数:
    query: 任务分解的标准方法
================================= 工具消息 =================================
名称: retrieve_context

来源: {'source': 'https://lilianweng.github.io/posts/2023-06-23-agent/'}
内容: 任务分解可以通过...

来源: {'source': 'https://lilianweng.github.io/posts/2023-06-23-agent/'}
内容: 组件一:规划...
================================== AI 消息 ==================================
工具调用:
  retrieve_context (call_0dbMOw7266jvETbXWn4JqWpR)
 调用 ID: call_0dbMOw7266jvETbXWn4JqWpR
  参数:
    query: 任务分解标准方法的常见扩展
================================= 工具消息 =================================
名称: retrieve_context

来源: {'source': 'https://lilianweng.github.io/posts/2023-06-23-agent/'}
内容: 任务分解可以通过...

来源: {'source': 'https://lilianweng.github.io/posts/2023-06-23-agent/'}
内容: 组件一:规划...
================================== AI 消息 ==================================

任务分解的标准方法通常是链式思维(CoT)...
我们可以看到,该代理:
  1. 生成一个查询,用于搜索任务分解的标准方法;
  2. 收到答案后,生成第二个查询,用于搜索该方法的常见扩展;
  3. 在获取所有必要上下文后,最终回答问题。
我们可以在 LangSmith 跟踪记录 中查看完整的步骤序列,以及延迟和其他元数据。
你可以直接使用 LangGraph 框架实现更深层次的控制和定制 —— 例如,你可以添加步骤来评估文档相关性并重写搜索查询。请查看 LangGraph 的 Agentic RAG 教程,了解更高级的实现方式。

RAG 链

在上述 代理式 RAG 实现中,我们允许 LLM 自主决定是否生成 工具调用 以帮助回答用户查询。这是一种通用性较强的解决方案,但也存在一些权衡:
✅ 优势⚠️ 劣势
按需搜索 – LLM 可以处理问候语、后续问题和简单查询,而无需触发不必要的搜索。两次推理调用 – 当执行搜索时,需要一次调用来生成查询,另一次调用来生成最终响应。
上下文感知的搜索查询 – 通过将搜索视为具有 query 输入的工具,LLM 能够结合对话上下文构造自己的查询。控制力降低 – LLM 可能在需要搜索时跳过搜索,或在不必要时执行额外搜索。
允许多次搜索 – LLM 可以为支持单个用户查询执行多次搜索。
另一种常见的方法是两步链,即我们始终执行一次搜索(可能使用原始用户查询),并将结果作为上下文整合到单次 LLM 查询中。这种方法每次查询仅需一次推理调用,在牺牲灵活性的同时降低了延迟。 在这种方法中,我们不再循环调用模型,而是仅执行单次传递。我们可以通过从代理中移除工具,并将检索步骤整合到自定义提示中来实现这一链式结构:
from langchain.agents import AgentState
from langchain_core.messages import MessageLikeRepresentation


def prompt_with_context(state: AgentState) -> list[MessageLikeRepresentation]:
    """将上下文注入到状态消息中。"""
    last_query = state["messages"][-1].text
    retrieved_docs = vector_store.similarity_search(last_query)

    docs_content = "\n\n".join(doc.page_content for doc in retrieved_docs)

    system_message = (
        "你是一个乐于助人的助手。请在回答中使用以下上下文:"
        f"\n\n{docs_content}"
    )

    return [{"role": "system", "content": system_message}, *list(state["messages"])]


agent = create_agent(llm, tools=[], prompt=prompt_with_context)
让我们来试一下:
query = "什么是任务分解?"
for step in agent.stream(
    {"messages": [{"role": "user", "content": query}]},
    stream_mode="values",
):
    step["messages"][-1].pretty_print()
================================ 人类消息 =================================

什么是任务分解?
================================== AI 消息 ==================================

任务分解是...
LangSmith 跟踪记录 中,我们可以看到检索到的上下文已整合到模型提示中。 当我们在受限环境中处理简单查询,并且通常希望将用户查询通过语义搜索以获取额外上下文时,这是一种快速而有效的方法。
上述 RAG 链 将检索到的上下文整合到单次运行的系统消息中。代理式 RAG 实现类似,我们有时希望在应用状态中包含原始源文档,以便访问文档元数据。我们可以通过以下方式为两步链实现这一点:
  1. 在状态中添加一个键以存储检索到的文档;
  2. 通过 预模型钩子 添加一个新节点,以填充该键(同时注入上下文)。
from langchain_core.documents import Document


def retrieve_documents(state: AgentState):
    """将上下文注入到状态消息中。"""
    last_message = state["messages"][-1]
    retrieved_docs = vector_store.similarity_search(last_message.text)

    docs_content = "\n\n".join(doc.page_content for doc in retrieved_docs)

    # 下面我们对每条输入消息都附加上下文,但也可以像之前那样仅修改系统消息。
    augmented_message_content = (
        f"{last_message.text}\n\n"
        "请使用以下上下文回答问题:\n"
        f"{docs_content}"
    )
    return {
        "messages": [
            last_message.model_copy(
                update={"content": augmented_message_content}
            )
        ],
        "context": retrieved_docs,
    }


class State(AgentState):
    context: list[Document]


agent = create_agent(
    llm,
    tools=[],
    pre_model_hook=retrieve_documents,
    state_schema=State,
)

后续步骤

现在我们已经通过 create_agent 实现了一个简单的 RAG 应用,可以轻松集成新功能并深入探索: