06_RAG检索增强生成_从文档加载到向量检索

概述:为什么需要 RAG?

大模型很强,但它有几个天然限制:

  • 它不知道你的私有文档、企业制度、产品手册、内部知识库。
  • 它的训练知识有截止时间,不一定知道最新信息。
  • 它可能在不知道答案时编造一个看起来合理的答案。
  • 它的上下文窗口有限,不能无限塞入所有资料。
  • 它无法自动判断哪些资料和用户问题最相关。

RAG 就是为了解决这些问题出现的。

RAG 全称 Retrieval-Augmented Generation,中文通常叫检索增强生成。

它的核心思想很简单:

先从外部知识库中检索出和问题相关的资料,再把这些资料连同问题一起交给大模型生成答案。

一个典型 RAG 流程如下:
#mermaid-svg-xHIXHhBP6r0s5Nto{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xHIXHhBP6r0s5Nto .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xHIXHhBP6r0s5Nto .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xHIXHhBP6r0s5Nto .error-icon{fill:#552222;}#mermaid-svg-xHIXHhBP6r0s5Nto .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xHIXHhBP6r0s5Nto .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xHIXHhBP6r0s5Nto .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xHIXHhBP6r0s5Nto .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xHIXHhBP6r0s5Nto .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xHIXHhBP6r0s5Nto .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xHIXHhBP6r0s5Nto .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xHIXHhBP6r0s5Nto .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xHIXHhBP6r0s5Nto .marker.cross{stroke:#333333;}#mermaid-svg-xHIXHhBP6r0s5Nto svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xHIXHhBP6r0s5Nto p{margin:0;}#mermaid-svg-xHIXHhBP6r0s5Nto .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-xHIXHhBP6r0s5Nto .cluster-label text{fill:#333;}#mermaid-svg-xHIXHhBP6r0s5Nto .cluster-label span{color:#333;}#mermaid-svg-xHIXHhBP6r0s5Nto .cluster-label span p{background-color:transparent;}#mermaid-svg-xHIXHhBP6r0s5Nto .label text,#mermaid-svg-xHIXHhBP6r0s5Nto span{fill:#333;color:#333;}#mermaid-svg-xHIXHhBP6r0s5Nto .node rect,#mermaid-svg-xHIXHhBP6r0s5Nto .node circle,#mermaid-svg-xHIXHhBP6r0s5Nto .node ellipse,#mermaid-svg-xHIXHhBP6r0s5Nto .node polygon,#mermaid-svg-xHIXHhBP6r0s5Nto .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xHIXHhBP6r0s5Nto .rough-node .label text,#mermaid-svg-xHIXHhBP6r0s5Nto .node .label text,#mermaid-svg-xHIXHhBP6r0s5Nto .image-shape .label,#mermaid-svg-xHIXHhBP6r0s5Nto .icon-shape .label{text-anchor:middle;}#mermaid-svg-xHIXHhBP6r0s5Nto .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-xHIXHhBP6r0s5Nto .rough-node .label,#mermaid-svg-xHIXHhBP6r0s5Nto .node .label,#mermaid-svg-xHIXHhBP6r0s5Nto .image-shape .label,#mermaid-svg-xHIXHhBP6r0s5Nto .icon-shape .label{text-align:center;}#mermaid-svg-xHIXHhBP6r0s5Nto .node.clickable{cursor:pointer;}#mermaid-svg-xHIXHhBP6r0s5Nto .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-xHIXHhBP6r0s5Nto .arrowheadPath{fill:#333333;}#mermaid-svg-xHIXHhBP6r0s5Nto .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-xHIXHhBP6r0s5Nto .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-xHIXHhBP6r0s5Nto .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xHIXHhBP6r0s5Nto .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xHIXHhBP6r0s5Nto .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xHIXHhBP6r0s5Nto .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-xHIXHhBP6r0s5Nto .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-xHIXHhBP6r0s5Nto .cluster text{fill:#333;}#mermaid-svg-xHIXHhBP6r0s5Nto .cluster span{color:#333;}#mermaid-svg-xHIXHhBP6r0s5Nto div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-xHIXHhBP6r0s5Nto .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xHIXHhBP6r0s5Nto rect.text{fill:none;stroke-width:0;}#mermaid-svg-xHIXHhBP6r0s5Nto .icon-shape,#mermaid-svg-xHIXHhBP6r0s5Nto .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xHIXHhBP6r0s5Nto .icon-shape p,#mermaid-svg-xHIXHhBP6r0s5Nto .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-xHIXHhBP6r0s5Nto .icon-shape .label rect,#mermaid-svg-xHIXHhBP6r0s5Nto .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xHIXHhBP6r0s5Nto .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-xHIXHhBP6r0s5Nto .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-xHIXHhBP6r0s5Nto :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 原始文档
文档加载
文本切分
Embedding 向量化
向量数据库
用户问题
向量检索
拼接上下文
LLM 生成答案

