引言

RAG(Retrieval-Augmented Generation)是当前最流行的 LLM 应用架构之一。然而,从 Demo 到生产之间横亘着巨大的鸿沟。本文基于多个 RAG 生产项目的实战经验,总结 12 个最常见、最致命的坑,并给出经过验证的解决方案。

坑 1:文档分块策略不当

问题

天真地按固定长度分块(如每 512 字符),导致:

  • 语义被截断(一个完整的段落从中间切断)
  • 关键信息分散在多个块中,检索时只命中一部分
  • 表格和列表被拆碎,失去结构信息

解决方案:语义分块 + 重叠窗口

from dataclasses import dataclass
from typing import List
import re

@dataclass
class Chunk:
    text: str
    metadata: dict
    token_count: int = 0

class SemanticChunker:
    """基于语义边界的智能分块器"""
    
    def __init__(
        self,
        target_size: int = 400,      # 目标块大小(tokens)
        min_size: int = 100,          # 最小块大小
        max_size: int = 600,          # 最大块大小
        overlap: int = 50,            # 重叠区间
    ):
        self.target_size = target_size
        self.min_size = min_size
        self.max_size = max_size
        self.overlap = overlap
    
    def chunk_document(self, text: str, source: str = "") -> List[Chunk]:
        """分块主流程"""
        # Step 1: 按结构边界切分
        sections = self._split_by_structure(text)
        
        # Step 2: 对每个 section 按 paragraph 切分
        paragraphs = []
        for section in sections:
            paragraphs.extend(self._split_by_paragraph(section))
        
        # Step 3: 合并过小的段落,拆分过大的段落
        chunks = self._merge_and_split(paragraphs)
        
        # Step 4: 添加重叠
        chunks = self._add_overlap(chunks)
        
        # Step 5: 附加元数据
        return [
            Chunk(
                text=c,
                metadata={"source": source, "chunk_index": i, "total_chunks": len(chunks)},
                token_count=len(c) // 2,  # 粗略估算
            )
            for i, c in enumerate(chunks)
        ]
    
    def _split_by_structure(self, text: str) -> List[str]:
        """按标题、分隔符等结构边界切分"""
        # 按 Markdown 标题切分
        pattern = r'(?=^#{1,6}\s)'
        sections = re.split(pattern, text, flags=re.MULTILINE)
        return [s.strip() for s in sections if s.strip()]
    
    def _split_by_paragraph(self, text: str) -> List[str]:
        """按段落(双换行)切分"""
        paras = text.split("\n\n")
        return [p.strip() for p in paras if p.strip()]
    
    def _merge_and_split(self, paragraphs: List[str]) -> List[str]:
        """合并过小段落,拆分过大段落"""
        chunks = []
        buffer = []
        buffer_size = 0
        
        for para in paragraphs:
            para_size = len(para) // 2  # 粗略 token 估算
            
            if buffer_size + para_size > self.max_size and buffer:
                chunks.append("\n\n".join(buffer))
                buffer = []
                buffer_size = 0
            
            if para_size > self.max_size:
                # 单段落过长,按句子切分
                if buffer:
                    chunks.append("\n\n".join(buffer))
                    buffer = []
                    buffer_size = 0
                sentences = re.split(r'(?<=[。!?.!?])\s+', para)
                sent_buffer = []
                sent_size = 0
                for sent in sentences:
                    if sent_size + len(sent) // 2 > self.max_size and sent_buffer:
                        chunks.append(" ".join(sent_buffer))
                        sent_buffer = []
                        sent_size = 0
                    sent_buffer.append(sent)
                    sent_size += len(sent) // 2
                if sent_buffer:
                    chunks.append(" ".join(sent_buffer))
            else:
                buffer.append(para)
                buffer_size += para_size
                
                if buffer_size >= self.target_size:
                    chunks.append("\n\n".join(buffer))
                    buffer = []
                    buffer_size = 0
        
        if buffer:
            chunks.append("\n\n".join(buffer))
        
        return chunks
    
    def _add_overlap(self, chunks: List[str]) -> List[str]:
        """为相邻块添加重叠"""
        if self.overlap <= 0 or len(chunks) <= 1:
            return chunks
        
        result = [chunks[0]]
        for i in range(1, len(chunks)):
            prev_text = chunks[i - 1]
            overlap_text = prev_text[-self.overlap * 2:]  # 粗略取后半段
            result.append(overlap_text + " " + chunks[i])
        
        return result


