Python & AI Tutorials Logo
LangChain & LangGraph

9. 构建你的第一个 RAG 系统

到目前为止,我们构建的所有应用都只依赖于 LLM 的预训练知识。这就是为什么它无法回答关于 LLM 从未学习过的信息的问题——比如你公司的内部文档或产品手册。

RAG(检索增强生成,Retrieval-Augmented Generation) 解决了这个问题。当用户提问时,它首先检索相关文档,然后将检索到的内容与问题一起传递给 LLM,使其能够基于该内容进行回答。你将 LLM 的推理能力与你的文档知识结合起来。

在本章中,我们将构建一个完整的 RAG 流水线,从文档准备(加载、分块、嵌入)到基于检索的答案生成。完成的系统在收到问题时会检索相关文档,然后将它们与问题一起传递给 LLM,使其基于文档内容进行回答。当信息在文档中时,它会准确回答;当信息不在文档中时,它会诚实地说"我不知道"——这就是可信赖 RAG 的本质。

9.1) 理解 RAG

9.1.1) 问题所在:LLM 不了解你的数据

LLM 是在互联网数据上训练的,比如维基百科、新闻文章和公开代码。它们不了解你公司的内部文档或你昨天收到的合同。因此它们无法回答以下问题:

  • "我们公司的休假政策是什么?"
  • "总结本季度的销售报告"
  • "我刚收到的合同中的退款条款是什么?"
python
from langchain_openai import ChatOpenAI
 
llm = ChatOpenAI(model="gpt-5-mini")
 
# 询问 LLM 从未见过的私有文档
response = llm.invoke("Acme Corp 的退款政策是什么?")
print(response.content)

输出:

我没有关于 Acme Corp 退款政策的具体信息。我建议查看他们的官方网站或直接联系他们的客户支持团队以获取最准确和最新的信息。

在这个例子中,LLM 诚实地说它不知道。(或者它可能会编造一个听起来合理的答案。)

但如果我们将退款政策文档与问题一起提供会怎样?LLM 会基于提供的内容给出准确的答案。这就是 RAG 背后的核心思想。

9.1.2) 我们应该如何提供文档?

最简单的方法是将整个文档复制粘贴到提示中。这对于短文档实际上效果很好。

python
from langchain_openai import ChatOpenAI
 
llm = ChatOpenAI(model="gpt-5-mini")
 
# 实际上这会长得多,但让我们假设以下是整个文档
document_text = """
退款政策(生效日期:2026年1月):
- 购买后30天内凭原始收据可全额退款。
- 30天后,仅提供商店积分。
- 数字产品下载后不可退款。
- 有缺陷的商品可随时退货并获得全额退款。
"""
 
response = llm.invoke(
    f"""请根据以下文档回答:
{document_text}
 
问题:数字产品的退款政策是什么?"""
)
print(response.content)

输出:

数字产品下载后不可退款。
...

这对短文档效果很好。但如果文档非常大呢?这会导致以下严重问题:

1. 上下文窗口限制:LLM 一次可以处理的令牌数量有限。对于 GPT-5-mini,是 400K 令牌。然而,你的整个公司文档很容易超过这个限制。即使能放进去,随着上下文变长,响应也会变慢且不太准确。

2. 成本:LLM API 按令牌收费。当只需要一两段时发送整个文档会使成本飙升。

3. 准确性下降:当你包含整个文档时,你实际需要的信息会被淹没在无关内容中。LLM 的注意力会被无关信息分散,降低答案质量。

RAG 通过检索并仅提供文档的相关部分来解决所有三个问题。

9.1.3) 核心思想:检索相关部分并与问题一起提供

RAG 的本质很简单:在将问题发送给 LLM 之前,首先从文档库中找到相关部分并与问题一起提供。

工作流程如下:

  1. 用户提出问题。
  2. 系统从文档库中检索(Retrieval) 相关内容。
  3. 检索到的内容与问题一起增强(Augmentation) 提示。
  4. LLM 基于检索到的内容生成(Generation) 答案。

