概述:为什么需要 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",
)
这里发生了几件事:
- 遍历每个 chunk。
- 调用 Embedding 模型生成向量。
- 把向量、文本、metadata 写入 Chroma。
- 持久化到
./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=3 或 k=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 排查顺序建议:
- 用户问题是否清楚。
- 检索结果是否命中正确资料。
- chunk 是否包含完整上下文。
- Prompt 是否正确约束模型。
- 模型是否正确理解资料。
- 输出格式是否符合要求。
不要一上来就换大模型。很多时候问题在检索,而不是生成。
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_content和metadata。Text Splitter负责把长文档切成适合检索的 chunk。chunk_size和chunk_overlap会直接影响检索效果。Embedding Model把文本转换成向量。Vector Store存储向量、文本和 metadata。Retriever根据用户问题召回相关文档片段。RunnablePassthrough可以在 LCEL 中保留原始问题。retriever | format_docs可以把检索结果格式化成 Prompt 上下文。- RAG Prompt 要明确资料边界,不知道时要说不知道。
- 调 RAG 时先看检索结果,再看模型答案。
- 生产级 RAG 必须考虑权限、引用、增量更新、评估和可观测。