LangChain 进阶(二):给 AI 外挂大脑 —— RAG (检索增强生成) 实战

1. 痛点:为什么 AI 总是“一本正经地胡说八道”?

在上一篇中,我们学会了如何调用 OpenAI 的 API。但是,如果你问 GPT-3.5 这样一个问题:“请总结一下昨天咱们公司发布的内部会议纪要”,或者“LangChain 0.1.0 版本具体是哪天发布的?”,它会遇到两个大问题:

  1. 知识截止(Knowledge Cutoff):模型的训练数据是固定的(比如截止到 2021 年或 2023 年),它不可能知道今天发生的新闻。
  2. 私有数据不可见:模型没有看过你电脑里的 PDF、Word 文档或公司的数据库。

当你强行问它不知道的事情时,为了满足你的要求,它往往会编造一个看起来很合理的答案。这就是幻觉(Hallucination)

为了解决这个问题,我们可以选择**微调(Fine-tuning)**模型,把新知识“教”给它。但这太贵、太慢了。

更聪明的做法是 RAG(Retrieval-Augmented Generation,检索增强生成)。 简单来说,就是开卷考试

  • 不使用 RAG:你问老师问题,老师全凭记忆回答(容易记错或不知道)。
  • 使用 RAG:你问老师问题,老师先去图书馆查阅相关书籍(检索),找到具体的段落(获取上下文),然后结合书里的内容回答你(生成)。

2. RAG 的核心架构:ETL 流程

要实现 RAG,我们需要搭建一个数据处理流水线。这个过程通常被称为 Indexing(索引),包含三个步骤:

  1. Load(加载):把各种来源的数据(网页、PDF、文本)加载进来。
  2. Split(切割):把长文章切成小块(Chunk)。为什么?因为 LLM 有输入长度限制(Context Window),而且切成小块更容易找到精准的答案。
  3. Embed & Store(向量化与存储):把文字转换成计算机能理解的数字列表(向量),存入专用的向量数据库。

3. 环境准备

我们需要安装一些处理数据和存储向量的库。我们将使用 BeautifulSoup 来抓取网页,使用 ChromaDB 作为本地向量数据库(因为它不需要注册账号,完全本地运行)。

请在终端执行:

1
2
3
4
5
6
7
8
# 安装网页解析器
pip install beautifulsoup4

# 安装向量数据库 Chroma 的 LangChain 适配器
pip install langchain-chroma

# 确保核心库是最新的
pip install -U langchain langchain-openai

4. 实战代码:构建这一整套流水线

我们将构建一个应用,让 AI 能够回答关于“LLM 自主代理(LLM Powered Autonomous Agents)”这篇著名技术博客的内容。如果不使用 RAG,GPT 可能只能泛泛而谈;使用了 RAG,它能精准引用博客细节。

第一阶段:数据加载与切分 (Load & Split)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import bs4
from langchain_community.document_loaders import WebBaseLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 1. 加载数据 (Load)
# 我们使用 WebBaseLoader 加载一篇关于 Agent 的长文
# bs_kwargs 是 BeautifulSoup 的参数,用于过滤网页中的广告、导航栏,只保留正文
loader = WebBaseLoader(
web_paths=("[https://lilianweng.github.io/posts/2023-06-23-agent/](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()

print(f"原始文档长度: {len(docs[0].page_content)} 字符")

# 2. 切分文本 (Split)
# 为什么使用 'Recursive' (递归) 切分器?
# 它会尝试按顺序使用 ["\n\n", "\n", " ", ""] 进行切分。
# 它的目的是尽量保持段落、句子的完整性,而不是在句子中间硬生生切断。
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, # 每个切片大约 1000 个字符
chunk_overlap=200, # 切片之间重叠 200 个字符 (为了保持上下文连贯性)
add_start_index=True,
)
all_splits = text_splitter.split_documents(docs)

print(f"切分后的片段数量: {len(all_splits)}")
print(f"第一个片段内容预览: \n{all_splits[0].page_content[:100]}...")

第二阶段:向量化与存储 (Embed & Store)

这一步是 RAG 的魔法所在。我们需要把文字变成向量(Vector)。 向量是一串很长的数字(比如 [0.12, -0.98, 0.55, ...])。 核心原理:意思相近的句子,在数学空间里的距离会很近。比如“我喜欢吃苹果”和“水果是我的最爱”在向量空间里靠得很近,而和“今天股市大跌”离得很远。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings

# 1. 初始化 Embeddings 模型
# 这个模型负责把文字翻译成向量。我们需要使用 OpenAI 的 embedding 模型。
# 注意:这也需要消耗 API 额度,但非常便宜。
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

# 2. 创建向量数据库并存入数据
# Chroma.from_documents 会自动做两件事:
# a. 调用 embeddings 模型把 all_splits 里的文字变成向量。
# b. 把向量和对应的原始文字存到本地内存中。
vectorstore = Chroma.from_documents(
documents=all_splits,
embedding=embeddings
)

# 3. 测试一下检索 (Retrieve)
# 我们不问 AI,直接问数据库:谁和"Task Decomposition"最相关?
# k=1 表示只返回最相似的 1 个片段
retriever = vectorstore.as_retriever(search_type="similarity", search_kwargs={"k": 1})

retrieved_docs = retriever.invoke("What is Task Decomposition?")

print("\n--- 检索到的相关文档片段 ---")
print(retrieved_docs[0].page_content)

第三阶段:构建 RAG 链 (Generate)

现在我们有了数据库(书本),也有了检索器(查书动作),我们需要把它们和 LLM(大脑)结合起来。

逻辑是:

  1. 用户提问。
  2. 检索器去数据库找相关片段。
  3. 用户提问相关片段一起塞进提示词里。
  4. LLM 生成答案。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from langchain_openai import ChatOpenAI

# 1. 初始化 LLM
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)