RAG 让模型从"只靠训练记忆回答",变成"带着外部资料回答"。

RAG 解决的不是"模型更聪明",而是"上下文更可靠"

很多初学者会误解 RAG,以为加了向量数据库,模型就一定不会胡说。

这不准确。

RAG 的价值主要在三个方面:

价值一:补充私有知识

企业内部制度、产品文档、项目 Wiki、合同条款、客服 FAQ,这些内容通常不在模型训练数据里。

RAG 可以把这些文档作为外部知识源,让模型回答时参考它们。

价值二:减少幻觉

如果 Prompt 明确要求"只能根据资料回答",并且检索结果确实包含答案,模型胡编的概率会降低。

但 RAG 不能保证绝对不幻觉。因为模型仍然可能误读资料、过度推断、忽略限制。

价值三:答案可溯源

如果保留文档来源、页码、标题、URL 等 metadata,就可以在答案里展示引用依据。

这对企业知识库、法律、医疗、金融、研发文档问答尤其重要。

关键点:RAG 的可靠性不只取决于模型,还取决于文档解析、切分、Embedding、检索、重排、Prompt 和评估。

LangChain 中 RAG 的核心模块

LangChain 官方文档中,RAG 相关能力通常围绕这几类模块展开:

模块 作用
Document Loader 从文件、网页、数据库等来源加载文档
Text Splitter 把长文档切成适合检索的小块
Embedding Model 把文本转换成向量
Vector Store 存储向量并支持相似度检索
Retriever 根据问题检索相关文档片段
Prompt 把问题和检索结果组织成模型上下文
Chat Model 根据上下文生成答案

这些模块组合起来,就是一个最小 RAG 系统。

text 复制代码
Document Loader
    -> Text Splitter
    -> Embedding Model
    -> Vector Store
    -> Retriever
    -> Prompt
    -> Chat Model
    -> Answer

本文会用一个 Markdown 文档问答示例,从零搭建一条完整 RAG 链。

准备环境:安装依赖

本篇使用:

  • langchain:LangChain 主包。
  • langchain-openai:OpenAI 模型和 Embedding 集成。
  • langchain-chroma:Chroma 向量数据库集成。
  • langchain-text-splitters:文本切分器。
  • python-dotenv:读取 .env

安装:

bash 复制代码
pip install -U langchain langchain-openai langchain-chroma langchain-text-splitters python-dotenv

如果你还没有 OpenAI API Key,需要在项目根目录创建 .env

bash 复制代码
OPENAI_API_KEY=sk-xxxx

如果你用的是 OpenAI 兼容服务或其他 Embedding 模型,需要按对应供应商文档配置 base_url、api_key 和模型名。本文为了减少干扰,先用 OpenAI 官方集成演示。

项目结构:先做一个最小 RAG Demo

建议项目结构:

text 复制代码
rag-demo/
  .env
  main.py
  docs/
    langchain_intro.md
  chroma_db/

其中:

  • .env:保存 API Key。
  • main.py:RAG 主程序。
  • docs/langchain_intro.md:测试用文档。
  • chroma_db/:Chroma 持久化目录,程序运行后生成。

创建测试文档 docs/langchain_intro.md

markdown 复制代码
# LangChain 简介

LangChain 是一个用于构建 LLM 应用的开发框架。它提供模型调用、Prompt 模板、RAG、工具调用、Agent、Memory 和可观测能力。

LCEL 是 LangChain Expression Language 的缩写,用于把 Prompt、模型、解析器、检索器等组件组合成可执行链。

RAG 是 Retrieval-Augmented Generation 的缩写,中文叫检索增强生成。它会先从外部知识库中检索相关资料,再让大模型基于资料回答问题。

LangGraph 是 LangChain 生态中的状态图引擎,适合构建复杂 Agent、多步骤工作流和需要持久化状态的应用。

这个文档很短,但足够跑通流程。

第一步:加载文档

LangChain 中,加载后的文档通常用 Document 表示。

一个 Document 主要包含两部分:

  • page_content:正文内容。
  • metadata:元数据,例如文件路径、页码、URL、标题等。

先用最简单的方式加载 Markdown 文件。

python 复制代码
from langchain_community.document_loaders import TextLoader

loader = TextLoader(
    "docs/langchain_intro.md",
    encoding="utf-8",
)

documents = loader.load()

print(len(documents))
print(documents[0].page_content[:100])
print(documents[0].metadata)

如果你没有安装 langchain-community,可以安装:

bash 复制代码
pip install -U langchain-community

TextLoader 会把整个文件加载成一个或多个 Document

输出大概类似:

python 复制代码
1
# LangChain 简介
{'source': 'docs/langchain_intro.md'}

为什么 metadata 很重要?

很多人做 RAG 只关注文本,不保留来源。

这是一个大坑。

