RAG 系统从搭建到优化:我踩过的 5 个坑,每一个都让我重新写代码
TL;DR:搭建一个「能跑」的 RAG 系统只需要 50 行代码。但让它「好用」——检索精准、回答稳定、不编造——我花了 3 个月踩了 5 个大坑。这篇文章是踩坑实录,也是避坑指南。
背景:我为什么要搭 RAG 系统
公司要做企业知识库问答系统。需求很简单:把内部文档(PDF、Word、Wiki)喂进去,员工问问题,系统回答。
第一版我只用了一周:
- 用 LangChain 加载文档
- 用 OpenAI 的 text-embedding-3-small 生成向量
- 用 FAISS 存向量
- 用户提问时检索 Top-3,拼进 Prompt,让 GPT-4 回答
上线第一天就翻车了。
老板问了一个很简单的问题:「公司的报销流程是什么?」系统回答:「请提交纸质申请表给财务部门。」
但实际上公司已经全面线上化了,根本不需要纸质表。文档里写得清清楚楚,但系统就是答错了。
排查后发现:不是模型的问题,是检索的问题。检索根本没找到那段文字。
这是第一个坑。后面还有 4 个。
坑 1:向量数据库选型错误
坑点描述
第一版用 FAISS,本地跑得飞快。但文档量从 1 万篇涨到 50 万篇后,检索延迟从 200ms 飙到 3 秒。而且 FAISS 不支持增量更新,每次新增文档都要重建整个索引。
我当时的选择逻辑很简单:
- FAISS 是 Facebook 开源的,应该很成熟
- 本地部署,不花钱
- LangChain 官方文档里有示例代码,照抄就行
但我忽略了一个关键问题:FAISS 是为「静态数据集」设计的。它的索引构建是一次性的,构建后不支持动态插入。每次新增文档,你都得重新构建整个索引。
对于企业知识库这种「每天都在新增文档」的场景,FAISS 完全不适用。
解决方案
换成Milvus / Qdrant / Pinecone(支持增量更新的向量数据库)。
我最终选了 Qdrant(开源、轻量、支持 Docker 部署):
docker-compose.yml
version: '3' services: qdrant: image: qdrant/qdrant:latest ports: - "6333:6333" volumes: - ./qdrant_storage:/qdrant/storage增量插入的代码:
Python - 增量插入向量
from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams, PointStruct client = QdrantClient(url="http://localhost:6333") # 创建 collection(只需执行一次) client.create_collection( collection_name="company_docs", vectors_config=VectorParams(size=1536, distance=Distance.COSINE) ) # 增量插入新文档 points = [ PointStruct( id="doc_001", vector=embedding_vector, payload={"text": "文档内容", "source": "wiki"} ) ] client.upsert(collection_name="company_docs", points=points)| 向量数据库 | 适用场景 | 增量更新 | 部署复杂度 |
|---|---|---|---|
| FAISS | 静态数据集、研究实验 | ❌ 不支持 | 低 |
| Qdrant | 动态数据、中小规模 | ✅ 支持 | 中 |
| Milvus | 大规模生产环境 | ✅ 支持 | 高 |
| Pinecone | 托管服务、不想运维 | ✅ 支持 | 零(SaaS) |
坑 2:文档切片策略不对
坑点描述
一开始我按固定字数切分(每段 500 字,重叠 50 字)。结果把很多完整的语义单元切断了。比如一个「报销流程」的步骤被切成两段,检索时只找到后半段,回答就缺了关键信息。
举个真实例子:
原文:
原始文档片段
报销流程: 1. 登录 OA 系统,进入「财务审批」模块 2. 填写报销单,上传发票扫描件(必须是 PDF 格式) 3. 提交给直属领导审批 4. 审批通过后,财务会在 3 个工作日内打款到工资卡按固定 500 字切分后,这段话被切成了两段:
- 片段 1:包含步骤 1-2
- 片段 2:包含步骤 3-4
用户问「发票格式要求是什么?」,检索到了片段 1,但片段 1 里只说了「上传发票扫描件」,没说格式。答案是「PDF 格式」——在片段 2 里。
这就是固定字数切分的致命问题:它不考虑语义边界。
解决方案
按语义单元切分——段落、章节、或者用模型判断切分点。
LangChain 提供了几种切分策略:
Python - 语义切分
from langchain.text_splitter import RecursiveCharacterTextSplitter # 按「段落 → 句子 → 字数」的优先级切分 splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。, ",", " "] ) chunks = splitter.split_text(document)更好的方案是用Semantic Chunker(基于句子相似度判断切分点),但计算成本更高。
❌ 错误做法
按固定字数切分,不考虑语义边界
✅ 正确做法
按段落/章节切分,或用 RecursiveCharacterTextSplitter 按分隔符优先级切分
坑 3:检索 Top-K 设太小
坑点描述
一开始我只检索 Top-3,觉得「最相关的 3 条信息应该够了吧」。实测发现完全不够。用户问复杂问题时,答案可能分散在 5-10 个文档片段里,Top-3 只能覆盖一部分。
举个例子:
用户问:「新员工入职需要办哪些手续?」
答案涉及:
- 人事合同签署(在人事制度文档)
- 工牌办理(在行政流程文档)
- 电脑领用(在 IT 资产管理文档)
- 账号开通(在信息安全文档)
这 4 个信息分布在 4 个不同的文档片段里。如果只检索 Top-3,至少漏掉 1 个。
我做过测试:
| Top-K | 检索召回率 | Token 消耗 | 回答完整性 |
|---|---|---|---|
| 3 | 62% | 低 | 经常缺信息 |
| 5 | 78% | 中 | 大部分完整 |
| 10 | 91% | 高 | 基本完整 |
| 20 | 96% | 很高 | 完整,但噪声多 |
解决方案
Top-K 设 10,但要做重排序(Rerank),把真正相关的片段提到前面。
Rerank 的原理:
- 先用向量检索召回 Top-20(快,但不够精准)
- 用 Cross-Encoder 模型对 20 个候选片段重新打分(慢,但精准)
- 取重排序后的 Top-10 喂给 LLM
Python - Rerank 示例
from sentence_transformers import CrossEncoder # 加载 Rerank 模型 reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2') # 候选片段(从向量检索得到的 Top-20) candidates = ["片段1", "片段2", ..., "片段20"] # 用 Cross-Encoder 重新打分 scores = reranker.predict([(query, doc) for doc in candidates]) # 按分数排序,取 Top-10 ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)[:10]坑 4:没有做重排序(Rerank)
这个坑和坑 3 直接相关。增大 Top-K 可以提高召回率,但会引入噪声。
坑点描述
向量检索只看「语义相似度」,不看「问题相关性」。比如用户问「如何报销」,检索结果里可能有「报销流程」(相关)和「报销被拒的案例」(不相关,但语义相似)。如果直接把这些喂给 LLM,它会混淆。
Rerank 的作用:过滤噪声,把真正相关的片段提到前面。
实际测试数据:
| 方法 | 准确率 | 延迟 |
|---|---|---|
| 仅向量检索 Top-10 | 68% | 150ms |
| 向量检索 + Rerank | 89% | 280ms |
延迟增加了 130ms,但准确率提升了 21 个百分点。值。
⚠️ 注意:Rerank 模型本身有计算成本。如果候选片段太多(比如 Top-50),Rerank 会很慢。推荐做法:向量检索 Top-20 → Rerank 取 Top-10。
坑 5:缺少回答验证机制
坑点描述
LLM 会编造答案。即使检索到的文档里没有相关信息,它也可能「一本正经地胡说八道」。我遇到过用户问「公司的竞争对手是谁」,系统回答了 3 家公司,但实际上文档里只提到了 1 家,另外 2 家是模型编的。
这是 RAG 系统最致命的问题:检索不到时,模型不会说「不知道」,而是编答案。
解决方案
三种方法叠加使用:
方法 1:Prompt 约束
System Prompt
你是一个企业知识库助手。 规则: 1. 只根据提供的文档内容回答问题 2. 如果文档中没有相关信息,回答「抱歉,知识库中没有相关信息」 3. 不要编造或推断答案 4. 回答时标注信息来源(文档名称)方法 2:置信度阈值
让模型输出置信度分数,低于阈值就拒绝回答:
Python - 置信度检查
import openai response = openai.chat.completions.create( model="gpt-4", messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_query} ], temperature=0, logprobs=True # 返回 token 的概率 ) # 计算平均置信度 avg_logprob = sum(response.choices[0].logprobs.content) / len(response.choices[0].logprobs.content) confidence = math.exp(avg_logprob) if confidence < 0.7: return "抱歉,我对这个问题的回答不够确信,建议咨询人工客服。"方法 3:引用来源
要求模型在回答中标注引用的文档片段 ID:
回答示例
根据公司报销制度(文档ID: doc_001): 报销流程如下: 1. 登录 OA 系统,进入「财务审批」模块 2. 填写报销单,上传发票扫描件(PDF 格式) 3. 提交给直属领导审批 4. 审批通过后,财务会在 3 个工作日内打款 来源:[doc_001, doc_003]用户看到来源标注,至少能判断答案是否可信。
总结:RAG 优化的 5 个关键点
| 坑点 | 问题 | 解决方案 |
|---|---|---|
| 向量数据库选型错误 | FAISS 不支持增量更新 | 换 Qdrant / Milvus / Pinecone |
| 文档切片策略不对 | 固定字数切断语义 | 按段落/章节切分,或用 RecursiveCharacterTextSplitter |
| 检索 Top-K 太小 | 复杂问题答案分散 | Top-K 设 10-20,配合 Rerank |
| 没有做重排序 | 向量检索不精准 | 用 Cross-Encoder Rerank |
| 缺少回答验证 | 模型编造答案 | Prompt 约束 + 置信度阈值 + 引用来源 |
搭建 RAG 系统的门槛很低,LangChain + OpenAI 50 行代码就能跑起来。但让它「好用」——检索精准、回答稳定、不编造——需要在这些细节上反复打磨。
如果只记住一条:向量检索只是第一步,Rerank 和回答验证才是区分「能跑」和「好用」的关键。
如果对你有帮助,欢迎在评论区聊聊你踩过的 RAG 坑。
