RAG 检索质量烂的根因:不是 embedding 模型不行,是你检索姿势错了
大多数 RAG 系统的瓶颈根本不在 LLM,而在检索阶段。本文分享二十多个 RAG 项目中踩过的真实坑点和解法。
# 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)
效果对比:
坑四:只看相似度分数,不看召回率
上线 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 系统的优化,有具体问题可以在评论区描述情况,我能帮看。
VkingAI