这三个步骤就是 RAG(检索增强生成,Retrieval-Augmented Generation) 名称的由来。

9.1.4) 我们如何检索相关内容?(关键词匹配的局限性)

检索步骤对 RAG 至关重要。你需要提供相关内容才能获得正确的答案。那么我们如何检索与问题相关的内容?

最简单的方法是关键词匹配:查找包含问题中单词的文档。例如,如果有人问"数字产品的退款政策是什么?"你会搜索包含"退款"、"数字"和"产品"这些词的文档。

但关键词匹配有一个致命弱点:它只能找到完全匹配的单词

假设你有一份退款政策文档,内容如下:

"购买后30天内可全额退款。"

当用户问"我如何拿回我的钱?"时会发生什么?这份文档不会被检索到。文档中不包含"拿回我的钱"这个短语。人类理解"退款"和"拿回我的钱"具有相同的含义,但关键词搜索只匹配单词,所以它无法找到。

关键词搜索只匹配单词。即使含义相同,如果单词不同,它也找不到。

解决方案是语义搜索。而使之成为可能的是嵌入(embedding)。

9.1.5) 嵌入:将文本转换为数值向量

嵌入(Embedding) 将文本的含义表示为一个数字列表(一个向量)。当你将文本输入嵌入模型时,它会将其转换为数百到数千个数字的向量。

python
from langchain_openai import OpenAIEmbeddings
 
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# 嵌入单个句子
vector = embeddings_model.embed_query("我如何拿回我的钱?")
 
print(f"向量维度: {len(vector)}")
print(f"前5个值: {vector[:5]}")

输出:

向量维度: 1536
前5个值: [0.0123, -0.0456, 0.0789, -0.0234, 0.0567]

维度是构成向量的值的数量。text-embedding-3-small 模型将所有文本表示为 1,536 个数字。

为什么我们需要这么多数字?因为每个维度捕获含义的不同方面:

  • 某些维度可能区分"动作/状态"
  • 其他维度可能表示"具体/抽象"的程度
  • 还有一些可能表示"积极/消极"的情感
  • ...(1,536 个语义特征——尽管我们实际上无法解释每个维度代表什么)

就像二维坐标 (x, y) 表示平面上的一个点一样,1,536 维向量表示 1,536 维"含义空间"中的一个点。更多的维度允许更精细的含义区分。

相似的含义在含义空间中位置接近。"退款方式"和"拿回钱"使用不同的词,但因为它们有相似的含义,它们在含义空间中被放置得很近。

9.1.6) 语义搜索:含义相似,距离更近

一旦你将文档和查询都转换为向量,你就可以通过测量向量之间的相似度来找到最相关的文档。这被称为语义搜索——通过语义相似性而不是关键词匹配进行搜索。

最常见的相似度度量是余弦相似度,它测量两个向量之间的角度。当向量指向相似方向时,相似度更高。接近 1.0 表示含义非常相似,而接近 0 表示相关性低。

退款期限是多久?

[0.12, -0.45, 0.78, ...]

购买后30天内可全额退款。

[0.14, -0.42, 0.80, ...]

我们的办公室在西雅图

[-0.67, 0.33, -0.11, ...]

接近!
(相似度 ≈ 0.6415)

距离远
(相似度 ≈ 0.1706)

让我们实际计算一下:

python
from langchain_openai import OpenAIEmbeddings
import numpy as np
 
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# 嵌入查询和两个候选文档
query_vec = embeddings_model.embed_query("退款期限是多久?")
doc1_vec = embeddings_model.embed_query("购买后30天内可全额退款。")  # 相关
doc2_vec = embeddings_model.embed_query("我们的办公室位于西雅图市中心。")  # 不相关
 
def cosine_similarity(a, b):
    """计算两个向量之间的余弦相似度。"""
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
 
sim1 = cosine_similarity(query_vec, doc1_vec)
sim2 = cosine_similarity(query_vec, doc2_vec)
 
