Day 3:RAG 系统构建(下)

在前一天的课程中,我们学会了如何将各类文档加载为 LangChain 的 Document 对象,并通过 RecursiveCharacterTextSplitter 按合理的粒度切分成 chunk。这些 chunk 现在还只是一段段原始文本,尽管它们已经被拆分得足够精细,但计算机仍然"看不懂"它们,它不知道"苹果"和"水果"在语义上高度相关,也无法判断"机器学习"和"深度学习"在同一篇文档中是否应该被一起检索出来。今天我们要做的,就是把这一堆零散的文本 chunk 转化为机器能够理解的数值向量,存入向量数据库,并在用户提问时从中检索出最相关的内容,最终拼接到大模型的提示词里,形成一个完整的检索增强生成(RAG)问答系统。整个过程可以概括为三个关键词:嵌入(Embed)、存储(Store)、检索(Retrieve)。

向量嵌入与存储

向量嵌入本质上是一种将自然语言文本映射到高维向量空间的数学手段。在这个空间中,语义相近的文本会被映射到几何上彼此靠近的点,而语义无关的文本则相距甚远。举例来说,"猫是一种宠物"和"狗是人类的好朋友"这两句话虽然字面完全不同,但它们都涉及"家庭宠物"这一语义范畴,因此在向量空间中大概率会聚在一起,而"今天的股票大涨"则可能落在完全不同的区域。这种语义压缩能力正是 embedding 模型的核心价值。

在 LangChain 生态中,所有 embedding 模型都遵循统一的 Embeddings 接口,提供两个核心方法,embed_documents 用于批量嵌入待存储的文档,embed_query 用于嵌入用户查询。两者的内部处理逻辑可能有所不同,某些模型(如 E5、BGE 系列)在训练时对查询和文档使用了不同的前缀提示词,分别调用这两个方法可以确保模型在正确的前缀下工作。对于 OpenAI 的 embedding 模型而言,langchain_openai 包中的 OpenAIEmbeddings 封装了这一切,我们只需要指定模型名称即可。把 embedding 模型和向量数据库组合起来,最简洁的写法如下:

python 复制代码
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

embeddings = OpenAIEmbeddings(
    model="Qwen/Qwen3-VL-Embedding-8B",
    base_url="https://api.siliconflow.cn/v1",
    check_embedding_ctx_length=False,
)
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db"
)

这段代码虽然短,却完成了两件关键的事。用 OpenAIEmbeddings 实例化了一个 embedding 模型,然后用 Chroma.from_documents 把之前 Day 2 切好的 chunks 一次性嵌入并存入 Chroma。其中

选好了 embedding 模型,下一步就是选择一个向量数据库来存储这些嵌入向量。LangChain 提供了几十种向量存储集成,从轻量级的 InMemoryVectorStore(适合原型验证)到功能完备的 ChromaFAISSPineconeWeaviateMilvus 等生产级方案。本课程选择 Chroma,因为它开箱即用,支持本地持久化,且与 LangChain 的协作非常顺畅。通过 Chroma.from_documents 方法,我们可以在一个调用中完成"嵌入所有文档 + 存入向量数据库"两个步骤,传入之前切分好的 chunks 和初始化好的 embeddings 对象,Chroma 会依次调用 embedding 模型将每个 chunk 转化为向量,并在内部构建索引。persist_directory 参数指定了持久化目录,如果不设置,数据只会留在内存中,程序退出后就丢失了。一旦设置了目录路径,Chroma 会将向量数据和元信息写入磁盘,下次启动时只需用相同的目录参数初始化 Chroma 实例即可恢复全部数据,而无需重新嵌入。

有一点值得留意,LangChain 的 VectorStore 对象本身并不直接实现 Runnable 接口,这意味着你不能像使用 chain 组件那样直接把它放进 LCEL 管道里。为此我们需要使用 as_retriever 方法将其包装为一个 Retriever 对象,后者才是真正的 Runnable,可以被无缝集成到任何 chain 或 agent 中。这个设计背后体现了一个重要的架构理念,检索器是一个比向量存储更通用的抽象,它只需要做到"接受一个字符串查询,返回一组 Document 对象",至于这些文档来自向量数据库、传统搜索引擎、还是外部 API,都可以被统一封装。