如果没有 metadata,后面就很难回答:

  • 答案来自哪个文件?
  • 来自第几页?
  • 来自哪个 URL?
  • 引用的是哪个章节?
  • 这段资料是什么时候更新的?

企业知识库问答里,引用来源往往和答案本身一样重要。

RAG 不只要存文本,还要保留可追溯的 metadata。

第二步:切分文本

不要直接把整篇文档丢进向量数据库。

原因有三个:

  • 文档太长时,Embedding 效果会变差。
  • 检索时粒度太粗,会召回大量无关内容。
  • 后续塞进模型上下文时,会浪费 token。

所以需要 Text Splitter。

LangChain 常用 RecursiveCharacterTextSplitter

python 复制代码
from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=300,
    chunk_overlap=50,
)

chunks = text_splitter.split_documents(documents)

print(len(chunks))
print(chunks[0].page_content)
print(chunks[0].metadata)

参数解释:

  • chunk_size:每个文本块的目标长度。
  • chunk_overlap:相邻文本块之间保留多少重叠内容。

为什么需要 overlap?

假设原文有一段:

text 复制代码
LangChain 提供 LCEL。LCEL 可以把 Prompt、模型、解析器组合成链。

如果切分刚好把这段从中间切开,可能变成:

text 复制代码
LangChain 提供 LCEL。

和:

text 复制代码
LCEL 可以把 Prompt、模型、解析器组合成链。

单个 chunk 上下文就不完整。

chunk_overlap 的作用是让相邻 chunk 有一部分重叠,降低关键信息被切断的概率。

但 overlap 也不是越大越好。

参数 太小的问题 太大的问题
chunk_size 语义不完整,检索碎片化 粒度太粗,召回噪声多
chunk_overlap 上下文断裂 冗余多,存储和检索成本上升

初学阶段可以从:

python 复制代码
chunk_size=500
chunk_overlap=100

开始试,再根据实际文档调整。

第三步:创建 Embedding 模型

向量检索的核心是 Embedding。

Embedding 模型会把文本转换成向量:

text 复制代码
"LangChain 是 LLM 应用框架" -> [0.012, -0.034, 0.128, ...]

语义相近的文本,在向量空间中距离更近。

使用 OpenAI Embedding:

python 复制代码
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(
    model="text-embedding-3-small",
)

text-embedding-3-small 成本相对较低,适合入门和很多普通检索场景。更高质量或更复杂的场景,可以选择更强的 embedding 模型。

Embedding 模型选择要注意什么?

主要看这些指标:

  • 中文效果。
  • 英文效果。
  • 领域文本效果。
  • 向量维度。
  • 价格。
  • 延迟。
  • 最大输入长度。
  • 是否支持本地部署。

不要只看模型名字,也不要只用一个简单问题判断效果。

真正生产环境要用你的业务文档和真实问题做评估。

第四步:写入 Chroma 向量数据库

Chroma 是一个常用的向量数据库,适合本地开发和小型原型。

导入:

python 复制代码
from langchain_chroma import Chroma

把 chunks 写入 Chroma:

python 复制代码
vector_store = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db",
    collection_name="langchain_intro",
)

这里发生了几件事:

  1. 遍历每个 chunk。
  2. 调用 Embedding 模型生成向量。
  3. 把向量、文本、metadata 写入 Chroma。
  4. 持久化到 ./chroma_db 目录。

Chroma.from_documents 适合 Demo,不一定适合生产

from_documents 很方便,但生产环境通常需要更完整的索引流程:

  • 文档去重。
  • 文档 ID 管理。
  • 增量更新。
  • 删除过期文档。
  • 版本管理。
  • 分租户隔离。
  • 失败重试。
  • 批量写入。
  • 索引构建日志。

本文先用最小方式跑通。后续项目篇会再讲企业知识库的完整索引策略。

第五步:创建 Retriever

向量库负责存储和相似度搜索。

Retriever 是更上层的检索接口。

python 复制代码
retriever = vector_store.as_retriever(
    search_kwargs={"k": 3}
)

k=3 表示每次检索返回最相关的 3 个 chunk。

测试检索:

python 复制代码
docs = retriever.invoke("LCEL 是什么?")

for i, doc in enumerate(docs, 1):
    print(f"--- Document {i} ---")
    print(doc.page_content)
    print(doc.metadata)

如果检索正常,你应该能看到包含 LCEL 解释的文档片段。

k 应该设多少?

没有固定答案。

k 值 特点
太小 可能漏掉答案
太大 噪声多,占用上下文 token,模型更容易分心

入门可以从 k=3k=5 开始。

生产环境要用评估集测:

  • top-1 是否命中。
  • top-3 是否命中。
  • top-5 是否命中。
  • 检索结果是否包含足够答案依据。
  • 噪声文档是否干扰生成。

第六步:把检索结果交给模型回答

现在我们已经能检索文档了,下一步是生成答案。

先写 Prompt:

python 复制代码
from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    (
        "system",
        "你是一个知识库问答助手。"
        "你只能根据提供的资料回答问题。"
        "如果资料中没有答案,请回答:根据当前资料无法确定。"
        "不要编造资料中不存在的信息。"
    ),
    (
        "human",
        "资料:\n{context}\n\n"
        "问题:{question}"
    ),
])

这里最关键的是系统提示词:

text 复制代码
只能根据提供的资料回答。
资料中没有答案就说无法确定。
不要编造不存在的信息。

然后写一个格式化函数,把检索到的 Document 列表拼成字符串:

python 复制代码
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

组合 RAG 链:

python 复制代码
from langchain.chat_models import init_chat_model
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

model = init_chat_model(
    "gpt-4o-mini",
    model_provider="openai",
    temperature=0,
)

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

answer = rag_chain.invoke("LCEL 是什么?")

print(answer)

数据流:
#mermaid-svg-gOmzbAoK6sinr7RA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-gOmzbAoK6sinr7RA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gOmzbAoK6sinr7RA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gOmzbAoK6sinr7RA .error-icon{fill:#552222;}#mermaid-svg-gOmzbAoK6sinr7RA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gOmzbAoK6sinr7RA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gOmzbAoK6sinr7RA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gOmzbAoK6sinr7RA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gOmzbAoK6sinr7RA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gOmzbAoK6sinr7RA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gOmzbAoK6sinr7RA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gOmzbAoK6sinr7RA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gOmzbAoK6sinr7RA .marker.cross{stroke:#333333;}#mermaid-svg-gOmzbAoK6sinr7RA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gOmzbAoK6sinr7RA p{margin:0;}#mermaid-svg-gOmzbAoK6sinr7RA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-gOmzbAoK6sinr7RA .cluster-label text{fill:#333;}#mermaid-svg-gOmzbAoK6sinr7RA .cluster-label span{color:#333;}#mermaid-svg-gOmzbAoK6sinr7RA .cluster-label span p{background-color:transparent;}#mermaid-svg-gOmzbAoK6sinr7RA .label text,#mermaid-svg-gOmzbAoK6sinr7RA span{fill:#333;color:#333;}#mermaid-svg-gOmzbAoK6sinr7RA .node rect,#mermaid-svg-gOmzbAoK6sinr7RA .node circle,#mermaid-svg-gOmzbAoK6sinr7RA .node ellipse,#mermaid-svg-gOmzbAoK6sinr7RA .node polygon,#mermaid-svg-gOmzbAoK6sinr7RA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-gOmzbAoK6sinr7RA .rough-node .label text,#mermaid-svg-gOmzbAoK6sinr7RA .node .label text,#mermaid-svg-gOmzbAoK6sinr7RA .image-shape .label,#mermaid-svg-gOmzbAoK6sinr7RA .icon-shape .label{text-anchor:middle;}#mermaid-svg-gOmzbAoK6sinr7RA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-gOmzbAoK6sinr7RA .rough-node .label,#mermaid-svg-gOmzbAoK6sinr7RA .node .label,#mermaid-svg-gOmzbAoK6sinr7RA .image-shape .label,#mermaid-svg-gOmzbAoK6sinr7RA .icon-shape .label{text-align:center;}#mermaid-svg-gOmzbAoK6sinr7RA .node.clickable{cursor:pointer;}#mermaid-svg-gOmzbAoK6sinr7RA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-gOmzbAoK6sinr7RA .arrowheadPath{fill:#333333;}#mermaid-svg-gOmzbAoK6sinr7RA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-gOmzbAoK6sinr7RA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-gOmzbAoK6sinr7RA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gOmzbAoK6sinr7RA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-gOmzbAoK6sinr7RA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gOmzbAoK6sinr7RA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-gOmzbAoK6sinr7RA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-gOmzbAoK6sinr7RA .cluster text{fill:#333;}#mermaid-svg-gOmzbAoK6sinr7RA .cluster span{color:#333;}#mermaid-svg-gOmzbAoK6sinr7RA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-gOmzbAoK6sinr7RA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-gOmzbAoK6sinr7RA rect.text{fill:none;stroke-width:0;}#mermaid-svg-gOmzbAoK6sinr7RA .icon-shape,#mermaid-svg-gOmzbAoK6sinr7RA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gOmzbAoK6sinr7RA .icon-shape p,#mermaid-svg-gOmzbAoK6sinr7RA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-gOmzbAoK6sinr7RA .icon-shape .label rect,#mermaid-svg-gOmzbAoK6sinr7RA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gOmzbAoK6sinr7RA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-gOmzbAoK6sinr7RA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-gOmzbAoK6sinr7RA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户问题
Retriever
RunnablePassthrough
Document 列表
format_docs
context
question
Prompt
ChatModel
StrOutputParser
答案

这就是一个完整的最小 RAG 链。

完整代码:从加载文档到生成答案

下面给出完整 main.py