print(f"查询 vs '退款' 文档:    {sim1:.4f}")
print(f"查询 vs '办公室' 文档:   {sim2:.4f}")

输出:

查询 vs '退款' 文档:    0.6415
查询 vs '办公室' 文档:   0.1706

(实际值可能因模型而异)

退款文档得分高得多。嵌入模型理解"退款期限"和"30天内全额退款"在语义上是相关的。这就是语义搜索,它是 RAG 的核心机制。

9.1.7) RAG 流水线概述

结合我们学到的概念,创建以下 RAG 流水线:

阶段2:检索和答案生成

阶段1:知识摄入

📄 完整文档

✂️ 分割成块

🔢 嵌入块

向量存储

❓ 用户问题

🔢 嵌入问题

🔍 相似度搜索

📋 提取相关块

📝 提示增强
(问题 + 相关块)

🤖 LLM

基于相关块的答案

流水线由两个阶段组成:

知识摄入(最初执行一次,或在文档更改时执行):

  1. 文档加载:从各种来源(Markdown、PDF 等)提取文本数据。
  2. 文本分割(分块):将长文档分割成更小的块(chunk),以提高检索精度并符合 LLM 输入限制。
  3. 向量转换(嵌入):使用嵌入模型将块转换为基于含义的数值向量
  4. 向量存储:将转换后的向量和原始文本存储在向量数据库中(索引)。

检索和答案生成(对每个用户问题执行):

  1. 问题嵌入:使用摄入期间使用的相同模型将用户的问题转换为数值向量。
  2. 相似度搜索(检索):从向量数据库中提取与问题向量语义上最接近的前 K 个块。
  3. 提示增强:将原始问题与检索到的块结合起来增强提示。
  4. 答案生成:LLM 参考提供的块生成有根据的答案。

9.2) 文档加载和分块

本节涵盖 RAG 流水线知识摄入阶段的前两个步骤:

  1. 文档加载:从文件中读取文本数据
  2. 文本分割(分块):将文本数据分解成小的、可搜索的片段

在下一节(9.3)中,我们将学习如何将这些块转换为向量并存储它们。

9.2.1) 从文件加载文档

RAG 流水线的第一步是将文档加载到 Python 对象中。LangChain 提供了文档加载器(document loader)——支持各种文件格式的类。主要的加载器有:

  • TextLoader:纯文本(.txt)和 Markdown(.md)文件
  • PyPDFLoader:PDF(.pdf)文件,逐页加载
  • CSVLoader:CSV(.csv)文件,每行作为单独的文档加载
  • UnstructuredMarkdownLoader:Markdown(.md)文件,具有结构感知能力(标题、列表等)

无论使用哪个加载器,结果始终作为 Document 对象列表返回。每个 Document 有两个关键属性:

  • page_content:文档的文本内容
  • metadata:包含元信息的字典,如文件路径和页码

在本教程中,我们将使用 TextLoader 加载 Markdown 文件。

准备示例文档

首先,让我们创建一些示例文档来使用。在项目中创建一个 data/docs/ 文件夹并添加以下文件:

bash
mkdir -p data/docs

创建 data/docs/refund_policy.md:

markdown
# 退款政策
 
**生效日期**:2026年1月1日
 
## 标准退货
 
所有实体产品可在购买后30天内退货并获得全额退款。
需要原始收据或订单确认邮件。商品必须处于原始包装和未使用状态。
 
30天后,接受退货但仅提供商店积分。商店积分不会过期。
 
## 数字产品
 
数字产品(软件许可证、电子书、在线课程)一旦激活下载或访问链接即不可退款。
如果您遇到阻止访问的技术问题,请在7天内联系支持以获得更换或退款。
 
## 有缺陷的商品
 
有缺陷的商品可随时退货并获得全额退款或更换。
请附上缺陷描述。有缺陷退货的运费由公司承担。
 
## 订阅服务
 