检索策略:相似度、MMR 与阈值过滤

把文档存进向量数据库只是完成了准备工作的一半,真正的挑战在于当用户提出一个问题时,如何从成千上万个 chunk 中精准地捞出最相关的那几条?LangChain 的 VectorStoreRetriever 支持三种不同的检索策略,通过 search_type 参数进行切换,具体配置方式如下:

python 复制代码
# 相似度检索
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})

# MMR 检索(最大边际相关性)
retriever = vectorstore.as_retriever(
    search_type="mmr",
    search_kwargs={"k": 4, "fetch_k": 10}
)

# 阈值过滤
retriever = vectorstore.as_retriever(
    search_type="similarity_score_threshold",
    search_kwargs={"score_threshold": 0.7}
)

这三种策略分别对应了不同的检索哲学,下面逐一拆解它们的运作方式与适用场景。

第一种 similarity 策略(也是默认值)使用的是纯粹的余弦相似度排序。当你调用检索时,向量数据库会将查询文本嵌入成向量,然后在所有存储的文档向量中计算余弦距离,按相似度从高到低排列,返回排名前 k 的文档。这种策略简单高效,适用于绝大多数场景,比如你问"LangChain 如何做文档切分",它会在向量空间中寻找与这句话最靠近的 chunk,大概率返回的就是关于 RecursiveCharacterTextSplitter 的内容。然而纯相似度排序有一个容易被忽略的缺陷,如果某个主题在文档中出现了大量的重复段落(比如同一概念在多个章节中被反复解释),那么返回的前 k 条结果可能高度同质化,都来自同一个章节,而其他同样相关但表述不同的内容反而被挤出了结果列表。

mmr(Maximum Marginal Relevance,最大边际相关性)正是为了解决这个"信息冗余"问题而设计的。通过设置 search_type="mmr" 并额外指定 fetch_k 参数(通常设为 k 的 2 到 4 倍),MMR 在内部会先从向量库中拉出 fetch_k 条候选文档,然后在这些候选中进行二次筛选,它既要确保被选中的文档与查询高度相关,又要确保新选入的文档与已经选入的文档尽量不相似。最终返回的 k 条文档既与问题相关,彼此之间又保持了较好的多样性。当你的文档集涵盖多个不同主题,或者你希望给用户一个更全面、更多元的回答视角时,MMR 是比纯相似度更好的选择。

similarity_score_threshold 则增加了一个质量门槛。在这种模式下,向量数据库依然计算所有文档的相似度并排序,但它只会返回那些相似度分数高于 score_threshold(取值在 0 到 1 之间)的文档。对于那些"不相关"的文档,分数自然偏低,就会被直接过滤掉。这在构建生产级 RAG 问答系统时尤其重要,如果你的知识库里根本没有和用户问题相关的内容,与其让大模型对着毫不相干的信息强行编造答案,不如让检索器返回空列表,再由下游逻辑触发"抱歉,我找不到相关信息"的兜底回复。这种做法在 LangChain 官方对 RAG 的设计建议中被称为"优雅降级":宁可不答,不可乱答。

三种策略并非互斥,很多时候你需要根据实际的检索效果来回切换和组合。例如,你可以在使用 MMR 确保多样性的同时,再加一层阈值过滤来排除那些连最低相关性标准都达不到的文档。重要的是理解每种策略的运作机制和适用边界,然后根据你的数据特征和业务需求做出选择。

构建完整 RAG Chain

有了 embedding 模型、向量数据库和检索器,我们现在可以将这些组件与之前学过的 prompt 模板和大语言模型串联起来,形成一条完整的 RAG 处理链路。用 LCEL 的管道语法表达出来就是下面这段代码。不到十行,却把检索、提示词构造、模型调用和输出解析四个步骤干净地串在了一起:

python 复制代码
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough

prompt = ChatPromptTemplate.from_messages([
    ("system", "基于以下上下文回答问题:\n{context}"),
    ("user", "{question}")
])