# 使用示例
chunker = SemanticChunker(target_size=400, overlap=50)
document = open("knowledge_base/product_manual.md").read()
chunks = chunker.chunk_document(document, source="product_manual.md")
print(f"分块完成: {len(chunks)} 个块")

分块策略对比

策略优点缺点适用场景
固定长度实现简单语义截断不推荐
按段落保持语义块大小不均短文档
语义分块语义完整实现复杂通用推荐
按文档结构保持层级需要结构化输入Markdown/HTML
递归分块灵活适配可控性差混合内容

坑 2:Embedding 模型与 LLM 不匹配

问题

用 OpenAI 的 text-embedding-3-large 做向量,但生成用的是 Claude 模型。两者对语义的理解不同,可能导致检索到的内容并非生成模型"认为"最相关的。

解决方案

# 统一使用同一生态的模型,或充分评测跨模型组合
EMBEDDING_MODEL_CONFIGS = {
    "openai_3_large": {
        "model": "text-embedding-3-large",
        "dim": 3072,
        "best_with": ["gpt-4o", "gpt-4o-mini"],
        "cost_per_1k": 0.00013,
    },
    "openai_3_small": {
        "model": "text-embedding-3-small",
        "dim": 1536,
        "best_with": ["gpt-4o-mini"],
        "cost_per_1k": 0.00002,
    },
    "cohere_multilingual": {
        "model": "embed-multilingual-v3",
        "dim": 1024,
        "best_with": ["command-r-plus"],
        "cost_per_1k": 0.0001,
    },
}

坑 3:向量检索召回率低

问题

纯向量检索(纯语义相似度)在以下场景效果差:

  • 精确匹配(产品型号、订单号)
  • 缩写和专有名词
  • 数字和日期范围查询

解决方案:混合检索

from rank_bm25 import BM25Okapi
import numpy as np

class HybridRetriever:
    """混合检索:向量 + BM25"""
    
    def __init__(self, documents: list[Chunk], embeddings: np.ndarray, weight: float = 0.5):
        self.documents = documents
        self.embeddings = embeddings
        self.vector_weight = weight
        self.keyword_weight = 1 - weight
        
        # 构建 BM25 索引
        tokenized_docs = [doc.text.lower().split() for doc in documents]
        self.bm25 = BM25Okapi(tokenized_docs)
    
    def search(self, query: str, query_embedding: np.ndarray, top_k: int = 5) -> list[dict]:
        # 向量检索
        vector_scores = self._cosine_scores(query_embedding)
        
        # BM25 检索
        tokenized_query = query.lower().split()
        bm25_scores = self.bm25.get_scores(tokenized_query)
        
        # 归一化
        vector_scores = self._normalize(vector_scores)
        bm25_scores = self._normalize(bm25_scores)
        
        # 融合
        combined = (
            self.vector_weight * vector_scores +
            self.keyword_weight * bm25_scores
        )
        
        # 取 Top-K
        top_indices = np.argsort(combined)[-top_k:][::-1]
        
        return [
            {
                "chunk": self.documents[i],
                "score": combined[i],
                "vector_score": vector_scores[i],
                "bm25_score": bm25_scores[i],
            }
            for i in top_indices
        ]
    
    def _cosine_scores(self, query_emb: np.ndarray) -> np.ndarray:
        return self.embeddings @ query_emb / (
            np.linalg.norm(self.embeddings, axis=1) * np.linalg.norm(query_emb) + 1e-8
        )
    
    def _normalize(self, scores: np.ndarray) -> np.ndarray:
        min_val, max_val = scores.min(), scores.max()
        if max_val - min_val < 1e-8:
            return np.zeros_like(scores)
        return (scores - min_val) / (max_val - min_val)


