B06「我踩过的 RAG 坑」2024-2025 实战问题汇总

0 阅读9分钟

首发于「林间昭语」 | 作者:程序员林间 | 阅读时间:约 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 踩坑检查表》👇


关注「林间昭语」,用技术创造可能。

点击上方蓝色公众号名称 → 设为星标 🌟,第一时间收到干货。