月度订阅可随时取消。退款根据计费周期中的剩余天数按比例计算。
年度订阅可在前14天内全额退款。14天后,不提供退款,但访问权限持续到计费期结束。

创建 data/docs/shipping_info.md:

markdown
# 配送信息
 
## 国内配送
 
标准配送(5-7个工作日):订单满$50免费,否则$5.99。
快速配送(2-3个工作日):$12.99。
隔夜配送(下一个工作日):$24.99。
 
## 国际配送
 
国际订单通过跟踪航空邮件配送。配送时间因目的地而异,
通常为10-21个工作日。国际配送费用在结账时根据重量和目的地计算。
 
关税和进口税由买方承担,不包含在配送费用中。
 
## 订单跟踪
 
所有订单都包含跟踪号码,在发货后24小时内通过电子邮件发送。
通过电子邮件中的跟踪链接或通过承运商网站跟踪您的订单。
 
## 丢失或损坏的包裹
 
如果您的包裹丢失或到达时损坏,请在48小时内联系支持。
我们将免费发送替换品。对于损坏的商品,请提供损坏和包装的照片。

现在使用 TextLoader 加载这些文件:

python
from pathlib import Path
from langchain_community.document_loaders import TextLoader
 
# 从 data/docs 目录加载所有 .md 文件
docs_dir = Path("data/docs")
 
for md_file in docs_dir.glob("*.md"):
    loader = TextLoader(str(md_file), encoding="utf-8")
    docs = loader.load()
 
    if docs:  # 检查文件不为空
        doc = docs[0]  # 单个文件 = 单个 Document
        print(f"文件: {doc.metadata['source']}")
        print(f"长度: {len(doc.page_content)} 字符")
        print(f"预览: {doc.page_content[:80]}...")
        print()

输出:

文件: data/docs/refund_policy.md
长度: 1166 字符
预览: # 退款政策
...
 
文件: data/docs/shipping_info.md
长度: 972 字符
预览: # 配送信息
...

注意:TextLoader 接受单个文件路径作为输入,但返回 List[Document] 以与其他加载器保持一致的接口。(例如,PDFLoader 返回多个 Document——每页一个。)

9.2.2) 将文档分割成块:分块

上面的两个文档为了本教程的目的而故意保持简短。在实际应用中,你经常会处理数百或数千页的文档。如果你将整个文档嵌入为单个向量,数千个概念会被压缩成一个——使得无法准确检索你实际需要的内容。

分块(Chunking) 是将文档分割成小的、有意义的片段的过程。目标很简单:当用户提问时,只有与答案直接相关的特定段落应该被检索——而不是整个文档。

块大小直接影响检索和答案质量:

  • 太大:多个主题混合到一个块中,使嵌入不太准确,检索更困难。即使找到了正确的块,无关内容也会传递给 LLM,降低答案质量。
  • 太小:LLM 可能没有收到足够的信息来正确回答。例如,如果只检索到"标准配送是$5.99"这句话,LLM 无法知道这仅适用于$50以下的订单。
  • 恰到好处:每个块涵盖一个主题并有足够的上下文,实现准确的检索和答案。

9.2.3) 控制块大小和重叠

