RAG 系统开发实战教程,分享完整的构建流程和关键经验。
我从零构建 RAG 系统时学到了什么——以及你也可以如何做到
最近,我完成了 DeepLearning.AI 的一门系统性 RAG 课程,讲师是 Zain Hasan。在此之前,我一直通过零散的教程和博客文章学习——完全没有体系。我以为自己已经理解了检索增强生成。我知道相关理论:嵌入、向量数据库、语义搜索。
但和工程领域的大多数事情一样,理论与实践完全是两个世界。
从那以后,我一直在为个人项目和课程项目构建独立的 RAG 系统,包括客户支持聊天机器人、文档搜索引擎和内部知识助手。每个项目都让我学到了一些仅靠阅读理论无法掌握的东西:生产环境中 AI 混乱却又令人着迷的真实面貌。
下面是我希望自己开始之前就有人告诉我的事情,以及一路走来学到的经验。
在课程中,讲师反复强调一点:LLM 非常擅长推理,却极不擅长记忆。当时我只是点头表示认同,但直到完成第一个项目,我才真正理解这句话。
当时,我正在为一家公司的内部文档构建聊天机器人。听起来很简单,对吧?向 GPT-4 提交一个问题,然后得到答案。然而:
它不断产生幻觉。编造根本不存在的 API 端点,自信地引用从未写过的文档章节。
它不断产生幻觉。编造根本不存在的 API 端点,自信地引用从未写过的文档章节。
它不知道最新的更新。即使我们上周刚发布了一项重大功能,模型呢?一无所知。
它不知道最新的更新。即使我们上周刚发布了一项重大功能,模型呢?一无所知。
就在那时,我突然理解了 RAG。与其指望模型记住一切,不如给它一个搜索引擎。当有人提出问题时,先搜索文档,再把相关内容提供给模型。
结果一下子变成了:没有幻觉、信息始终保持最新,而且 token 使用也得到了优化。
这就是 RAG 的力量。
构建过几个系统后,我是这样理解 RAG 的:
与其让 LLM 凭记忆回答问题——这会导致幻觉——不如:
将文档存储在一个能够理解语义的数据库中(向量数据库)
当有人提问时,搜索相关文档
将这些文档连同问题一起交给 LLM
让 LLM 根据刚刚读到的内容作答
这就像让一个人凭记忆背诵教科书,与允许他先查阅教科书之间的区别。
关键突破在于:将“知道事实”与“基于事实进行推理”分离。更新文档后,AI 会立即掌握新信息。不需要重新训练,不会有过时的知识,也不会编造答案。
在开始构建之前,先确保我们对三个关键概念有相同的理解:
嵌入会将文本转换为能够捕捉语义的数字数组(向量)。含义相近的词在这个数学空间中的位置也彼此接近。
"dog" → [0.32, 0.89, -0.45, ...]
"puppy" → [0.34, 0.87, -0.43, ...]
"car" → [-0.12, 0.15, 0.78, ...]
关键洞见:嵌入让我们可以通过数学方式计算“语义相似度”。“dog”和“puppy”在几何空间中彼此接近,而“dog”和“car”则相距很远。
当用户询问“What’s your refund policy?”时,我们会:
将问题转换为嵌入
查找具有相似嵌入的文档(使用余弦相似度或点积)
返回最匹配的结果
这比逐一阅读每份文档快得多。向量数据库可以在几毫秒内搜索数百万份文档。
LLM 的上下文窗口是有限的(可以将其理解为对话使用的 RAM):
GPT-3.5:4K tokens(约 3,000 个英文单词)
GPT-4:8K–128K tokens
Claude 3:最高 200K tokens
问题在于:上下文越多,响应越慢,成本也越高。RAG 的重点是找到正确的上下文,而不是提供全部上下文。
生产级 RAG 系统的工作方式如下:
Documents → Chunking → Embedding → Vector DB Storage
第 1 步:收集数据
文档(Markdown、PDF)
客户支持工单
第 2 步:分块
将大型文档拆分为更小的片段(块)。为什么?因为 LLM 需要聚焦的上下文,而不是整本手册。
第 3 步:嵌入
使用嵌入模型(OpenAI Ada、Sentence-BERT 等)将每个块转换为向量。
第 4 步:存储
在向量数据库中为向量建立索引,并附加元数据(来源、时间戳、类别)。
User Query → Embed Query → Search Vector DB → Retrieve Top-K Chunks
当用户提出问题时:
将其问题转换为嵌入
在向量数据库中搜索相似的块
检索相关性最高的 3~10 个片段
(可选)对结果重新排序,以提高精度
Query + Retrieved Context → LLM → Grounded Answer
Context: [Retrieved chunks]
Question: [User query]
Instructions: Answer based only on the context provided.
LLM 会基于你的真实数据生成答案。
完成课程后,我想构建一个尽可能简单的 RAG 系统,以证明自己确实理解了它。下面是我写出的方案——大约 50 行 Python 代码(但它在我的 MacBook 上运行得非常慢):
# requirements: sentence-transformers, faiss-cpu, openai
from sentence_transformers import SentenceTransformer
import faiss
import numpy as np
import openai
# Step 1: Prepare documents
documents = [
"Our refund policy: 30 days, full refund with receipt.",
"Shipping takes 3-5 business days for domestic orders.",
"We accept Visa, Mastercard, and PayPal.",
"Customer support: support@example.com or call 1-800-HELP"
]
# Step 2: Create embeddings
model = SentenceTransformer('all-MiniLM-L6-v2') # 384-dim embeddings
embeddings = model.encode(documents)
# Step 3: Build FAISS index
dimension = embeddings.shape[1]
index = faiss.IndexFlatL2(dimension)
index.add(np.array(embeddings))
# Step 4: Retrieval function
def retrieve(query, k=2):
query_embedding = model.encode([query])
distances, indices = index.search(query_embedding, k)
return [documents[i] for i in indices[0]]
# Step 5: RAG function
def rag_query(question):
# Retrieve relevant docs
context = retrieve(question)
# Create prompt
prompt = f"""Answer the question based only on this context:
Context:
{chr(10).join(context)}
Question: {question}
Answer:"""
# Generate response
response = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": prompt}]
)
return response.choices[0].message.content
# Test it
print(rag_query("How long does shipping take?"))
# Output: "Shipping takes 3-5 business days for domestic orders."
我使用一个可以在本地运行的轻量级模型对 4 份文档进行了嵌入(下载大小为 22MB)
将它们存储在 FAISS 中——仅仅弄明白文档里的用法,就花了我 10 分钟
当询问配送时间时,系统找到了正确的文档
将文档提供给 GPT-3.5,生成自然语言答案
结果:token 使用得到了优化。零幻觉。我彻底着迷了。
这个小例子教给我的东西,比数小时的课程还要多。亲眼看到检索实时生效后,一切都豁然开朗。
课程只用一节课讲解分块。但在实践中,我花了三周时间做实验。以下是我通过惨痛教训学到的经验:
每隔 N 个 token(例如 512 个 token)进行切分,并可选择设置重叠部分:
def chunk_fixed(text, chunk_size=512, overlap=50):
words = text.split()
chunks = []
for i in range(0, len(words), chunk_size - overlap):
chunk = ' '.join(words[i:i + chunk_size])
chunks.append(chunk)
return chunks
优点:简单、可预测
缺点:可能从句子中间切开,破坏语义单元
适用场景:拥有干净、统一的文本(文章、文档)
在自然边界处进行切分(段落、章节、句子):
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
separators=["\n\n", "\n", ".", " "] # Try these in order
)
chunks = splitter.split_text(long_document)
优点:尊重语义边界
缺点:块的大小不固定
适用场景:拥有结构化文档(PDF、文章)
使用章节标题定义块边界
将块控制在 token 限制以内
添加元数据(章节标题、页码)
真正对我有效的方法:
✅ 始终使用重叠(我设定为 15%)—— 这解决了很多"半答案"的问题 ✅ 元数据是你的朋友 —— 我可以按日期、来源、文档类型过滤 ✅ 从 512 tokens 开始 —— 之后再调整。对于技术文档我增加到 768 ✅ 用真实查询测试 —— 理论上看起来不错的方案在实践中往往失效 ⚠️ 我最大的错误:块太小(200 tokens)。上下文消失了。
我的建议:一开始别想太复杂。用 512 tokens 配 50 token 重叠,然后根据用户实际搜索行为迭代。
在课程中,我们学了语义搜索(embedding + 向量相似度)。在我的项目中,我发现这还不总是足够的。
这是我开始的方式:把所有内容转换成向量,找相似的向量。
截至 2025 年最佳模型:
优点:捕捉语义含义,处理同义词 缺点:计算密集
传统关键字搜索。快速、简单、可解释。
from rank_bm25 import BM25Okapi
corpus = [doc.split() for doc in documents]
bm25 = BM25Okapi(corpus)
query = "refund policy".split()
scores = bm25.get_scores(query)
top_doc = documents[np.argmax(scores)]
优点:快速、确定性强、适合精确关键字匹配 缺点:容易漏掉语义相似性("car" 不会匹配 "automobile")
结合稠密和稀疏检索:
def hybrid_search(query, alpha=0.5):
# 获取语义结果
semantic_results = vector_db.search(query, k=20)
# 获取 BM25 结果
bm25_results = bm25.search(query, k=20)
# 用加权分数合并
combined = merge_results(
semantic_results,
bm25_results,
alpha=alpha # 0.5 = 等权重
)
return combined[:10] # 前 10 个
何时使用混合搜索:
我差点没实现重排。我想"初始检索已经足够好"。然后我试了试:
from sentence_transformers import CrossEncoder
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
# 初始检索:获取前 20 个
candidates = vector_db.search(query, k=20)
# 重排:为每个候选项评分
scores = reranker.predict([(query, doc) for doc in candidates])
# 重排后返回前 5 个
reranked = [candidates[i] for i in np.argsort(scores)[-5:]]
我的结果:在课程测试数据集的测试查询上,准确率从 73% 跳到 89%。我立即注意到答案质量更好。权衡:增加了一些延迟,完全值得。
重排在我后续的项目中成了必不可少的。
课程后这是我的第一选择。为什么?
问题在于:没有开箱即用的持久化。我必须手动保存/加载索引。对原型化还好,对生产就烦人了。
import faiss
index = faiss.IndexFlatL2(dimension) # 暴力搜索,精确搜索
index.add(embeddings)
优点:简单、嵌入式模式、适合初学者 缺点:没有针对大规模优化 用于:副项目、MVP、本地开发
import chromadb
client = chromadb.Client()
collection = client.create_collection("docs")
collection.add(documents=texts, embeddings=embeddings, ids=ids)
优点:Rust 构建(快速)、过滤、开源、文档好、对延迟至关重要的场景(如语音应用) 缺点:社区比其他方案小 用于:生产、性能关键的应用
优点:为大规模构建(数十亿向量)、实战考验 缺点:复杂部署、学习曲线陡峭 用于:企业规模、数十亿文档
这是百万美元问题。
何时使用 RAG:
何时使用微调:
最佳方案?混合:微调行为,RAG 处理知识。
这不在课程里,但这是我学到的最重要的东西:
一个调优得当的 7B 模型配 RAG 在特定领域任务上击败 GPT-4。
直到我在演示项目里试过,我才相信。这是为什么:
专业化检索击败通用知识
小模型更快
成本节省巨大
GPT-4:$0.03 / 1K tokens
Llama-3 8B(自托管):$0.0001 / 1K tokens
便宜 300 倍
你控制基础设施
最开始用 GPT-4:答案很好
换成 Llama-3 8B + RAG:更好的答案
区别:GPT-4 会不准确地改写。Llama-3 + RAG 引用精确文档。
先试 GPT-4:一些查询处理正确(67%)
改成 Llama-3.2 8B + RAG:91% 准确率
为什么:RAG 检索精确的支持文章。模型只需要总结。
用 Phi-3 3.8B(微小模型)+ 激进混合搜索 RAG
平均响应 120ms
我更喜欢它而不是单独的 GPT-4 系统
经过三个项目和无数次实验,这是我的看法:
随着小模型变得更好(而且速度很快),RAG 成了大平衡器。我们正朝这个方向移动:
下一个演进是 AI 智能体 RAG —— 不只是检索和生成,还要推理要检索什么、何时检索的系统:
AI 智能体 RAG 如何工作:
# 例子:AI 智能体 RAG 流程
def agentic_rag(query):
# 步骤 1:分析查询复杂度
if needs_multi_step_reasoning(query):
# 步骤 2:分解为子问题
sub_queries = decompose_query(query)
# 步骤 3:为每个子问题检索
contexts = [retrieve(q) for q in sub_queries]
# 步骤 4:合成和验证
answer = generate_with_verification(query, contexts)
# 步骤 5:如果置信度低,检索更多
if answer.confidence < 0.8:
additional_context = retrieve_with_feedback(query, answer)
answer = generate_final(query, contexts + additional_context)
else:
# 简单单步 RAG
answer = simple_rag(query)
return answer
AI 智能体 RAG 的优点:
现实影响:在 HotpotQA 和 MultiHop-RAG 等复杂问答基准上,AI 智能体 RAG 系统相比传统 RAG 显示 30-40% 的性能提升。
我的看法:大多数公司不需要最新的 GPT-5。他们需要自己的数据、聪明的检索和实现良好的 RAG 系统。那是 10% 的成本换 90% 的价值。
这个认识彻底改变了我对 AI 工程的思考。
我第一个项目没加监控。大错误。在第二个项目,我跟踪了上下文,但从其他来源我学到:
MRR(平均倒数排名):第一个相关结果排在多靠后的位置?
忠实度:答案是否与检索到的上下文一致?
相关性:答案是否回答了问题?
延迟:从查询到响应所需的时间
用户满意度(赞/踩)
解决率(针对客服聊天机器人)
# 简单的评估框架
def evaluate_rag(query, ground_truth, rag_system):
# 检索
retrieved_docs = rag_system.retrieve(query)
recall = calculate_recall(retrieved_docs, ground_truth_docs)
# 生成
answer = rag_system.generate(query, retrieved_docs)
faithfulness = check_faithfulness(answer, retrieved_docs)
return {"recall": recall, "faithfulness": faithfulness}
如果检索到的上下文超出了 LLM 的上下文窗口,该怎么办?
在传递给 LLM 之前对文本块进行摘要
使用上下文窗口更长的模型(Claude 3 200K)
实现多跳检索(迭代优化)
使用抽取式摘要压缩上下文
缓存嵌入(不要为相同文本重复计算)
使用更便宜的模型进行初步检索
批量执行嵌入操作
在准确率允许的情况下使用更小的模型
为常见查询实现缓存
设置 max_tokens,避免生成过程失控
自行托管嵌入模型(一次性成本)
使用竞价实例进行批处理
实现请求限流
# 命名空间方案
collection.add(
documents=docs,
embeddings=embeddings,
metadata=[{"tenant_id": "customer_123"}]
)
# 使用过滤条件查询
results = collection.query(
query_embedding=query_emb,
filter={"tenant_id": "customer_123"}
)
不要每天重建整个索引:
# 添加新文档
new_docs = fetch_new_documents(since=last_update)
new_embeddings = model.encode(new_docs)
index.add(new_embeddings)
# 更新现有文档
updated_docs = fetch_updated_documents()
# 删除旧版本,添加新版本
for doc in updated_docs:
index.remove(doc.old_id)
index.add(doc.new_embedding, doc.new_id)
我犯过或原本可能会犯的错误(这样你就不必再犯)
⚠️ 错误 1:文本块切分得太小
我的做法:一开始使用 128 token 的文本块,以“最大限度提高精确率”
结果:检索只能找到缺少足够上下文的片段,答案并不完整。
修复方法:将文本块增大到 512 token,并设置 15% 的重叠。效果立刻得到了改善。
⚠️ 错误 2:忽略元数据
我的做法:只使用纯语义搜索,不添加过滤条件
结果:明明存在新版本,却检索出了旧文档
修复方法:添加时间戳和版本过滤条件。这彻底改变了效果。
results = vector_db.search(
query=query,
filter={"category": "product_docs", "date": {"$gte": "2024-01-01"}}
)
⚠️ 错误 3:没有分析延迟
我的做法:在没有测量的情况下,想当然地认为速度“足够快”
结果:用户抱怨响应时间长达 3 秒
修复方法:
分析每一个步骤:嵌入(50ms)、检索(80ms)、重排序(200ms)、生成(2.1s)
通过切换模型优化生成过程
最终将总耗时降到了 800ms
⚠️ 错误 4:盲目信任检索结果
我的做法:总是把排名靠前的结果传递给 LLM,不做质量检查
结果:检索失败时,LLM 会编造内容
修复方法:添加置信度阈值:
def rag_with_fallback(query):
results = retrieve(query, k=3)
# 检查排名第一的结果是否具有足够的置信度
if results[0].score < 0.7: # 低置信度
return "I don't have enough information to answer this."
return generate(query, results)
⚠️ 错误 5:“设置完就置之不理”
我的做法:构建并部署系统后,就不再管它
结果:又添加了 5000 份文档后,检索质量下降了 15%
修复方法:我的计划是定期运行评估测试,尽早发现性能退化。
常见问题
Q:我需要向量数据库吗,还是可以使用传统数据库?
A:对于少于 1 万份文档的场景,可以使用 FAISS,甚至 numpy 数组也能应付。超过这个规模后,专业的向量数据库可以提供可扩展性、过滤能力和更好的性能。
Q:最小可行的 RAG 系统是什么样的?
A:50 行 Python 代码(参见上面的示例)、一个免费的嵌入模型,以及 FAISS。初始总成本为 0 美元。
Q:如何处理 PDF 提取和预处理?
A:使用 pymupdf、pdfplumber 或 unstructured 等库。要特别注意表格提取——这很棘手。
Q:可以使用完全私有或离线的模型来实现 RAG 吗?
A:当然可以。使用 sentence-transformers 生成嵌入,并使用 llama.cpp 或 ollama 在本地进行 LLM 推理。
Q:结构化数据(数据库、电子表格)该如何处理?
A:将其转换为文本描述,或者使用混合方案(SQL + RAG)。例如:为数据库中的行生成自然语言描述。
Q:如何判断我的文本分块策略是否有效?
A:衡量检索指标。如果召回率较低,请尝试不同的文本块大小和重叠比例。
Q:应该使用多个嵌入模型吗?
A:对于代码、法律、医疗等专业领域,特定领域的嵌入模型通常优于通用模型。
Q:多模态 RAG(图像、表格、图表)该如何处理?
A:使用 CLIP 等多模态嵌入模型(用于图像),或者将表格转换为文本。这是一个活跃的研究领域。
开始之前,我希望自己就明白的事
如果能够回到过去,在构建第一个 RAG 系统之前,我会告诉自己以下这些事情:
RAG 将推理与知识分离 → LLM 负责推理,数据库负责事实
流程很简单:摄取 → 分块 → 嵌入 → 存储 → 检索 → 生成
文本分块很重要:从 512 token 开始,添加重叠,并尊重语义边界
混合搜索优于纯语义搜索:结合 BM25 与向量搜索可以获得最佳结果
小模型 + RAG 能够在特定领域任务上超越 GPT-4,而成本仅为其百分之一
生产环境的关键在于监控:跟踪检索质量、延迟和成本
从简单方案开始,之后再优化:FAISS + OpenAI embeddings + GPT-3.5 能够帮你完成 80% 的工作
智能体式 RAG 是未来:多步推理和自适应检索将解锁新的能力
我为什么对此感到兴奋:
可及性:我正在尝试凭借还不错的机器学习背景来构建生产级 AI。所需要的只是好奇心和决心。
经济性:RAG 系统的成本仅为纯 LLM 解决方