python 复制代码
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_community.document_loaders import TextLoader
from langchain_chroma import Chroma
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter

load_dotenv()


def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)


loader = TextLoader(
    "docs/langchain_intro.md",
    encoding="utf-8",
)
documents = loader.load()

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=300,
    chunk_overlap=50,
)
chunks = text_splitter.split_documents(documents)

embeddings = OpenAIEmbeddings(
    model="text-embedding-3-small",
)

vector_store = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db",
    collection_name="langchain_intro",
)

retriever = vector_store.as_retriever(
    search_kwargs={"k": 3}
)

model = init_chat_model(
    "gpt-4o-mini",
    model_provider="openai",
    temperature=0,
)

prompt = ChatPromptTemplate.from_messages([
    (
        "system",
        "你是一个知识库问答助手。"
        "你只能根据提供的资料回答问题。"
        "如果资料中没有答案,请回答:根据当前资料无法确定。"
        "不要编造资料中不存在的信息。"
    ),
    (
        "human",
        "资料:\n{context}\n\n"
        "问题:{question}"
    ),
])

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

question = "LCEL 是什么?"
answer = rag_chain.invoke(question)

print("问题:", question)
print("答案:", answer)

运行:

bash 复制代码
python main.py

如果一切正常,你会看到模型根据文档内容解释 LCEL。

改进一:显示引用来源

上面的版本能回答,但没有显示引用来源。

我们可以让链返回更多信息:

  • answer:模型答案。
  • sources:检索到的来源。

先单独检索:

python 复制代码
question = "LCEL 是什么?"
retrieved_docs = retriever.invoke(question)

for doc in retrieved_docs:
    print(doc.metadata)

然后生成答案:

python 复制代码
answer = rag_chain.invoke(question)

sources = [
    doc.metadata.get("source", "unknown")
    for doc in retrieved_docs
]

print("答案:", answer)
print("来源:", sources)

更完整一点,可以把来源去重:

python 复制代码
sources = sorted({
    doc.metadata.get("source", "unknown")
    for doc in retrieved_docs
})

输出:

text 复制代码
来源:
- docs/langchain_intro.md

真实项目中,metadata 里最好保留:

  • 文件 ID。
  • 文件名。
  • 页码。
  • 章节标题。
  • URL。
  • 文档更新时间。
  • chunk ID。

这样用户才能判断答案依据是否可信。

改进二:让答案带引用编号

如果想让答案中出现引用编号,可以把文档格式化成带编号的上下文。

python 复制代码
def format_docs_with_sources(docs):
    formatted = []

    for i, doc in enumerate(docs, 1):
        source = doc.metadata.get("source", "unknown")
        formatted.append(
            f"[{i}] 来源:{source}\n{doc.page_content}"
        )

    return "\n\n".join(formatted)

Prompt 改成:

python 复制代码
prompt = ChatPromptTemplate.from_messages([
    (
        "system",
        "你是一个知识库问答助手。"
        "你只能根据提供的资料回答问题。"
        "回答中需要用 [1]、[2] 这样的编号标注依据。"
        "如果资料中没有答案,请回答:根据当前资料无法确定。"
    ),
    (
        "human",
        "资料:\n{context}\n\n"
        "问题:{question}"
    ),
])

链改成:

python 复制代码
rag_chain = (
    {
        "context": retriever | format_docs_with_sources,
        "question": RunnablePassthrough(),
    }
    | prompt
    | model
    | StrOutputParser()
)

注意,这种引用编号依赖模型遵守 Prompt。更严格的引用系统通常会在后端保留检索结果,并把答案里的引用和真实文档 ID 做绑定,而不是完全相信模型自己生成引用。

改进三:避免每次运行都重复建库

上面的代码每次运行都会重新加载、切分、Embedding、写入 Chroma。

这在 Demo 里没问题,但真实项目不合适。

可以把"建索引"和"问答"拆成两个脚本。

ingest.py:负责建索引

python 复制代码
from dotenv import load_dotenv
from langchain_community.document_loaders import TextLoader
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter

load_dotenv()

loader = TextLoader(
    "docs/langchain_intro.md",
    encoding="utf-8",
)
documents = loader.load()

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=300,
    chunk_overlap=50,
)
chunks = text_splitter.split_documents(documents)

embeddings = OpenAIEmbeddings(
    model="text-embedding-3-small",
)

Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db",
    collection_name="langchain_intro",
)

print(f"Indexed {len(chunks)} chunks.")

ask.py:负责问答

python 复制代码
from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
from langchain_chroma import Chroma
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_openai import OpenAIEmbeddings

load_dotenv()


def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)


embeddings = OpenAIEmbeddings(
    model="text-embedding-3-small",
)

vector_store = Chroma(
    persist_directory="./chroma_db",
    collection_name="langchain_intro",
    embedding_function=embeddings,
)

retriever = vector_store.as_retriever(
    search_kwargs={"k": 3}
)