要将文档分割成块,你需要一个文本分割器。文本分割器(text splitter) 是一个 LangChain 工具,将长文档分解成更小的片段。选择正确的分割器很重要。

  • RecursiveCharacterTextSplitter:按层次顺序尝试多个分隔符以尽可能保留上下文。最广泛用于一般用途的分割器。
  • CharacterTextSplitter:在单个分隔符上分割(默认:\n\n)。适用于结构简单的文档。
  • MarkdownHeaderTextSplitter:在 Markdown 标题(###)上分割。当你想保留文档的目录结构时有效。

为什么 RecursiveCharacterTextSplitter 有效?

这个分割器通过从最大到最小单位尝试分隔符来找到最佳分割点。默认顺序如下(可以通过 separators 参数更改):

段落(\n\n) → 换行符(\n) → 单词( )

它总是首先尝试在最大的有意义单位上分割。如果段落超过 chunk_size,它会回退到换行符,然后是单词。因为它总是找到最自然的分割点,而不是在单词中间任意切割,所以生成的块更有可能包含语义上完整的信息。

关键参数

  • chunk_size:每个块的最大字符数。例如,chunk_size=400 表示没有块会超过 400 个字符。
  • chunk_overlap:相邻块之间的重叠字符数。例如,chunk_overlap=80 表示一个块的最后 80 个字符在下一个块的开头重复。
  • separators:用于分割文本的分隔符列表,按优先级顺序尝试。如果在当前分隔符上分割会超过 chunk_size,则尝试下一个分隔符以避免超过 chunk_size

什么是重叠,为什么需要它?

重叠意味着相邻的块共享一些内容——一个块的结尾包含在下一个块的开头。

这样做的原因是确保每个块都可以独立存在并具有足够的上下文。当在不知道之前内容的情况下阅读文档的一部分时,可能很难理解为什么提到某些内容。重叠使一个块的结尾流入下一个块,这样无论检索到哪个块,内容都能自然阅读。

📄 原始文档
段落1 | 段落2 | 段落3 | 段落4

✂️ 分割

📋 块1

段落1

📋 块2

段落1的结尾
+ 段落2

📋 块3

段落2的结尾
+ 段落3

📋 块4

段落3的结尾
+ 段落4

现在让我们实际分割一个文档:

python
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
 
# 加载文档
loader = TextLoader("data/docs/refund_policy.md", encoding="utf-8")
docs = loader.load()
 
# 配置分割器
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=80,
    separators=["\n## ", "\n\n", "\n", " "],
)
 
chunks = text_splitter.split_documents(docs)
 
print(f"分割成 {len(chunks)} 个块\n")
for i, chunk in enumerate(chunks):
    print(f"--- 块 {i} (来源: {chunk.metadata['source']}) ---")
    print(f"长度: {len(chunk.page_content)} 字符")
    print(chunk.page_content[:120])
    print()

输出:

分割成 4 个块
 
--- 块 0 (来源: data/docs/refund_policy.md) ---
长度: 374 字符
# 退款政策
...
 
--- 块 1 (来源: data/docs/refund_policy.md) ---
长度: 267 字符
## 数字产品
...
 
--- 块 2 (来源: data/docs/refund_policy.md) ---
长度: 204 字符
## 有缺陷的商品
...
 
--- 块 3 (来源: data/docs/refund_policy.md) ---
长度: 315 字符
## 订阅服务
...

注意:在上面的例子中,没有发生重叠。这是因为每个段落都基于第一个分隔符(\n## )干净地分割,同时保持在 chunk_size 内。只有当特定段落长于 chunk_size 并且必须分成两个或多个片段时才会发生重叠。

9.3) 使用 ChromaDB 进行向量存储和检索

9.3.1) 什么是向量存储?

向量存储(vector store)(也称为向量数据库)是一个优化用于使用嵌入向量存储和搜索数据的数据库。与传统数据库不同,在传统数据库中你通过精确的字段值查询(SELECT * FROM products WHERE category = 'electronics'),向量存储查找与你的查询含义最相似的项目。

在 RAG 中,向量存储保存文档块及其嵌入。当用户提问时,问题被转换为向量,向量存储检索具有最相似向量的块。

9.3.2) 选择向量存储并设置 ChromaDB

流行的向量存储包括 ChromaDBPineconeWeaviatepgvector(PostgreSQL 扩展)。它们在托管模型(本地 vs. 云)、规模和操作复杂性方面有所不同。对于本书,我们将使用 ChromaDB——它是开源的,完全在你的本地机器上运行,无需服务器设置,不仅对开发有用,对中小型生产工作负载也很有用。