# 检索效果对比
检索方式语义查询精确查询混合查询延迟
纯向量优秀一般5ms
纯 BM25优秀一般1ms
混合检索优秀优秀优秀6ms

坑 4:忽略了查询重写

问题

用户原始查询往往口语化、模糊或缺乏关键信息,直接用于检索效果很差。

解决方案

class QueryRewriter:
    """查询重写器"""
    
    def __init__(self, llm_client):
        self.client = llm_client
    
    def rewrite(self, query: str, conversation_history: list[dict] = None) -> list[str]:
        """生成多个重写查询以提高召回"""
        
        prompt = """将用户查询重写为 3 个不同的检索查询。
要求:
1. 补充隐含的关键词
2. 使用不同的表述方式
3. 包含同义词和相关术语

对话历史:
{history}

原始查询: {query}

输出格式(JSON):
{{"queries": ["查询1", "查询2", "查询3"]}}
""".format(
            history=conversation_history or "无",
            query=query,
        )
        
        response = self.client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            response_format={"type": "json_object"},
        )
        
        import json
        result = json.loads(response.choices[0].message.content)
        return [query] + result["queries"]  # 原始查询 + 重写查询

坑 5:上下文窗口溢出

问题

检索返回 10 个文档块,每块 500 tokens,加上系统提示和对话历史,总 token 数超出模型限制。

解决方案

class ContextWindowManager:
    """RAG 上下文窗口管理器"""
    
    def __init__(self, max_context_tokens: int = 6000):
        self.max_context = max_context_tokens
    
    def build_context(
        self,
        system_prompt: str,
        conversation: list[dict],
        retrieved_chunks: list[dict],
    ) -> list[dict]:
        """在 token 预算内构建最优上下文"""
        
        budget = self.max_context
        system_tokens = self._estimate_tokens(system_prompt)
        budget -= system_tokens
        
        # 预留生成空间
        generation_reserve = 1024
        budget -= generation_reserve
        
        # 对话历史(保留最近 N 轮)
        conversation_tokens = sum(self._estimate_tokens(m["content"]) for m in conversation[-6:])
        budget -= conversation_tokens
        
        if budget <= 0:
            # 严重不足,只保留系统提示和最后一条消息
            return [
                {"role": "system", "content": system_prompt},
                *conversation[-2:],
            ]
        
        # 填充检索结果
        selected_chunks = []
        for chunk_data in retrieved_chunks:
            chunk_tokens = self._estimate_tokens(chunk_data["chunk"].text)
            if chunk_tokens <= budget:
                selected_chunks.append(chunk_data)
                budget -= chunk_tokens
            else:
                # 尝试截断
                truncated = chunk_data["chunk"].text[:budget * 2]
                selected_chunks.append({**chunk_data, "chunk": Chunk(
                    text=truncated + "...[截断]",
                    metadata=chunk_data["chunk"].metadata,
                )})
                break
        
        # 组装最终上下文
        context_text = "\n\n---\n\n".join([
            f"[文档 {i+1}] (相关度: {c['score']:.2f})\n{c['chunk'].text}"
            for i, c in enumerate(selected_chunks)
        ])
        
        return [
            {"role": "system", "content": system_prompt},
            {"role": "system", "content": f"参考文档:\n{context_text}"},
            *conversation[-6:],
        ]
    
    def _estimate_tokens(self, text: str) -> int:
        return len(text) // 2  # 中英混合粗略估算

坑 6:缺少重排序(Reranking)

问题

向量检索返回的 Top-K 结果中,最相关的未必排在第一位。直接把全部结果塞给 LLM,会导致"中间迷失"(lost in the middle)效应。