model = init_chat_model(
    "gpt-4o-mini",
    model_provider="openai",
    temperature=0,
)

prompt = ChatPromptTemplate.from_messages([
    (
        "system",
        "你是一个知识库问答助手。只能根据资料回答。"
        "如果资料中没有答案,请回答:根据当前资料无法确定。"
    ),
    ("human", "资料:\n{context}\n\n问题:{question}"),
])

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

question = "LangGraph 适合什么场景?"
print(rag_chain.invoke(question))

运行顺序:

bash 复制代码
python ingest.py
python ask.py

建索引和在线问答是两条不同链路,生产环境不要混在一次请求里。

文档加载:不只是 TextLoader

本文用的是 TextLoader,因为它最简单。

真实项目中可能需要加载:

  • Markdown。
  • PDF。
  • Word。
  • HTML。
  • CSV。
  • Notion。
  • Confluence。
  • Git 仓库。
  • 数据库记录。
  • 对象存储文件。

不同来源的文档解析质量差异很大。

例如 PDF:

  • 页眉页脚可能被误识别。
  • 表格可能被拆乱。
  • 多栏排版可能顺序错乱。
  • 扫描件需要 OCR。
  • 页码、标题、章节需要额外提取。

很多 RAG 系统效果差,不是向量数据库的问题,而是文档解析阶段已经把内容搞乱了。

RAG 的上限,很大程度取决于文档解析质量。

文本切分:chunk_size 不是随便填

切分策略会直接影响召回效果。

常见策略有:

策略 特点
固定字符切分 简单,但可能破坏语义
递归字符切分 尽量按段落、句子等边界切分
Markdown 标题切分 适合结构化 Markdown 文档
语义切分 根据语义相似度切分,成本更高
代码切分 按函数、类、文件结构切分

RecursiveCharacterTextSplitter 的优势是通用、简单、够用。

但不同文档应该用不同策略:

  • FAQ:一问一答作为 chunk。
  • Markdown 文档:按标题层级切分。
  • API 文档:按接口或函数切分。
  • 法律合同:按条款切分。
  • 代码仓库:按文件、类、函数切分。

不要一套 chunk_size=1000 打天下。

Embedding:向量质量决定召回上限

Embedding 模型会决定哪些文本被认为语义相近。

例如用户问:

text 复制代码
如何申请住宿报销?

文档里写的是:

text 复制代码
差旅住宿费用报销需提交发票和行程单。

关键词不完全一致,但语义接近。好的 Embedding 模型应该能把它们拉近。

Embedding 选型建议:

  • 中文业务要测试中文语义效果。
  • 代码业务要测试代码和自然语言对齐效果。
  • 法律、医疗、金融等领域要测试专业术语。
  • 长文档要注意 embedding 模型最大输入长度。
  • 生产环境要关注价格和吞吐量。

如果 Embedding 模型效果差,后面的 LLM 再强也很难回答正确,因为它拿不到正确资料。

向量检索:相似不等于正确

向量检索返回的是"语义相似"的片段,不一定就是"能回答问题"的片段。

例如问题:

text 复制代码
如何取消订单?

可能检索到:

text 复制代码
订单创建后会进入待支付状态。

它和订单相关,但不能回答取消流程。

所以生产 RAG 常见改进包括:

  • 提高 k 后再重排。
  • 加关键词检索,做混合检索。
  • 使用 reranker 对候选文档重新排序。
  • 根据 metadata 过滤文档范围。
  • 根据用户权限过滤可见文档。
  • 根据时间、版本、业务线做过滤。

最小 Demo 用相似度检索就够了,但生产系统通常要做更多检索工程。

Prompt:让模型知道资料的边界

RAG Prompt 至少要说清楚:

  • 只能根据资料回答。
  • 资料不足时如何回答。
  • 是否需要引用来源。
  • 输出格式是什么。
  • 不要编造资料之外的信息。

一个常用模板:

python 复制代码
prompt = ChatPromptTemplate.from_messages([
    (
        "system",
        "你是一个知识库问答助手。"
        "你只能根据提供的资料回答问题。"
        "如果资料中没有答案,请回答:根据当前资料无法确定。"
        "不要使用资料以外的知识补充事实。"
    ),
    (
        "human",
        "资料:\n{context}\n\n"
        "问题:{question}\n\n"
        "请给出简洁答案,并列出依据。"
    ),
])

注意最后一句:

text 复制代码
不要使用资料以外的知识补充事实。

这对企业知识库很重要。用户问制度、报销、权限、合同条款时,模型不能凭常识补充。

常见错误一:把所有文档都塞进 Prompt

错误做法:

python 复制代码
context = "\n\n".join(all_documents)
answer = model.invoke(f"资料:{context}\n问题:{question}")

这会导致:

  • token 成本暴涨。
  • 延迟变高。
  • 上下文超长。
  • 无关内容干扰模型。
  • 关键答案被埋在大量噪声里。

