← 返回博客
·AI技术

RAG 检索质量烂的根因:不是 embedding 模型不行,是你检索姿势错了

大多数 RAG 系统的瓶颈根本不在 LLM,而在检索阶段。本文分享二十多个 RAG 项目中踩过的真实坑点和解法。

#RAG#LLM#检索优化#Embedding

# RAG 检索质量烂的根因:不是 embedding 模型不行,是你检索姿势错了

做了二十多个 RAG 项目之后,我最想说的一句话是:**大多数 RAG 系统的瓶颈根本不在 LLM,而在检索阶段。**

LLM 再强,你喂给它一堆不相关的上下文,它也给你编一个看起来像样的废话。Embedding 模型再准,你的检索 query 写得不对,Top-K 返回的全是噪声,结果一样崩。

这篇文章说几个真实项目里踩过的坑,没有废话,直接给解法。

坑一:分块策略一刀切

最常见的错误是不管什么类型的内容,都用固定 token 数分块。

# 这是反面教材

text_splitter = RecursiveCharacterTextSplitter(

chunk_size=500,

chunk_overlap=50

)

500 tokens 的分块对普通文档可能还行,但对代码结构、表格数据、对话记录简直是灾难。

代码分块要按函数/类边界切:

import ast

class CodeChunker(ast.NodeVisitor):

def __init__(self):

self.chunks = []

self.current_class = None

def visit_ClassDef(self, node):

old_class = self.current_class

self.current_class = node.name

self.chunks.append({

"type": "class",

"name": node.name,

"content": ast.unparse(node)

})

self.generic_visit(node)

self.current_class = old_class

def visit_FunctionDef(self, node):

if self.current_class:

return # 类内方法已在 visit_ClassDef 里处理

self.chunks.append({

"type": "function",

"name": node.name,

"content": ast.unparse(node)

})

表格数据要整表检索,不要切成行:

def chunk_table(table_element):

"""表格作为独立块,包含完整的行列关系"""

return {

"type": "table",

"header": table_element.header_cells,

"rows": table_element.all_rows,

# 检索时整表召回,避免行孤立的语义缺失

}

坑二:Metadata 写了但没用上

Embedding 时把 metadata 存进去,检索时却只用向量相似度,metadata 全部吃灰。这是极大的浪费。

合理做法是在检索阶段加 metadata 过滤:

def retrieve_with_filter(query: str, filters: dict, top_k: int = 10):

"""元数据过滤 + 向量检索双重保险"""

# 第一步:用 metadata 预过滤,减少候选集

prefiltered = vector_store.filter(

date_range=(filters.get("start_date"), filters.get("end_date")),

category=filters.get("category"),

source=filters.get("source")

)

# 第二步:在过滤后的小候选集里做向量检索

results = embed_and_search(

query,

candidates=prefiltered,

top_k=top_k

)

# 第三步:MMR 防止返回内容太相似

return max_marginal_relevance(results, diversity_threshold=0.7)

Metadata 不只是"来源"、"日期"、"作者"这些通用字段。**你应该把最能区分这条数据含义的特征都存进去**。比如代码文档的"所属模块"、"函数签名",产品文档的"适用版本"、"目标用户"。

坑三:Query 改写太简单

用户说"我想查去年Q3的销售额对比",embedding 模型收到的 query 是"去年Q3的销售额对比"——这几个字的语义信息密度根本不够。

Query 改写(Query Rewriting)是被严重低估的一步:

def rewrite_query_for_rag(user_query: str, conversation_history: list = None):

"""用 LLM 把自然语言 query 改写成检索友好的形式"""

context = ""

if conversation_history:

context = f"对话历史:{conversation_history[-3:]}"

prompt = f"""{context}

用户问题:{user_query}

请将问题改写成适合向量检索的形式:

  • 补充隐含的上下文(如专有名词全称、时间范围)
  • 2. 拆解复合问题为多个检索 query

    3. 移除口语化表达,改用技术术语

    输出格式:JSON数组,每个元素是一个检索 query

    """

    response = llm.complete(prompt)

    return json.loads(response.text)

    效果对比:

  • 原 query:"销售额怎么样" → 改写后:`["2025年Q3产品A与产品B销售额对比", "Q3季度收入数据同比环比"]`
  • 前者可能召回一堆无关的财务术语文档,后者直接命中你要的数据
  • 坑四:只看相似度分数,不看召回率

    上线 RAG 系统后只监控 average_precision@5,这是不够的。

    你需要监控真实的召回质量——特别是对于高频 query:

    def evaluate_recall(ground_truth_queries: list[dict]):

    """

    ground_truth_queries 格式:

    {

    "query": "用户问题",

    "relevant_docs": ["doc_id_1", "doc_id_2"]

    }

    """

    recalls = []

    for q in ground_truth_queries:

    retrieved = retrieve(q["query"], top_k=10)

    retrieved_ids = {doc["id"] for doc in retrieved}

    relevant = set(q["relevant_docs"])

    recall = len(retrieved_ids & relevant) / len(relevant)

    recalls.append(recall)

    return {

    "mean_recall": sum(recalls) / len(recalls),

    "low_recall_queries": [

    q for q, r in zip(ground_truth_queries, recalls)

    if r < 0.5

    ]

    }

    如果某些高频 query 的 recall 长期低于 0.5,说明这部分内容根本没有被有效索引,需要重新做 chunking 或调整 embedding 策略。

    总结

    RAG 系统的优化路径其实很清晰:**先让对的文档被召回来,再让 LLM 把召回的内容用好**。前者是检索工程的问题,后者才是 LLM 的强项。

    别在 prompt 调优上卷了,先把 retrieval 质量看清楚。

    如果你正在做 RAG 系统的优化,有具体问题可以在评论区描述情况,我能帮看。