解决方案

class Reranker:
    """使用 Cross-Encoder 重排序"""
    
    def __init__(self, model_name: str = "bge-reranker-v2-m3"):
        from sentence_transformers import CrossEncoder
        self.model = CrossEncoder(model_name)
    
    def rerank(self, query: str, documents: list[str], top_k: int = 3) -> list[dict]:
        """对检索结果重排序"""
        pairs = [(query, doc) for doc in documents]
        scores = self.model.predict(pairs)
        
        ranked = sorted(
            [{"document": doc, "score": float(score), "rank": i}
             for i, (doc, score) in enumerate(zip(documents, scores))],
            key=lambda x: x["score"],
            reverse=True,
        )
        
        # 只返回 Top-K
        for i, item in enumerate(ranked[:top_k]):
            item["rank"] = i + 1
        
        return ranked[:top_k]

重排序效果

指标无重排序有重排序提升
Top-1 准确率62%81%+19%
Top-3 准确率78%93%+15%
端到端答案准确率71%86%+15%
检索延迟5ms25ms+20ms

坑 7:表格与结构化数据丢失

问题

Markdown 表格被分块拆散,LLM 拿到的是不完整的表格片段,无法正确理解行列关系。

解决方案

class TableAwareChunker:
    """表格感知的分块器"""
    
    def chunk(self, text: str) -> list[str]:
        chunks = []
        lines = text.split("\n")
        i = 0
        
        while i < len(lines):
            line = lines[i]
            
            # 检测表格开始
            if "|" in line and i + 1 < len(lines) and "---" in lines[i + 1]:
                # 收集完整表格
                table_lines = [line]
                i += 1
                while i < len(lines) and "|" in lines[i]:
                    table_lines.append(lines[i])
                    i += 1
                # 表格作为整体块
                chunks.append("\n".join(table_lines))
            else:
                # 普通文本行
                chunks.append(line)
                i += 1
        
        # 合并相邻文本块
        return self._merge_text_chunks(chunks)
    
    def _merge_text_chunks(self, chunks: list[str]) -> list[str]:
        merged = []
        text_buffer = []
        
        for chunk in chunks:
            if "|" in chunk and "---" in chunk:
                if text_buffer:
                    merged.append("\n".join(text_buffer))
                    text_buffer = []
                merged.append(chunk)
            else:
                text_buffer.append(chunk)
        
        if text_buffer:
            merged.append("\n".join(text_buffer))
        
        return merged

坑 8:多语言场景处理不当

问题

中英文混合文档的 Embedding 效果差,跨语言检索准确率低。

解决方案

class MultiLanguageHandler:
    """多语言处理器"""
    
    def __init__(self):
        self.language_routes = {
            "zh": {"embedding_model": "text-embedding-3-large", "index": "idx_zh"},
            "en": {"embedding_model": "text-embedding-3-large", "index": "idx_en"},
            "ja": {"embedding_model": "text-embedding-3-large", "index": "idx_ja"},
        }
    
    def detect_language(self, text: str) -> str:
        """简单语言检测"""
        chinese_chars = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
        if chinese_chars / max(len(text), 1) > 0.3:
            return "zh"
        return "en"
    
    def translate_query(self, query: str, target_lang: str) -> str:
        """查询翻译(使用 LLM)"""
        # 对于多语言知识库,统一翻译为英文检索
        # 或分别用原语言和翻译语言检索,合并结果
        pass
    
    def search_multilingual(self, query: str, top_k: int = 5) -> list[dict]:
        """多语言搜索"""
        lang = self.detect_language(query)
        
        # 1. 用原始语言检索
        results_original = self._search(query, self.language_routes[lang]["index"])
        
        # 2. 翻译后检索
        translated = self.translate_query(query, "en" if lang != "en" else "zh")
        results_translated = self._search(translated, self.language_routes["en" if lang != "en" else "zh"]["index"])
        
        # 3. 合并去重
        merged = self._merge_results(results_original, results_translated)
        return merged[:top_k]