RAG 的核心就是"先检索相关内容",不是"把所有内容都塞给模型"。

常见错误二:chunk 太大或太小

chunk 太小:

  • 语义不完整。
  • 检索结果像碎片。
  • 模型缺少上下文。

chunk 太大:

  • 检索粒度粗。
  • 一个 chunk 里混入多个主题。
  • 召回结果噪声高。
  • 占用更多上下文。

建议用真实问题测试不同参数:

python 复制代码
chunk_size=300, chunk_overlap=50
chunk_size=500, chunk_overlap=100
chunk_size=1000, chunk_overlap=150

看哪个配置的 top-k 命中率更好。

常见错误三:不保留 source

很多 Demo 里只保存文本,不保存来源。

上线后用户问:

text 复制代码
这个答案依据哪份文档?

系统答不上来。

建议每个 chunk 至少保留:

python 复制代码
metadata = {
    "source": "docs/employee_handbook.md",
    "title": "员工手册",
    "section": "差旅报销",
    "updated_at": "2026-06-01",
}

没有来源的 RAG,很难建立用户信任。

常见错误四:不做权限过滤

企业知识库里,不同用户能看的文档不一样。

如果检索阶段不做权限过滤,可能出现严重数据泄露:

  • 普通员工问到管理层文档。
  • A 项目成员检索到 B 项目资料。
  • 外部客户看到内部 SOP。
  • 离职员工仍能访问历史索引。

正确做法是在检索前或检索时加入权限约束:

python 复制代码
retriever = vector_store.as_retriever(
    search_kwargs={
        "k": 5,
        "filter": {
            "department": "engineering",
        },
    }
)

具体 filter 语法取决于向量数据库支持能力。不要只靠 Prompt 告诉模型"不要泄露"。

常见错误五:只看生成答案,不看检索结果

调 RAG 时,一定要先看检索结果。

python 复制代码
docs = retriever.invoke(question)

for i, doc in enumerate(docs, 1):
    print(f"--- {i} ---")
    print(doc.page_content)
    print(doc.metadata)

如果检索结果里根本没有答案,模型回答错就不奇怪。

RAG 排查顺序建议:

  1. 用户问题是否清楚。
  2. 检索结果是否命中正确资料。
  3. chunk 是否包含完整上下文。
  4. Prompt 是否正确约束模型。
  5. 模型是否正确理解资料。
  6. 输出格式是否符合要求。

不要一上来就换大模型。很多时候问题在检索,而不是生成。

RAG 效果评估:至少看两层指标

RAG 评估至少分两层:

第一层:检索评估

关注问题:

  • 正确文档是否被召回?
  • top-1 是否命中?
  • top-3 是否命中?
  • top-5 是否命中?
  • 检索结果噪声大不大?
  • metadata 过滤是否生效?

第二层:生成评估

关注问题:

  • 答案是否正确。
  • 是否基于检索资料。
  • 是否编造资料外信息。
  • 是否引用了来源。
  • 不知道时是否说不知道。
  • 输出格式是否稳定。

最小评估集可以是一个表格:

question expected_source expected_answer
LCEL 是什么? langchain_intro.md LCEL 是 LangChain Expression Language
LangGraph 适合什么场景? langchain_intro.md 复杂 Agent、多步骤工作流、持久状态

不要只凭几次手动提问判断 RAG 系统好不好。

生产级 RAG 还缺什么?

本文只是最小 Demo。

生产级 RAG 通常还要补充:

  • 多格式文档解析。
  • 文档增量更新。
  • 文档删除和索引同步。
  • 权限过滤。
  • chunk ID 和文档版本管理。
  • 混合检索。
  • rerank 重排。
  • 查询改写。
  • 多轮对话问题压缩。
  • 引用来源展示。
  • 答案质量评估。
  • 检索日志和失败样本分析。
  • 成本、延迟和 token 监控。

所以不要把"跑通 RAG Demo"等同于"做完知识库系统"。

Demo 解决的是流程理解。生产系统要解决的是数据治理、权限、安全、可观测和持续评估。

完整流程回顾:RAG 的每一步都可能影响效果