ChromaDB 可以通过几种方式使用:

  • 本地模式(pip):将其作为 Python 库安装并立即使用。你可以在本地目录中存储和加载数据,无需任何单独的服务器基础设施。
  • 独立服务器(Docker):将 ChromaDB 作为单独的服务器进程运行。当多个应用程序需要共享同一个向量存储时很有用。
  • 托管云服务(Chroma Cloud):将 ChromaDB 用作云服务。Chroma Cloud 处理托管、扩展和维护,使你能够在没有基础设施管理负担的情况下提供稳定的服务。

让我们使用 pip 安装 ChromaDB:

bash
pip install chromadb langchain-chroma

chromadb 是核心向量存储库,langchain-chroma 是一个集成包,允许你直接在 LangChain 库中使用 ChromaDB。

9.3.3) 选择嵌入模型

首先要决定的是使用哪个嵌入模型。嵌入向量只有在由同一模型生成时才能进行比较。因此,你必须在存储文档和查询时使用相同的嵌入模型。

OpenAI 提供以下嵌入模型:

模型维度注释
text-embedding-3-small1536质量和成本的良好平衡
text-embedding-3-large3072更高质量,更高成本

对于本书,我们将使用 OpenAI 的 text-embedding-3-small 模型。它以低成本提供高效率,使其成为一般搜索、RAG 和成本敏感项目的实用选择。

python
from langchain_openai import OpenAIEmbeddings
 
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# 验证它是否工作
test_vector = embedding_model.embed_query("测试")
print(f"嵌入维度: {len(test_vector)}")

输出:

嵌入维度: 1536

成本说明:嵌入 API 调用比 LLM 调用便宜得多,但它们确实会产生成本。在数据库中存储文档(索引)时,每个块需要一次 API 调用,当用户提问(检索)时,问题需要一次 API 调用。有关当前定价,请参阅 OpenAI 定价页面

9.3.4) 在 ChromaDB 中存储块

现在让我们把所有东西放在一起。我们将加载文档,将它们分割成块,嵌入块,并将它们与嵌入向量一起存储在 ChromaDB 中。

python
# ingest.py - 完整的摄入流水线
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# 步骤1:加载文档
# DirectoryLoader:扫描目录并加载匹配的文件。
# 实际加载委托给 loader_cls 中指定的加载器。
loader = DirectoryLoader(
    "data/docs/", glob="**/*.md",
    loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"加载了 {len(documents)} 个文档")
 
# 步骤2:分割成块
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=80,
    separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"创建了 {len(chunks)} 个块")
 
# 步骤3:创建嵌入模型
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# 步骤4:创建向量存储并摄入块
vector_store = Chroma.from_documents(
    documents=chunks,
    embedding=embedding_model,
    persist_directory="data/chroma_db",
    collection_name="company_docs",
)
 
print(f"在 data/chroma_db/ 中存储了 {len(chunks)} 个块")

输出:

加载了 2 个文档
创建了 8 个块
在 data/chroma_db/ 中存储了 8 个块

Chroma.from_documents() 方法在一次调用中执行两个任务:

  1. 通过嵌入模型传递通过 documents 参数提供的块以获得嵌入向量。
  2. 将每个块与其嵌入向量一起存储在 ChromaDB 中。

9.3.5) 加载持久化的向量存储

在上一节中,我们将文档存储在向量存储中。此存储操作只需要最初执行一次(或在文档更改时执行)。之后,你可以简单地加载持久化的向量存储并直接使用它。

python
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# 加载持久化的向量存储——无需重新嵌入
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
vector_store = Chroma(
    persist_directory="data/chroma_db",
    collection_name="company_docs",
    embedding_function=embedding_model,
)
 
print(f"加载了包含 {len(vector_store.get()['ids'])} 个块的向量存储")

输出:

加载了包含 8 个块的向量存储

现在你可以通过简单地加载持久化的向量存储立即开始搜索,而无需重新嵌入文档。

9.3.6) 相似度搜索