坑 9:缺少答案溯源

问题

LLM 生成的答案无法追溯到来源文档,用户无法验证信息的准确性。

解决方案

class CitationTracker:
    """答案引用追踪"""
    
    def build_prompt_with_citations(
        self, query: str, retrieved_chunks: list[dict]
    ) -> str:
        """构建带引用标记的 Prompt"""
        
        doc_section = "参考文档:\n"
        for i, chunk in enumerate(retrieved_chunks):
            doc_section += f"\n[文档{i+1}] (来源: {chunk['source']}, 相关度: {chunk['score']:.2f})\n"
            doc_section += chunk["text"] + "\n"
        
        prompt = f"""{doc_section}

基于上述参考文档回答用户问题。要求:
1. 只使用参考文档中的信息
2. 在每个关键陈述后标注来源,格式:[文档X]
3. 如果参考文档中没有答案,明确说"提供的文档中没有相关信息"

用户问题: {query}
"""
        return prompt
    
    def extract_citations(self, answer: str) -> list[dict]:
        """从答案中提取引用"""
        import re
        citations = []
        pattern = r'\[文档(\d+)\]'
        
        for match in re.finditer(pattern, answer):
            citations.append({
                "doc_id": int(match.group(1)),
                "position": match.start(),
            })
        
        return citations

坑 10:增量更新导致索引不一致

问题

知识库更新后,向量索引未同步更新;或更新过程中部分旧向量残留,导致检索到已删除的内容。

解决方案

class IncrementalIndexer:
    """增量索引管理器"""
    
    def __init__(self, vector_store, document_store):
        self.vector_store = vector_store
        self.doc_store = document_store
    
    def update_document(self, doc_id: str, new_content: str):
        """更新单个文档"""
        # 1. 删除旧向量
        self.vector_store.delete(filter={"doc_id": doc_id})
        
        # 2. 更新文档存储
        self.doc_store.update(doc_id, new_content)
        
        # 3. 重新分块
        chunks = self.chunker.chunk_document(new_content, source=doc_id)
        
        # 4. 生成新向量
        embeddings = self.embedding_model.encode([c.text for c in chunks])
        
        # 5. 写入向量存储
        self.vector_store.upsert(
            ids=[f"{doc_id}_chunk_{i}" for i in range(len(chunks))],
            embeddings=embeddings,
            metadatas=[{**c.metadata, "doc_id": doc_id, "version": "latest"} for c in chunks],
        )
    
    def delete_document(self, doc_id: str):
        """安全删除文档及其所有向量"""
        # 先标记,再删除
        self.doc_store.mark_deleted(doc_id)
        self.vector_store.delete(filter={"doc_id": doc_id})
        self.doc_store.actual_delete(doc_id)

坑 11:评估指标缺失

问题

没有量化指标,无法判断 RAG 系统是变好了还是变差了。

解决方案:RAG 三维评估框架

@dataclass
class RAGEvaluation:
    """RAG 系统评估"""
    
    def evaluate_retrieval(self, test_set: list[dict]) -> dict:
        """评估检索质量"""
        metrics = {"hit_rate": 0, "mrr": 0, "ndcg": 0}
        
        for case in test_set:
            retrieved = self.retrieve(case["query"], top_k=5)
            relevant_ids = set(case["relevant_doc_ids"])
            retrieved_ids = [r["doc_id"] for r in retrieved]
            
            # Hit Rate: Top-K 中是否包含相关文档
            hit = int(bool(set(retrieved_ids) & relevant_ids))
            
            # MRR: 第一个相关文档的倒数排名
            mrr = 0
            for rank, rid in enumerate(retrieved_ids, 1):
                if rid in relevant_ids:
                    mrr = 1 / rank
                    break
            
            metrics["hit_rate"] += hit
            metrics["mrr"] += mrr
        
        n = len(test_set)
        return {k: v / n for k, v in metrics.items()}
    
    def evaluate_generation(self, test_set: list[dict]) -> dict:
        """评估生成质量"""
        metrics = {"faithfulness": 0, "answer_relevance": 0, "context_precision": 0}
        
        # 使用 RAGAS 或类似框架
        # faithfulness: 答案是否忠实于检索到的上下文
        # answer_relevance: 答案是否回应了用户问题
        # context_precision: 检索到的上下文是否相关
        
        return metrics