再回到最开始的流程图:
#mermaid-svg-xWrkXcppAJJDbkZ9{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xWrkXcppAJJDbkZ9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xWrkXcppAJJDbkZ9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xWrkXcppAJJDbkZ9 .error-icon{fill:#552222;}#mermaid-svg-xWrkXcppAJJDbkZ9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xWrkXcppAJJDbkZ9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xWrkXcppAJJDbkZ9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xWrkXcppAJJDbkZ9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xWrkXcppAJJDbkZ9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xWrkXcppAJJDbkZ9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xWrkXcppAJJDbkZ9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xWrkXcppAJJDbkZ9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xWrkXcppAJJDbkZ9 .marker.cross{stroke:#333333;}#mermaid-svg-xWrkXcppAJJDbkZ9 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xWrkXcppAJJDbkZ9 p{margin:0;}#mermaid-svg-xWrkXcppAJJDbkZ9 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-xWrkXcppAJJDbkZ9 .cluster-label text{fill:#333;}#mermaid-svg-xWrkXcppAJJDbkZ9 .cluster-label span{color:#333;}#mermaid-svg-xWrkXcppAJJDbkZ9 .cluster-label span p{background-color:transparent;}#mermaid-svg-xWrkXcppAJJDbkZ9 .label text,#mermaid-svg-xWrkXcppAJJDbkZ9 span{fill:#333;color:#333;}#mermaid-svg-xWrkXcppAJJDbkZ9 .node rect,#mermaid-svg-xWrkXcppAJJDbkZ9 .node circle,#mermaid-svg-xWrkXcppAJJDbkZ9 .node ellipse,#mermaid-svg-xWrkXcppAJJDbkZ9 .node polygon,#mermaid-svg-xWrkXcppAJJDbkZ9 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xWrkXcppAJJDbkZ9 .rough-node .label text,#mermaid-svg-xWrkXcppAJJDbkZ9 .node .label text,#mermaid-svg-xWrkXcppAJJDbkZ9 .image-shape .label,#mermaid-svg-xWrkXcppAJJDbkZ9 .icon-shape .label{text-anchor:middle;}#mermaid-svg-xWrkXcppAJJDbkZ9 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-xWrkXcppAJJDbkZ9 .rough-node .label,#mermaid-svg-xWrkXcppAJJDbkZ9 .node .label,#mermaid-svg-xWrkXcppAJJDbkZ9 .image-shape .label,#mermaid-svg-xWrkXcppAJJDbkZ9 .icon-shape .label{text-align:center;}#mermaid-svg-xWrkXcppAJJDbkZ9 .node.clickable{cursor:pointer;}#mermaid-svg-xWrkXcppAJJDbkZ9 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-xWrkXcppAJJDbkZ9 .arrowheadPath{fill:#333333;}#mermaid-svg-xWrkXcppAJJDbkZ9 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-xWrkXcppAJJDbkZ9 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-xWrkXcppAJJDbkZ9 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xWrkXcppAJJDbkZ9 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xWrkXcppAJJDbkZ9 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xWrkXcppAJJDbkZ9 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-xWrkXcppAJJDbkZ9 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-xWrkXcppAJJDbkZ9 .cluster text{fill:#333;}#mermaid-svg-xWrkXcppAJJDbkZ9 .cluster span{color:#333;}#mermaid-svg-xWrkXcppAJJDbkZ9 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-xWrkXcppAJJDbkZ9 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xWrkXcppAJJDbkZ9 rect.text{fill:none;stroke-width:0;}#mermaid-svg-xWrkXcppAJJDbkZ9 .icon-shape,#mermaid-svg-xWrkXcppAJJDbkZ9 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xWrkXcppAJJDbkZ9 .icon-shape p,#mermaid-svg-xWrkXcppAJJDbkZ9 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-xWrkXcppAJJDbkZ9 .icon-shape .label rect,#mermaid-svg-xWrkXcppAJJDbkZ9 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xWrkXcppAJJDbkZ9 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-xWrkXcppAJJDbkZ9 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-xWrkXcppAJJDbkZ9 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 文档
加载 Document Loader
切分 Text Splitter
向量化 Embedding
写入 Vector Store
用户问题
Retriever 检索
格式化 context
Prompt
Chat Model
答案

每一步都有坑:

  • 文档加载错,后面全错。
  • 切分不好,答案上下文不完整。
  • Embedding 不适合领域,召回差。
  • 向量库没保留 metadata,无法溯源。
  • Retriever 的 k 不合理,要么漏召回,要么噪声多。
  • Prompt 没约束好,模型容易编造。
  • 评估集缺失,系统好坏只能靠感觉。

RAG 是一条工程链路,不是一个向量数据库组件。

总结

如果只记住一句话:

RAG 的核心流程是:加载文档、切分文本、生成向量、写入向量库、检索相关片段、把片段和问题交给模型生成答案。

再具体一点:

  • Document Loader 负责把外部资料加载成 Document
  • Document 包含 page_contentmetadata
  • Text Splitter 负责把长文档切成适合检索的 chunk。
  • chunk_sizechunk_overlap 会直接影响检索效果。
  • Embedding Model 把文本转换成向量。
  • Vector Store 存储向量、文本和 metadata。
  • Retriever 根据用户问题召回相关文档片段。
  • RunnablePassthrough 可以在 LCEL 中保留原始问题。
  • retriever | format_docs 可以把检索结果格式化成 Prompt 上下文。
  • RAG Prompt 要明确资料边界,不知道时要说不知道。
  • 调 RAG 时先看检索结果,再看模型答案。
  • 生产级 RAG 必须考虑权限、引用、增量更新、评估和可观测。