rag_chain = (
    {"context": retriever, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

让我们沿着数据流动的方向,逐段拆解这条链做了什么。RunnablePassthrough 在这里起到的作用是将用户的输入原封不动地"穿"过去,因为 retriever 本身也是一个 Runnable,当链的输入是一个字符串时,这个字符串会同时传给字典中的两个键:retriever 会用它执行检索并返回一组 Document 对象,而 RunnablePassthrough 则直接返回同一个字符串。最终构造出的字典包含了 context(检索到的文档内容列表)和 question(原始问题)两个字段。

管道的第二阶段是 prompt 模板。我们使用 ChatPromptTemplate.from_messages 构建了一个包含两条消息的模板,一条 system 消息承载检索到的上下文,指示模型"基于以下上下文回答问题",一条 user 消息承载用户的原始提问。把上下文放在 system 消息里而不是 user 消息里,这是 RAG 实践中一个被广泛认可的习惯做法,system 消息在语义上代表"环境设定"或"背景信息",而 user 消息保持用户的原始意图不被打扰,这样模型在理解指令时的角色边界更加清晰。

管道的第三阶段是大语言模型(代码中的 llm),它接收已经被填充好的消息列表,在上下文的辅助下进行推理并生成回答。最后,StrOutputParser 将模型的 AIMessage 输出对象转化为纯字符串,方便后续的展示或接口返回。

值得一提的是来源追踪。标准的 RAG 链路返回给用户的通常只有模型生成的最终答案,但很多时候用户(或系统管理员)想知道这个答案究竟是从哪几段原文中推断出来的。幸运的是,Retriever 返回的 Document 对象天然携带 metadata 字段,里面可以记录源文件路径、页码、chunk 编号等关键信息。你只需要在管道的第一步将检索结果"分流"出来,比如在字典中额外添加一个键 "source_documents": retriever 或者从 context 中提取 metadata 信息,就能在输出最终答案的同时附带引用来源。一些更进阶的做法还会将原文片段以引用注释的形式拼接在答案末尾,让用户一键溯源。

回顾今天所构建的整个系统,它的数据流清晰而紧凑。原始文档经过加载和切分变成 chunk,chunk 经过 embedding 变成向量并存入 Chroma,用户的查询同样被 embedding 后与库中的向量做相似度匹配,匹配到的 chunk 作为上下文注入提示词,最终由大语言模型生成有据可依的回答。这一整套流程,从离线索引到在线检索再到生成,构成了 RAG 应用的骨架,也是后续学习更复杂的 Agentic RAG 和多步推理检索的基础。

练习任务

  • 基于本课程提供的示例文档(week2/code/docs/ 目录下的 notes.mdreadme.txt),将 Day 2 的文档切分成果与今天的 embedding 和向量存储相结合,构建一个完整的 RAG 问答系统。
  • 在同一个问题上分别使用 similaritymmrsimilarity_score_threshold 三种检索策略,记录每种策略返回的 chunk 内容和顺序,形成一份对比表,并分析差异原因。
  • 在 RAG chain 的输出中附带引用来源追踪:至少包含每个引用 chunk 的源文件名和 chunk 序号(即切分后在列表中的索引位置),让答案具备可核查性。

考核点 ✅

  1. RAG 系统验收:提交完整可运行的 RAG chain 代码,能针对给定文档回答问题。要求至少包含文档加载、文本切分、向量嵌入、向量存储、检索、提示词构造、大模型调用和输出解析这八个环节。
  2. 策略对比:用同一个问题测试三种检索策略,输出检索结果的对比表格,并在表格中标注每条结果的相似度分数(如有)。口头或书面解释三种策略在不同场景下的适用性。
  3. 来源追踪:RAG 回答中附带原文引用来源,至少包含 chunk 编号和源文件名。如果 chunk 来自同一文件的不同位置,需在引用中加以区分。
  4. 向量维度:口头解释 embedding 模型向量维度对存储和检索性能的影响。具体包括:不同维度模型(384 / 768 / 1024 / 1536 / 3072)在存储空间占用、检索速度和语义精度方面的大致关系,以及 OpenAI text-embedding-3 系列的 Matryoshka 截断特性如何在实际工程中权衡这些因素。

遇到的问题

在运行上述代码时,很有可能会遇到如下报错:

shell 复制代码
 python .\embeding_chroma.py
D:\Project\learning\langchain\week2\code\embeding_chroma.py:2: DeprecationWarning: `langchain-community` is being sunset and is no longer actively maintained. See https://github.com/langchain-ai/langchain-community/issues/674 for details and migration guidance toward standalone integration packages.
  from langchain_community.document_loaders import    TextLoader
Traceback (most recent call last):
  File "D:\Project\learning\langchain\week2\code\embeding_chroma.py", line 23, in <module>
    vectorstore = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory="./chroma_db"
    )
  File "D:\Project\learning\langchain\week2\code\.venv\Lib\site-packages\langchain_community\vectorstores\chroma.py", line 887, in from_documents
    return cls.from_texts(
           ~~~~~~~~~~~~~~^
        texts=texts,
        ^^^^^^^^^^^^
    ...<8 lines>...
        **kwargs,
        ^^^^^^^^^
    )
    ^
  File "D:\Project\learning\langchain\week2\code\.venv\Lib\site-packages\langchain_community\vectorstores\chroma.py", line 843, in from_texts
    chroma_collection.add_texts(
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~^
        texts=batch[3] if batch[3] else [],
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        metadatas=batch[2] if batch[2] else None,
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        ids=batch[0],
        ^^^^^^^^^^^^^
    )
    ^
  File "D:\Project\learning\langchain\week2\code\.venv\Lib\site-packages\langchain_community\vectorstores\chroma.py", line 277, in add_texts
    embeddings = self._embedding_function.embed_documents(texts)
  File "D:\Project\learning\langchain\week2\code\.venv\Lib\site-packages\langchain_openai\embeddings\base.py", line 755, in embed_documents
    return self._get_len_safe_embeddings(
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^
        texts, engine=engine, chunk_size=chunk_size, **kwargs
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "D:\Project\learning\langchain\week2\code\.venv\Lib\site-packages\langchain_openai\embeddings\base.py", line 622, in _get_len_safe_embeddings
    response = self.client.create(input=batch_tokens, **client_kwargs)
  File "D:\Project\learning\langchain\week2\code\.venv\Lib\site-packages\openai\resources\embeddings.py", line 136, in create
    return self._post(
           ~~~~~~~~~~^
        "/embeddings",
        ^^^^^^^^^^^^^^
    ...<9 lines>...
        cast_to=CreateEmbeddingResponse,
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
  File "D:\Project\learning\langchain\week2\code\.venv\Lib\site-packages\openai\_base_client.py", line 1332, in post
    return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls))
                           ~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\Project\learning\langchain\week2\code\.venv\Lib\site-packages\openai\_base_client.py", line 1105, in request
    raise self._make_status_error_from_response(err.response) from None
openai.BadRequestError: Error code: 400 - {'code': 20015, 'message': 'The parameter is invalid. Please check again.', 'data': None}

这是因为 check_embedding_ctx_length 默认为 True,会让 OpenAIEmbeddings 使用 tiktoken 将文本转换成 token ID 数组发送给 API。但是 SiliconFlow 等非 OpenAI 服务商不支持 token 化输入,所以返回了 20015 "参数无效" 错误。

相关推荐
Tina学编程12 小时前
LangChain P1 | LangChain快速上手[MacOS]
langchain
颜酱15 小时前
LangChain 调用大模型实战:从跑通到服务商与模型选型
python·langchain
VipSoft16 小时前
LangChain 入门 Model 的初始化和调用
langchain
好家伙VCC16 小时前
Qdrant + LangChain 实战:构建毫秒级语义检索服务
java·langchain
wuhen_n18 小时前
AI Agent 入门:从零实现 LangChain 基础智能体
前端·langchain·ai编程
lhxcc_fly19 小时前
6.LangChain--RAG
langchain·llm·rag
wuhen_n19 小时前
LangChain 自定义 Tool 封装:打造专属 AI 能力工具集
前端·langchain·ai编程
lhxcc_fly20 小时前
6.1RAG--文档加载器
langchain·llm·rag
Irissgwe21 小时前
十、LangGraph能力详解(2)LangGraph入门教程,构建AI工作流
ai·langchain·graph·langgraph