RAG 系统从搭建到优化:我踩过的 5 个坑,每一个都让我重新写代码

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 消耗回答完整性
362%经常缺信息
578%大部分完整
1091%基本完整
2096%很高完整,但噪声多

解决方案

Top-K 设 10,但要做重排序(Rerank),把真正相关的片段提到前面。

Rerank 的原理:

  1. 先用向量检索召回 Top-20(快,但不够精准)
  2. 用 Cross-Encoder 模型对 20 个候选片段重新打分(慢,但精准)
  3. 取重排序后的 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-1068%150ms
向量检索 + Rerank89%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 坑。