RAG 检索增强生成
你有没有遇到过这样的场景:
你有一份几百页的产品手册,用户问了一个很具体的问题,你翻了半天也找不到答案在哪。
-
你公司积累了好几年的内部文档、会议纪要、技术规范,新员工想了解某项业务规则,根本不知道从哪查起。
-
你有一堆电子书、研究报告、论文,想让 AI 基于这些内容回答问题,但直接把整本书贴给 AI,上下文窗口装不下。
这就是 RAG 要解决的问题。
RAG(Retrieval-Augmented Generation,检索增强生成)是一种让大语言模型基于特定知识库回答问题的技术。
RAG 的核心思路很简单:先从你的知识库中找出最相关的内容,然后把这些内容和用户的问题一起送给 LLM,让 LLM 基于这些上下文来回答。
没有 RAG,LLM 只知道它训练数据里的内容;有了 RAG,LLM 就能用上你私有的、最新的数据。
为什么需要 RAG
RAG 不是解决这些问题的唯一方案,但它是目前最实用、成本最低的方案之一。
LLM 知识截止日期问题
-
所有 LLM 都有一个知识截止日期——训练数据只到某个时间点,之后发生的事它不知道。
-
比如 GPT-4 的知识截止到 2023 年 10 月,你问它 2024 年发生的新闻,它只能说我不知道。
-
更重要的是,你的私有数据(内部文档、产品手册、公司规范)从来就没出现在 LLM 的训练数据里,它怎么可能知道?
私有数据无法直接输入 LLM
你可能会想:那我直接把文档贴给 LLM 不行吗?
答案是:短文档可以,长文档不行。
-
首先,LLM 的上下文窗口是有限的。比如 GPT-3.5 只有 16K tokens,大概是 1 万字左右。一份几百页的手册,根本贴不下。
-
其次,就算上下文窗口够大,把整份文档都塞进去,效果也不好。LLM 容易迷失在长文本中,找不到真正相关的信息。
-
最后,成本问题。GPT-4 128K 上下文的价格是输入每百万 tokens 10 美元,每次都把整本书塞进去,钱包会哭的。
RAG vs 微调:如何选择
另一个让 LLM 用上新数据的方法是微调(Fine-tuning)——用新数据继续训练模型。
但微调和 RAG 有本质区别,适用场景完全不同:
| 对比项 | RAG | 微调 |
|---|---|---|
| 数据新鲜度 | 随时更新,立即可用 | 需要重新训练,周期长 |
| 适用数据量 | 非常大(百万级文档) | 中等(几千到几万样本) |
| 引用来源 | 可以显示答案来自哪篇文档 | 无法追溯来源 |
| 幻觉问题 | 通过提供上下文减少幻觉 | 仍可能产生幻觉 |
| 修改知识 | 直接删除或更新文档即可 | 需要重新训练,难以"遗忘" |
| 技术门槛 | 较低,用现有框架即可 | 较高,需要 GPU 和训练经验 |
| 成本 | 主要是向量数据库和 Embedding | 训练成本高,推理成本也高 |
一个简单的判断原则:如果是让 LLM "知道"某些事实类知识(比如产品说明、公司规范),用 RAG;如果是让 LLM "学会"某种风格或能力(比如写作风格、代码规范),用微调。
RAG 架构全貌
RAG 分为两个阶段:离线索引阶段和在线检索阶段。
先看一张完整的架构图:
离线索引阶段(数据准备)
这个阶段是在后台运行的,用户不会直接看到。
它的任务是把你的文档处理成可以快速检索的格式。
步骤如下:
-
1. 文档加载:读取 PDF、TXT、DOCX、网页等各种格式的文档。
-
2. 文档切分:把长文档分割成小的文本块(Chunk),通常是几百到一千个词。
-
3. 向量化:用 Embedding 模型把每个文本块转换成向量(一串数字)。
-
4. 存储:把向量和原始文本一起存入向量数据库。
这个阶段只需要做一次,或者当文档更新时重新运行。
在线检索阶段(用户查询)
这个阶段是用户提问时实时发生的。
步骤如下:
1. 问题向量化:用同一个 Embedding 模型把用户的问题也转换成向量。
-
2. 相似度检索:在向量数据库中找出和问题向量最接近的几个文本块。
-
3. 构建 Prompt:把检索到的文本块作为上下文,和用户的问题一起组装成 Prompt。
-
4. LLM 生成:把 Prompt 送给 LLM,让它基于上下文回答。
-
5. 返回答案:把 LLM 的回答返回给用户,通常还会附上引用的文档来源。
向量数据库入门
向量数据库是 RAG 的核心组件之一。要理解它,先得理解什么是向量。
什么是向量(Embedding)
向量(Embedding)是把文本转换成的一串数字。
比如"猫"这个词,可能被转换成 [0.23, -0.45, 0.12, 0.89, ...] 这样一个几百维的向量。
关键在于:语义相近的文本,它们的向量在空间中也会离得很近。
比如:
- "猫"和"猫咪"的向量距离很近
- "猫"和"狗"的向量距离比和"汽车"近
- "我喜欢猫"和"我爱猫咪"的向量距离很近
这就是我们能做"语义搜索"的基础——不是按关键词匹配,而是按意思匹配。
语义相似度的计算原理
两个向量的相似度通常用"余弦相似度"(Cosine Similarity)来计算。
余弦相似度的范围是 -1 到 1:
- 1 表示完全相同
- 0 表示不相关
- -1 表示完全相反
实际应用中,我们通常只关心正相似度,越接近 1 表示越相关。
当你问一个问题时,向量数据库会快速计算问题向量和所有文档向量的相似度,返回最相关的前几个。
主流向量数据库
市面上有很多向量数据库,各有特点:
| 数据库 | 类型 | 特点 | 适用场景 |
|---|---|---|---|
| Chroma | 本地/开源 | 轻量、易用、Python 友好 | 原型开发、小规模应用 |
| Pinecone | 云服务/SaaS | 托管服务、无需运维、弹性扩展 | 生产环境、大规模应用 |
| Weaviate | 开源/托管 | 功能丰富、支持 GraphQL | 需要高级功能的场景 |
| Qdrant | 开源/托管 | 高性能、Rust 编写 | 性能要求高的场景 |
| Milvus | 开源/托管 | 功能全面、企业级 | 大规模企业应用 |
| FAISS | 本地库 | Facebook 出品、极快 | 不需要持久化的场景 |
对于初学者,我们推荐从 Chroma 开始——它安装简单,不需要额外配置,非常适合学习和原型开发。
文档处理流程
文档处理是 RAG 中很容易被忽视但实际上非常重要的一环。
你的检索效果好不好,很大程度上取决于文档处理得好不好。
支持的文档格式
LangChain 支持很多文档格式:
- 纯文本:.txt, .md
- PDF:.pdf
- Office:.docx, .pptx, .xlsx
- 网页:HTML, URL
- 代码:.py, .js, .java 等
- JSON:.json, .jsonl
- CSV:.csv
每种格式都有对应的加载器,使用起来很方便。
文档切分策略
文档切分(Chunking)是把长文档切成小块的过程。
这看起来简单,其实有很多讲究:
- 切得太小:可能丢失上下文,一个完整的意思被切断了
- 切得太大:可能包含不相关信息,浪费上下文窗口
常见的切分策略:
| 策略 | 原理 | 适用场景 |
|---|---|---|
| 按字符数 | 简单按字数切分,比如每 500 个字符一块 | 简单场景、对上下文要求不高 |
| 按段落 | 按换行符切分,尽量保持段落完整 | 格式规范的文档 |
| 递归切分 | 优先按段落,太长再按句子,最后按词 | 大多数场景的首选 |
| 语义切分 | 根据语义相似度切分,保证语义完整 | 对语义要求高的场景 |
大多数情况下,用 LangChain 的 RecursiveCharacterTextSplitter 就够了。
切分大小的选择技巧
切分大小(Chunk Size)是一个需要根据场景调整的参数。
一些参考原则:
- 问答场景:500-1000 个字符,刚好能包含一个完整的问答对
- 摘要场景:1000-2000 个字符,需要更多上下文
- 代码场景:可以按函数或类切分,保持代码块完整
还有一个重要参数:重叠(Overlap)——相邻两个文本块之间重叠的字符数。
重叠可以防止重要信息被切分在两个块里,通常设为切分大小的 10%-20%。
不要盲目追求"最优"切分参数。先从 500 字符、50 重叠开始,实际测试效果,然后再调整。
嵌入模型(Embedding Model)
嵌入模型是把文本转换成向量的模型。
它的质量直接影响检索效果——好的 Embedding 模型能准确理解语义,把相关的文档排到前面。
什么是 Embedding 模型
Embedding 模型是一个预训练的神经网络,输入是文本,输出是向量。
它的训练目标是:让语义相近的文本,输出的向量也相近。
比如:
- "如何学习 Python" 和 "Python 学习方法" → 向量接近
- "猫咪很可爱" 和 "小狗很可爱" → 向量有一定距离但也比较近
- "如何学习 Python" 和 "猫咪很可爱" → 向量距离很远
OpenAI text-embedding-3 系列
OpenAI 提供了目前最流行的 Embedding 模型:
| 模型 | 维度 | 价格(每百万 tokens) | 特点 |
|---|---|---|---|
| text-embedding-3-small | 1536 | $0.02 | 性价比高,大多数场景够用 |
| text-embedding-3-large | 3072 | $0.13 | 效果更好,适合高精度需求 |
text-embedding-3-small 是目前的首选——它比上一代 text-embedding-ada-002 效果更好,价格更便宜。
开源 Embedding 模型
如果你不想用 OpenAI,或者有数据隐私要求,也有很多开源选择:
| 模型 | 维度 | 特点 |
|---|---|---|
| sentence-transformers/all-MiniLM-L6-v2 | 384 | 轻量、速度快、效果不错 |
| sentence-transformers/all-mpnet-base-v2 | 768 | 效果更好,但也更慢更大 |
| BAAI/bge-large-zh-v1.5 | 1024 | 专门针对中文优化 |
| Alibaba-NLP/gte-large-zh | 1024 | 阿里出品,中文效果好 |
对于中文场景,推荐用 BAAI/bge 系列或 Alibaba-NLP/gte 系列——它们专门针对中文做了优化,比通用模型效果更好。
检索策略
检索不是"找出最相关的文档"这么简单——不同的策略适合不同的场景。
向量相似度检索
这是最常用的检索方式:计算问题向量和所有文档向量的相似度,返回最相关的前 K 个。
优点:能理解语义,同义词、近义词都能匹配。
缺点:对精确术语、专有名词可能不如关键词检索准确。
比如用户问"runoob 的 Python 教程",向量检索能理解"教程"和"课程"是类似的,但如果用户问的是"runoob Python3.10 文档",可能关键词搜索更准确。
关键词检索(BM25)
这是传统搜索引擎用的方法:匹配关键词,根据词频、逆文档频率等打分。
-
优点:精确匹配术语、产品名、型号等效果好。
-
缺点:无法理解同义词、语义关系。
比如用户问如何学习编程,关键词检索只能找到包含编程的文档,找不到写着写代码但意思一样的文档。
混合检索(Hybrid Search)
既然两种方法各有优劣,为什么不一起用呢?
混合检索就是同时做向量检索和关键词检索,然后把结果合并、重新排序。
这样既可以利用语义理解,又能保证精确匹配。
很多向量数据库都支持混合检索,比如 Weaviate、Pinecone、Qdrant 等。
如果拿不准用哪种,选混合检索——大多数情况下,它效果最好。
生成阶段优化
检索到相关文档只是第一步,如何让 LLM 基于这些文档生成好的回答,也有很多技巧。
Prompt 模板设计
Prompt 模板是 RAG 中影响输出质量的关键因素。
一个好的 RAG Prompt 通常包含:
- 角色设定:告诉 LLM 它是什么角色
- 上下文:检索到的文档内容
- 任务说明:让 LLM 做什么
- 约束条件:比如"只基于上下文回答"、"如果不知道就说不知道"
- 输出格式:希望 LLM 怎么回答
一个经典的模板:
实例
RAG_PROMPT_TEMPLATE = """你是一个专业的 {role}。
请基于以下上下文回答用户的问题。如果上下文中没有相关信息,请说"抱歉,我没有找到相关信息"。
上下文:
{context}
用户问题:{question}
请用简洁、准确的语言回答:"""
如何减少幻觉
幻觉(Hallucination)是 LLM 编造事实的现象——它会说得有模有样,但全是假的。
RAG 能减少幻觉,但不能完全消除。
一些减少幻觉的技巧:
- 在 Prompt 中强调"只基于上下文回答"
- 让 LLM 在回答中引用原文片段
- 如果检索结果不相关,让 LLM 说"我不知道"
- 用多个 LLM 交叉验证答案
- 让 LLM 先从上下文中找出相关内容,再回答
引用来源标注
让用户知道答案是从哪里来的,这很重要——不仅增加可信度,也方便用户查证。
常见做法:
- 在回答中标注来源,比如"根据文档 A 第 2 页"
- 在回答末尾列出参考文档的标题、链接
- 让用户点击可以跳转到原文位置
LangChain 的一些高级框架(比如 LlamaIndex)可以自动帮你做这些。
实战:基于 LangChain 搭建文档问答系统
说了这么多,我们来动手做一个完整的 RAG 系统。
我们将用 LangChain + Chroma + OpenAI 来搭建一个文档问答系统。
环境准备
先安装需要的库:
pip install langchain langchain-openai langchain-chroma python-dotenv
然后在项目目录下创建一个 .env 文件,填入你的 OpenAI API Key:
OPENAI_API_KEY=你的-api-key-在这里
完整代码实现
这是一个完整的 RAG 系统,包含索引创建和查询功能:
实例
# RAG 文档问答系统完整实现
# ============================================
import os
from dotenv import load_dotenv
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_openai import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
from langchain.schema import Document
def load_documents_from_directory(directory: str) -> list[Document]:
"""从目录加载文档
这里为了演示,我们手动创建一些文档
实际项目中可以用 DirectoryLoader 加载真实文件
"""
documents = [
Document(
page_content="""RUNOOB 教程网站创立于 2013 年,由菜鸟教程团队创建。
网站的目标是为编程初学者提供简单易懂的编程教程。
我们提供了 HTML、CSS、JavaScript、Python、Java、C++ 等多种编程语言的教程。""",
metadata={"source": "runoob_intro.txt", "page": 1}
),
Document(
page_content="""RUNOOB 的 Python 教程涵盖了 Python 的所有重要知识点。
包括:Python 基础语法、数据类型、控制语句、函数、模块、文件操作等。
教程中提供了大量的代码示例,每个示例都经过精心设计和测试。
我们还提供了在线 Python 编辑器,你可以直接在浏览器中运行代码。""",
metadata={"source": "python_tutorial.txt", "page": 1}
),
Document(
page_content="""RUNOOB 教程的特点是内容简洁、示例丰富、注重实践。
我们相信"学习编程的最好方式就是写代码"。
因此每个知识点都配有可运行的代码示例,你可以边看边练。
我们的教程完全免费,任何人都可以访问学习。""",
metadata={"source": "features.txt", "page": 1}
),
Document(
page_content="""除了文字教程,RUNOOB 还提供了视频教程。
视频教程更加直观,适合视觉学习型的用户。
我们的视频教程覆盖了 Python、Web 开发、数据分析等热门主题。
你可以在 B 站和 YouTube 上找到我们的视频。""",
metadata={"source": "video_course.txt", "page": 1}
),
]
print(f"已加载 {len(documents)} 个文档")
return documents
def split_documents(documents: list[Document], chunk_size: int = 500, chunk_overlap: int = 50) -> list[Document]:
"""切分文档"""
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
length_function=len,
separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""],
)
splits = text_splitter.split_documents(documents)
print(f"已将 {len(documents)} 个文档切分为 {len(splits)} 个文本块")
return splits
def create_vector_store(splits: list[Document], persist_directory: str = "./chroma_db") -> Chroma:
"""创建向量数据库"""
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma.from_documents(
documents=splits,
embedding=embeddings,
persist_directory=persist_directory,
)
print(f"向量数据库已创建,共 {vector_store._collection.count()} 条记录")
return vector_store
def load_vector_store(persist_directory: str = "./chroma_db") -> Chroma:
"""加载已有的向量数据库"""
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory=persist_directory,
embedding_function=embeddings,
)
print(f"向量数据库已加载,共 {vector_store._collection.count()} 条记录")
return vector_store
def create_rag_chain(vector_store: Chroma):
"""创建 RAG 问答链"""
# 自定义 Prompt 模板
template = """你是一个专业的 RUNOOB 客服助手。
请基于以下上下文回答用户的问题。如果上下文中没有相关信息,请说"抱歉,我没有找到相关信息"。
回答要简洁明了,使用中文。
上下文:
{context}
用户问题:{question}
回答:"""
prompt = PromptTemplate(
template=template,
input_variables=["context", "question"],
)
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
# 创建检索链
rag_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=vector_store.as_retriever(search_kwargs={"k": 3}),
chain_type_kwargs={"prompt": prompt},
return_source_documents=True,
)
return rag_chain
def main():
"""主函数"""
# 加载环境变量
load_dotenv()
# 检查数据库是否已存在
db_exists = os.path.exists("./chroma_db") and os.path.isdir("./chroma_db")
if db_exists:
print("发现已存在的数据库,正在加载...")
vector_store = load_vector_store()
else:
print("创建新的数据库...")
# 1. 加载文档
docs = load_documents_from_directory("./data")
# 2. 切分文档
splits = split_documents(docs)
# 3. 创建向量数据库
vector_store = create_vector_store(splits)
# 4. 创建 RAG 链
rag_chain = create_rag_chain(vector_store)
# 5. 测试查询
test_questions = [
"RUNOOB 是什么时候创立的?",
"RUNOOB 的 Python 教程有什么特点?",
"RUNOOB 有视频教程吗?",
"如何在 RUNOOB 上学习 C++?",
"RUNOOB 的教程需要付费吗?",
]
for question in test_questions:
print(f"\n{'='*60}")
print(f"问题:{question}")
print(f"{'='*60}")
result = rag_chain.invoke({"query": question})
answer = result["result"]
sources = result["source_documents"]
print(f"回答:{answer}")
print("\n参考来源:")
for i, doc in enumerate(sources, 1):
print(f" [{i}] {doc.metadata['source']} (第 {doc.metadata['page']} 页)")
print(f" 片段:{doc.page_content[:50]}...")
if __name__ == "__main__":
main()
运行这个程序,你会看到类似这样的输出:
创建新的数据库...
已加载 4 个文档
已将 4 个文档切分为 4 个文本块
向量数据库已创建,共 4 条记录
============================================================
问题:RUNOOB 是什么时候创立的?
============================================================
回答:RUNOOB 教程网站创立于 2013 年。
参考来源:
[1] runoob_intro.txt (第 1 页)
片段:RUNOOB 教程网站创立于 2013 年,由菜鸟教程团队创建。...
[2] features.txt (第 1 页)
片段:除了文字教程,RUNOOB 还提供了视频教程。...
============================================================
问题:RUNOOB 的 Python 教程有什么特点?
============================================================
回答:RUNOOB 的 Python 教程涵盖了 Python 的所有重要知识点,包括基础语法、数据类型、控制语句、函数、模块、文件操作等,并且提供了大量经过精心设计和测试的代码示例,还提供了在线 Python 编辑器,可以直接在浏览器中运行代码。
参考来源:
[1] python_tutorial.txt (第 1 页)
片段:RUNOOB 的 Python 教程涵盖了 Python 的所有重要知识点。...
[2] features.txt (第 1 页)
片段:RUNOOB 教程的特点是内容简洁、示例丰富、注重实践。...
恭喜!你已经成功搭建了一个完整的 RAG 文档问答系统。
进阶优化:Re-ranking、HyDE
基础 RAG 已经能用,但如果你想让效果更好,可以试试这些进阶优化方法。
Re-ranking(重排序)
向量检索返回的结果是按相似度排序的,但相似度高不一定意味着最适合回答问题。
Re-ranking 就是:先用向量检索返回前 K 个结果(比如 20 个),然后用一个更精确的模型对这些结果重新排序,选最好的几个送给 LLM。
常用的 Re-ranking 模型:
- cross-encoder/ms-marco-MiniLM-L-6-v2
- BAAI/bge-reranker-large
虽然多了一步,但效果提升很明显,值得一做。
HyDE(Hypothetical Document Embedding)
HyDE 是一个很巧妙的想法:先用 LLM 为用户的问题生成一个"假设的回答",然后用这个假设的回答去检索。
为什么这样有效?因为问题通常很短,信息密度低;而假设的回答更长,更像文档的风格,和真实文档的相似度更高。
比如:
- 用户问题:"RUNOOB 什么时候创立的?"
- 假设回答:"RUNOOB 教程网站创立于 2013 年,由菜鸟教程团队创建,旨在为编程初学者提供优质教程。"
用假设的回答去检索,往往能找到更相关的文档。