RAG 评估指标体系

维度指标说明目标值
检索Hit Rate@5Top-5 中命中相关文档的比例>90%
检索MRR平均倒数排名>0.7
检索NDCG@5归一化折损累积增益>0.8
生成Faithfulness答案忠实于上下文的比例>95%
生成Answer Relevance答案与问题的相关度>0.85
生成Context Precision上下文精确度>0.8

坑 12:多跳推理失败

问题

复杂问题需要跨多个文档推理(如"对比 A 产品和 B 产品在 X 方面的差异"),单次检索无法获取所有必要信息。

解决方案:迭代检索

class IterativeRetriever:
    """迭代检索:多步推理"""
    
    def __init__(self, retriever, llm_client, max_iterations: int = 3):
        self.retriever = retriever
        self.llm = llm_client
        self.max_iterations = max_iterations
    
    def search(self, query: str) -> dict:
        """多步迭代检索"""
        all_contexts = []
        reasoning_chain = []
        
        current_query = query
        
        for i in range(self.max_iterations):
            # 检索
            results = self.retriever.search(current_query, top_k=3)
            all_contexts.extend(results)
            
            # 判断是否需要继续检索
            decomposition_prompt = f"""基于已有信息,判断是否需要进一步检索。

原始问题: {query}
已检索信息: {[r['chunk'].text[:200] for r in all_contexts]}

如果需要更多信息,生成下一个检索查询。如果信息足够,返回 "SUFFICIENT"。
"""
            response = self.llm.chat.completions.create(
                model="gpt-4o-mini",
                messages=[{"role": "user", "content": decomposition_prompt}],
                max_tokens=200,
            )
            
            next_action = response.choices[0].message.content.strip()
            
            if "SUFFICIENT" in next_action:
                reasoning_chain.append({"step": i, "action": "sufficient", "query": current_query})
                break
            
            current_query = next_action
            reasoning_chain.append({"step": i, "action": "search", "query": current_query})
        
        # 去重
        unique_contexts = self._deduplicate(all_contexts)
        
        return {
            "contexts": unique_contexts,
            "reasoning_chain": reasoning_chain,
            "iterations": len(reasoning_chain),
        }
    
    def _deduplicate(self, contexts: list[dict]) -> list[dict]:
        seen = set()
        unique = []
        for ctx in contexts:
            text_hash = hash(ctx["chunk"].text[:100])
            if text_hash not in seen:
                seen.add(text_hash)
                unique.append(ctx)
        return unique

总结

严重程度修复难度影响
分块策略不当🔴 高检索质量基础
Embedding 不匹配🟡 中语义理解偏差
向量召回率低🔴 高检索效果
查询未重写🟡 中召回率
上下文溢出🔴 高系统稳定性
缺少重排序🟡 中排序质量
表格数据丢失🔴 高结构化信息
多语言处理🟡 中多语言场景
答案无溯源🟡 中可信度
索引不一致🔴 高数据准确性
评估缺失🔴 高无法迭代
多跳推理失败🟡 中复杂问题

建议优先级:先修分块(坑1)和混合检索(坑3),再建评估体系(坑11),然后按业务场景逐个解决其余问题。RAG 不是一次性工程,而是持续优化的系统。


加入讨论

这篇文章有姊妹讨论帖在硅基AGI论坛 — 全球首个碳基硅基认知交流平台。

碳基与硅基的智慧碰撞,认知差异创造无限可能。