首发于「林间昭语」 | 作者:程序员林间 | 阅读时间:约 12 分钟
关联阅读:前一篇《上线!把知识库接入企业微信/钉钉》
一篇文章,讲完我踩过的所有 RAG 坑
从 2024 年开始做 RAG 项目,到现在已经交付了十几个知识库系统。
这一年多我踩过的坑,比写过的代码还多。
这篇文章把我踩过的坑全部整理出来,每一个都是真实案例,每一个都花了真金白银和时间才解决。
希望你能绕过这些坑。
一、文档处理踩过的坑
坑 1:PDF 解析出来全是乱码
问题:用 PyPDFLoader 解析一份合同 PDF,解析出来的文字是乱码。
原因:这是一份扫描件(图片转 PDF),普通 PDF 解析器读不出图片里的文字。
解决:
# 先判断是否是扫描件
def is_scanned_pdf(pdf_path):
from pypdf import PdfReader
reader = PdfReader(pdf_path)
for page in reader.pages:
if "/XObject" in page["/Resources"]:
xobjects = page["/Resources"]["/XObject"].get_object()
for obj in xobjects:
if xobjects[obj]["/Subtype"] == "/Image":
return True
return False
# 如果是扫描件,用 OCR 处理
from paddleocr import PaddleOCR
def ocr_pdf(pdf_path):
ocr = PaddleOCR(use_angle_cls=True, lang='ch')
result = ocr.ocr(pdf_path, cls=True)
text = "\n".join([line[1][0] for line in result[0]])
return text
经验:处理 PDF 前先判断是否是扫描件,是就用 OCR,不是就直接解析。
坑 2:Word 文档里的表格消失了一半
问题:解析一份 Word 手册,表格内容全丢了。
原因:LangChain 的 Docx2txtLoader 对表格支持不好。
解决:
from docx import Document
def extract_text_with_tables(doc_path):
doc = Document(doc_path)
full_text = []
for para in doc.paragraphs:
full_text.append(para.text)
for table in doc.tables:
for row in table.rows:
row_text = " | ".join([cell.text.strip() for cell in row.cells])
full_text.append(row_text)
return "\n".join(full_text)
坑 3:HTML 网页解析出来一堆广告
问题:用 WebBaseLoader 抓取一个产品官网,解析结果里 80% 是广告和导航栏。
解决:
from langchain_community.document_loaders import BSHTMLLoader
loader = BSHTMLLoader(
file_path="product.html",
open_encoding="utf-8"
)
# 只保留 main、article、content 标签
from bs4 import BeautifulSoup
def clean_html(html_content):
soup = BeautifulSoup(html_content, "html.parser")
# 删除不需要的元素
for unwanted in soup(["script", "style", "nav", "footer", "aside"]):
unwanted.decompose()
# 只保留主要内容
main_content = soup.find("main") or soup.find("article") or soup.find("body")
return main_content.get_text(separator="\n", strip=True)
二、文本分块踩过的坑
坑 4:块太小,导致回答不完整
问题:设置 chunk_size=128,检索出来的内容总是"半截话",LLM 无法理解完整意思。
原因:128 字符太小,一个完整的意思还没说完就被切断了。
解决:改成 256 字符。
splitter = RecursiveCharacterTextSplitter(
chunk_size=256, # 从 128 改成 256
chunk_overlap=64
)
坑 5:按固定字符分块,把一句话切成两半
问题:固定字符分块,把"根据《劳动合同法》第十条"切成"根据《劳动" + "合同法》第十条"。
解决:用递归分割,按段落、句子分割,而不是按固定字符。
# ❌ 错误:固定字符
splitter = CharacterTextSplitter(chunk_size=500)
# ✅ 正确:递归分割
splitter = RecursiveCharacterTextSplitter(
chunk_size=256,
chunk_overlap=64,
separators=["\n\n", "\n", "。", ","] # 按语义层级分割
)
坑 6:不同格式文档用同一套分块参数
问题:合同文档和产品手册用同样的参数,效果差异巨大。
原因:合同是条款式文档(一句话一个条款),手册是描述式文档(一段话描述一个功能)。
解决:针对不同文档类型,用不同策略。
def get_splitter_for_doc(doc_type):
if doc_type == "contract":
# 合同:按条款分割
return RecursiveCharacterTextSplitter(
chunk_size=500,
separators=["第", "条", "\n", "。"]
)
elif doc_type == "manual":
# 手册:按段落分割
return RecursiveCharacterTextSplitter(
chunk_size=256,
separators=["\n\n", "\n", "。"]
)
else:
# 默认
return RecursiveCharacterTextSplitter(chunk_size=256)
三、Embedding 踩过的坑
坑 7:用英文 Embedding 模型处理中文文档
问题:用 text-embedding-ada-002 处理中文法律文档,召回率只有 40%。
原因:英文 Embedding 模型对中文语义理解能力很弱。
解决:换成中文优化的 Embedding 模型。
# ❌ 错误:用英文模型
embedding = OpenAIEmbeddings(model="text-embedding-ada-002")
# ✅ 正确:用中文模型
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
embedding = HuggingFaceBgeEmbeddings(
model_name="BAAI/bge-large-zh" # 中文效果最好
)
坑 8:Embedding 向量维度不匹配
问题:存入向量数据库时报错"vector dimension mismatch"。
原因:Embedding 模型输出的维度(1024 维)和数据库 collection 定义的维度(768 维)不一致。
解决:创建 collection 时指定正确的维度。
# 创建 collection 时指定维度
client.create_collection(
collection_name="my_kb",
vectors_config=VectorParams(
size=1024, # 必须和 Embedding 输出维度一致
distance=Distance.COSINE
)
)
坑 9:换了个 Embedding 模型,效果反而变差
问题:从 M3E 换成 BGE-large-zh,原来的向量库全部失效。
原因:不同 Embedding 模型生成的向量空间不同,无法混用。
解决:Embedding 模型一旦确定就不要换,换了必须重建向量库。
# 如果必须换模型,需要重建向量库
def rebuild_vector_store(new_embedding_model, documents):
# 1. 用新模型重新生成向量
vectors = new_embedding_model.embed_documents(documents)
# 2. 清空旧数据
client.delete_collection(collection_name="my_kb")
# 3. 存入新向量
# ...
四、向置数据库踩过的坑
坑 10:Chroma 数据丢失
问题:用 Chroma 存了 10 万条向量,重启服务后数据全没了。
原因:Chroma 的持久化在某些情况下不稳定,尤其在 Mac 和 Windows 上。
解决:生产环境换用 Qdrant 或 Milvus。
# ❌ 错误:Chroma 生产环境
vectorstore = Chroma.from_documents(documents, embedding)
# ✅ 正确:Qdrant 生产环境
vectorstore = Qdrant.from_documents(
documents, embedding,
client=client,
collection_name="my_kb"
)
坑 11:向量检索很慢,查询要 3 秒
问题:100 万条向量,查询一次要 3 秒,太慢了。
原因:没有建索引,用的是暴力搜索。
解决:建立向量索引。
# Qdrant 建索引
client.create_index(
collection_name="my_kb",
vectors_config=VectorParams(
size=1024,
distance=Distance.COSINE,
quantization_config=QuantizationConfig(
type=QuantizationType.FP16,
always_on=True
)
)
)
坑 12:删除了文档,但向量库里还有
问题:删除了源文档,向量数据库里还有这条记录。
原因:向量数据库是独立存储的,删除源文件不会自动同步。
解决:用 UUID 或文件哈希做 ID,建立双向映射。
import hashlib
def generate_doc_id(content):
return hashlib.md5(content.encode()).hexdigest()
# 存向量时记录映射
doc_id = generate_doc_id(page_content)
vectorstore.add_texts(
texts=[page_content],
ids=[doc_id],
metadatas=[{"source": "contract.pdf", "page": 1}]
)
# 删除时用同样的 ID 删除
vectorstore.delete(ids=[doc_id])
五、LLM 生成踩过的坑
坑 13:LLM 回答的内容是错的
问题:文档里没有这个信息,但 LLM 硬是"编"了一个答案。
原因:LLM 有幻觉,不用 Prompt 约束它就会编。
解决:在 Prompt 里明确加约束。
system_prompt = """你是一个企业知识库助手。
【严格规则】
1. 只基于提供的参考资料回答,不要编造任何信息
2. 如果参考资料中没有相关信息,明确回复"抱歉,我无法从现有资料中找到这个问题的答案"
3. 回答必须标注参考来源
【回答格式】
回答:<你的回答>
参考来源:<文档名或来源>
"""
llm = ChatOpenAI(
model="deepseek-chat",
temperature=0.1 # 降低随机性
)
坑 14:回答太慢,一次要 30 秒
问题:用户发一个问题,要等 30 秒才能收到回答。
原因:每次都调用 LLM 生成,太慢。
解决:加缓存 + 流式输出。
from langchain.cache import InMemoryCache
# 启用缓存
llm_cache = InMemoryCache()
llm = ChatOpenAI(
model="deepseek-chat",
caching=True,
temperature=0.1
)
# 流式输出(用户体验更好)
def stream_answer(question):
response = llm.stream(f"请回答:{question}")
for chunk in response:
print(chunk.content, end="", flush=True)
坑 15:回答太短,总是"抱歉我不知道"
问题:加了"不确定就说不知道"的约束后,LLM 变得过度保守,稍微不确定就不回答。
解决:调整 Prompt,让 LLM 适度自信。
system_prompt = """你是一个企业知识库助手。
【回答规则】
1. 优先基于参考资料回答,如果参考资料中有相关信息,给出答案
2. 只有在参考资料完全无法回答问题时,才说"抱歉,我无法从现有资料中找到答案"
3. 如果资料中有部分相关信息,可以基于这部分信息给出答案,同时说明"根据现有资料,..."
【回答格式】
回答:<你的回答>
参考来源:<文档名>
"""
六、上线运维踩过的坑
坑 16:上线后效果慢慢变差
问题:刚上线时召回率 90%,三个月后降到 60%。
原因:知识库没有更新,文档变了但向量库没同步。
解决:建立定时重建索引机制。
import schedule
import time
def rebuild_index():
print("开始重建向量索引...")
# 1. 重新加载文档
documents = load_all_documents()
# 2. 重新分块
chunks = split_documents(documents)
# 3. 重新生成向量并存储
vectorstore = Qdrant.from_documents(
documents=chunks,
embedding=embedding,
collection_name="my_kb"
)
print("向量索引重建完成 ✅")
# 每周日凌晨 3 点重建
schedule.every().sunday.at("03:00").do(rebuild_index)
while True:
schedule.run_pending()
time.sleep(60)
坑 17:用户量上来后系统崩溃
问题:200 个用户同时访问,系统响应不过来了。
原因:没有做限流和并发控制。
解决:加限流 + 异步处理。
from functools import wraps
from queue import Queue
import threading
# 简单限流
class RateLimiter:
def __init__(self, max_per_second=10):
self.max_per_second = max_per_second
self.calls = []
def __call__(self, func):
@wraps(func)
def wrapper(*args, **kwargs):
now = time.time()
self.calls = [t for t in self.calls if now - t < 1]
if len(self.calls) >= self.max_per_second:
time.sleep(1)
self.calls.append(now)
return func(*args, **kwargs)
return wrapper
limiter = RateLimiter(max_per_second=10)
@limiter
def handle_question(question):
return rag_chain.invoke({"query": question})
坑 18:敏感数据泄露
问题:知识库里有一些客户敏感信息,不小心被其他用户搜到了。
解决:加访问控制和敏感词过滤。
# 敏感词过滤
SENSITIVE_WORDS = ["密码", "身份证", "银行卡", "薪资", "机密"]
def filter_sensitive(text):
for word in SENSITIVE_WORDS:
if word in text:
return "[内容已脱敏]"
return text
def check_permission(user_id, doc_id):
# 检查用户是否有权限访问这个文档
allowed_docs = get_user_allowed_docs(user_id)
return doc_id in allowed_docs
七、总结
18 个坑,18 条经验:
| 类别 | 坑数 | 核心教训 |
|------|------|----------|
| 文档处理 | 3 | PDF 要先判断是否扫描 |
| 文本分块 | 3 | 256 字符是黄金值 |
| Embedding | 3 | 中文用 BGE-large-zh |
| 向量数据库 | 3 | 生产环境用 Qdrant |
| LLM 生成 | 3 | Prompt 要加幻觉约束 |
| 运维 | 3 | 要定期重建索引 |
下一步
这 18 个坑,是我用一年多时间、十几个项目、真金白银踩出来的。
希望你可以绕过这些坑,少走弯路。
如果你正在做 RAG 项目,有什么问题欢迎扫码聊一聊。
我可以帮你诊断现有系统,避免踩坑。
备注"避坑",送你一份本文的《RAG 踩坑检查表》👇
关注「林间昭语」,用技术创造可能。
点击上方蓝色公众号名称 → 设为星标 🌟,第一时间收到干货。