加载向量存储后,你现在可以搜索与查询语义相似的块。top-K 参数指定要返回多少结果:

python
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
    persist_directory="data/chroma_db",
    collection_name="company_docs",
    embedding_function=embedding_model,
)
 
# 搜索与问题相关的块
query = "我可以退回数字产品吗?"
results = vector_store.similarity_search(query, k=2)
 
print(f"查询: {query}")
print(f"找到 {len(results)} 个结果\n")
 
for i, doc in enumerate(results):
    print(f"--- 结果 {i + 1} (来源: {doc.metadata['source']}) ---")
    print(doc.page_content[:200])
    print()

输出:

查询: 我可以退回数字产品吗?
找到 2 个结果
 
--- 结果 1 (来源: data/docs/refund_policy.md) ---
## 数字产品
 
...
 
--- 结果 2 (来源: data/docs/refund_policy.md) ---
# 退款政策
 
**生效日期**:2026年1月1日
 
## 标准退货
 
...

对于此查询,数字产品块以最高相似度被检索,其次是退款政策块。

你还可以使用 similarity_search_with_score 检索带有相似度分数的结果:

python
results_with_scores = vector_store.similarity_search_with_score(query, k=2)
 
for doc, score in results_with_scores:
    # ChromaDB 返回距离(越低越相似)
    print(f"分数: {score:.4f} | 来源: {doc.metadata['source']}")
    print(f"  {doc.page_content[:200]}...")
    print()

输出:

分数: 0.5942 | 来源: data/docs/refund_policy.md
  ## 数字产品
 
...
 
分数: 0.9577 | 来源: data/docs/refund_policy.md
  # 退款政策
 
...

请注意,ChromaDB 使用距离分数(越低越相似),而不是相似度分数(越高越相似)。数字产品块的距离最低,为 0.5942,使其成为最相关的结果。

9.4) 构建完整的 RAG 链

现在我们将构建一个完整的 RAG 系统:首先检索相关文档,然后将它们与问题一起传递给 LLM,以基于提供的信息生成答案。

9.4.1) 设计提示模板

提示模板最重要的部分是指示 LLM 基于提供的上下文回答。没有这个指令,LLM 可能会忽略搜索结果并根据其训练数据编造答案。

python
from langchain_core.prompts import ChatPromptTemplate
 
rag_prompt = ChatPromptTemplate.from_messages([
    ("system",
     "你是一名客户服务代表。"
     "仅使用提供的上下文回答用户的问题。"
     "如果上下文不包含足够的信息来回答,"
     "请说\"我没有足够的信息来回答这个问题。\"\n\n"
     "上下文:\n{context}"),
    ("human", "{question}"),
])

系统消息强制 LLM 仅使用提供的上下文回答。至关重要的是,当上下文不足时说"我没有足够的信息"的指令可以防止 LLM 编造看似合理但没有支持的答案。

9.4.2) 构建 RAG 链

我们现在已经准备好所有组件。我们只需要连接检索器、提示模板和 LLM。

完成的 RAG 系统将按以下方式工作:

  1. 接收用户的问题
  2. 从向量存储中检索相关块
  3. 将块和问题传递给提示模板以生成提示
  4. 使用 LLM 生成答案

context

question

用户问题

向量存储搜索

检索到的块
(合并成单个字符串)

提示生成

LLM

答案

让我们使用第6章的 LCEL | 运算符连接 RAG 链。

python
# rag_chain.py - 完整的 RAG 流水线
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
 
 
def format_docs(docs):
    """将检索到的文档连接成单个上下文字符串。"""
    return "\n\n---\n\n".join(doc.page_content for doc in docs)
 
 