# 2. 定义 RAG 专用的 Prompt
# 我们在 prompt 里显式地告诉 AI:基于提供的 Context 回答问题。
template = """你是一个专业的 AI 助手。
请完全基于下面提供的上下文(Context)来回答用户的问题。
如果上下文中没有答案,就直接说"我不知道",不要编造内容。

上下文 Context:
{context}

用户问题:
{question}
"""
prompt = ChatPromptTemplate.from_template(template)

# 3. 辅助函数:格式化文档
# 检索回来的是 Document 对象列表,我们需要把它们的内容拼成一个长字符串
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)

# 4. 构建 RAG 链 (LCEL)
# 这里是难点,请仔细看数据流向:
# 用户的输入是一个字符串 (question)
#
# chain 的第一步是一个并行处理 (字典):
# - "context": 拿到用户输入 -> 传给 retriever -> 拿到 docs -> 传给 format_docs -> 生成长字符串
# - "question": 拿到用户输入 -> 原样传递 (RunnablePassthrough)
#
# chain 的第二步: 把上面生成的字典传给 prompt
# chain 的第三步: 把 prompt 结果传给 llm
# chain 的第四步: 把 llm 结果转成字符串
rag_chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)

# 5. 运行
print("\n--- 开始 RAG 回答 ---")
# 注意:这里我们问的是英文文章里的具体概念,建议用英文提问,或者让 AI 翻译
response = rag_chain.invoke("What is the Chain of Thought prompting?")
print(response)

5. 深度解析:为什么这么设计?

为什么要 chunk_overlap(切片重叠)?

假设我们的一句话是:“LangChain 的核心作者是 Harrison Chase。” 如果我们不幸地在“是”和“Harrison”中间切开了: 片段 A: “LangChain 的核心作者是” 片段 B: “Harrison Chase。” 那么检索的时候,片段 A 缺失了关键信息,片段 B 缺失了主语。这两个片段都变成了废数据。 设置 chunk_overlap=200,意味着片段 A 的末尾会包含片段 B 的开头,片段 B 的开头会包含片段 A 的末尾,保证了语义的完整连接。

为什么 temperature=0

在 RAG 场景下,我们希望 AI 严格忠实于检索到的事实,而不是发挥创造力。将温度设为 0 可以最大程度减少幻觉。

retriever | format_docs 是什么写法?

这是 LCEL 的嵌套用法。retriever 也是一个 Runnable(可运行对象)。当 retriever 运行完,它的输出(Document 列表)会自动作为输入流向 format_docs 函数。

6. 总结

在本篇博客中,我们完成了一个质的飞跃:从单纯的闲聊 AI,变成了基于知识库的问答 AI。

现在的流程是:

  1. Index: 抓取 -> 切分 -> 存向量。
  2. Retrieve: 用户提问 -> 找向量 -> 拿回文本。
  3. Generate: 文本 + 问题 -> LLM -> 答案。

但是,现在的系统还是“直来直去”的。用户问一次,它答一次。如果我想让 AI 帮我上网搜索,或者查询数据库,然后再决定怎么回答呢?这就需要引入**Agent(代理)**的概念。

在下一篇博客中,我们将探讨 LangGraph 的前身:如何利用 LangChain 的 Tool(工具)功能,让 AI 拥有“手”和“脚”。