def build_rag_chain():
    """构建并返回完整的 RAG 链。"""
    # 加载向量存储
    embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
    vector_store = Chroma(
        persist_directory="data/chroma_db",
        collection_name="company_docs",
        embedding_function=embedding_model,
    )
 
    # 创建检索器(k=3 表示返回前3个块)
    retriever = vector_store.as_retriever(search_kwargs={"k": 3})
 
    # 定义提示
    rag_prompt = ChatPromptTemplate.from_messages([
        ("system",
         "你是一名客户服务代表。"
         "仅使用提供的上下文回答用户的问题。"
         "如果上下文不包含足够的信息来回答,"
         "请说\"我没有足够的信息来回答这个问题。\"\n\n"
         "上下文:\n{context}"),
        ("human", "{question}"),
    ])
 
    # 初始化 LLM
    llm = ChatOpenAI(model="gpt-5-mini")
 
    # 使用 LCEL 组合链
    rag_chain = (
        {"context": retriever | format_docs, "question": lambda x: x}
        | rag_prompt
        | llm
        | StrOutputParser()
    )
 
    return rag_chain
 
 
if __name__ == "__main__":
    chain = build_rag_chain()
    answer = chain.invoke("我可以退回数字产品吗?")
    print(answer)

输出:

数字产品一旦激活下载或访问链接即不可退款。
如果您遇到阻止访问的技术问题,请在7天内联系支持以获得更换或退款。

让我们逐步分解链组合:

python
rag_chain = (
    {"context": retriever | format_docs, "question": lambda x: x}
    | rag_prompt
    | llm
    | StrOutputParser()
)

当你调用 chain.invoke("我可以退回数字产品吗?") 时,会发生以下情况:

  1. 字典步骤:
    • retriever | format_docs:使用问题搜索向量存储并将块合并成单个字符串
    • lambda x: x:将问题原样传递
    • 结果:{"context": "检索到的块(合并成单个字符串)", "question": "我可以退回数字产品吗?"}
  2. rag_prompt:使用字典值填充提示模板中的 {context}{question} 占位符
  3. llm:将完成的提示发送给 LLM
  4. StrOutputParser():从 LLM 的响应中仅提取文本

有关 LCEL 工作原理的更多详细信息,请参阅第6章。

9.4.3) 测试可回答和不可回答的问题

RAG 系统必须处理它可以回答的问题(信息存在于文档中)和它不能回答的问题(信息不在文档中)。让我们测试两种情况:

python
# test_rag.py - 使用各种问题测试 RAG 链
from rag_chain import build_rag_chain
 
chain = build_rag_chain()
 
test_questions = [
    # 可回答——信息在文档中
    "实体产品的退款政策是什么?",
    "快速配送费用是多少?",
    "我可以在6个月后退回有缺陷的商品吗?",
    # 不可回答——信息不在文档中
    "员工休假政策是什么?",
    "使用了哪些编程语言?",
]
 
for question in test_questions:
    print(f"问: {question}")
    answer = chain.invoke(question)
    print(f"答: {answer}\n")
    print("-" * 60)

输出:

问: 实体产品的退款政策是什么?
答: 所有实体产品可在购买后30天内退货并获得全额退款。...
 
------------------------------------------------------------
问: 快速配送费用是多少?
答: 快速配送(2-3个工作日)费用为$12.99。
 
------------------------------------------------------------
问: 我可以在6个月后退回有缺陷的商品吗?
答: 可以。有缺陷的商品可随时退货并获得全额退款或更换。...
 
------------------------------------------------------------
问: 员工休假政策是什么?
答: 我没有足够的信息来回答这个问题。
 
------------------------------------------------------------
问: 使用了哪些编程语言?
答: 我没有足够的信息来回答这个问题。
 
------------------------------------------------------------

结果展示了我们想要的确切行为:

  • 可回答的问题:基于检索到的文档提供准确的答案。LLM 不会添加文档中不包含的信息。
  • 不可回答的问题:回答"我没有足够的信息来回答这个问题。"LLM 正确识别检索到的上下文缺乏相关信息,并拒绝编造答案。

这就是 RAG 的力量。你的 LLM 回答关于你的数据的问题,并在不知道时诚实承认。每个答案都有文档支持,使系统比标准 LLM 更值得信赖。