RAG 优化实战——Naive RAG 的5个主要问题和对应解法
RAG 优化实战——Naive RAG 的5个主要问题和对应解法
适读人群:AI 工程师、做过 RAG 但效果不满意的开发者 | 阅读时长:约18分钟 | 核心价值:系统性解决 RAG 的五大问题,把效果从 60 分提升到 85 分
做过 RAG 的工程师都懂一个规律:最初那个版本效果都不怎么样。
不是你代码写得差,是 Naive RAG(最基础的 RAG 实现)本身就有结构性缺陷。我帮几个团队做过 RAG 优化,基本上每个项目都会遇到这 5 个问题,解法也是固定的。
这篇文章把这 5 个问题系统地讲一遍。
问题一:分块质量差——"查到了但没用"
现象:向量检索确实召回了相关文档,但召回的片段上下文不完整,模型没法基于这个片段给出正确答案。
根本原因:固定大小的分块策略(如每 500 tokens 切一块)不考虑内容的语义边界,会把一段完整的流程说明切成两半,每半段单独看都没有价值。
解法:语义感知分块
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
# 策略一:按标题层级分块(适合结构化文档)
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
)
# 先按标题分,再对过长的段落递归切
recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=100,
separators=["\n\n", "\n", "。", ";", ",", " ", ""] # 中文友好
)
def smart_split(text: str) -> list[str]:
# 先尝试按标题分
header_splits = markdown_splitter.split_text(text)
final_chunks = []
for split in header_splits:
if len(split.page_content) > 800:
# 太长的再递归切
sub_chunks = recursive_splitter.split_text(split.page_content)
final_chunks.extend(sub_chunks)
else:
final_chunks.append(split.page_content)
return final_chunks策略二:Parent-Child 分块(检索小块,返回大块)
# 索引时存小块(512 tokens)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50)
# 返回时给大块(2000 tokens)—— 包含更多上下文
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000, chunk_overlap=200)
# 建索引时:用小块的 embedding,但存储 parent_id 指向大块
def index_with_parent(doc: str):
parent_chunks = parent_splitter.split_text(doc)
for i, parent in enumerate(parent_chunks):
child_chunks = child_splitter.split_text(parent)
for child in child_chunks:
# 存小块 embedding,带上 parent 内容
vectorstore.add_texts(
texts=[child],
metadatas=[{"parent_content": parent, "parent_id": i}]
)
# 查询时:检索小块,返回对应大块
def retrieve_with_parent(query: str, k: int = 5) -> list[str]:
results = vectorstore.similarity_search(query, k=k)
# 返回 parent 内容而不是 child 内容
return [doc.metadata["parent_content"] for doc in results]问题二:检索召回率低——"找不到明明有的内容"
现象:用户问"散热风扇噪音大怎么处理",知识库里有"风扇异响排查手册",但检索不到。
根本原因:用户的问法和文档的写法有语义差距("噪音大" vs "异响"),纯向量相似度无法弥合这个 gap。
解法一:Query 改写
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
def rewrite_query(original_query: str) -> list[str]:
"""生成多个语义相近的查询变体,提高召回覆盖率"""
prompt = f"""
请将以下问题改写为5个不同的表达方式,保持语义相同但用词不同,
有助于在技术文档中搜索相关内容。每行一个,只返回改写后的问题,不要解释。
原问题:{original_query}
"""
response = llm.invoke(prompt)
variants = [q.strip() for q in response.content.split('\n') if q.strip()]
return [original_query] + variants[:4] # 原始 + 4个变体
def multi_query_retrieve(query: str, k: int = 5) -> list[Document]:
queries = rewrite_query(query)
all_docs = []
for q in queries:
docs = vectorstore.similarity_search(q, k=k)
all_docs.extend(docs)
# 去重(相同内容只保留一个)
seen_contents = set()
unique_docs = []
for doc in all_docs:
if doc.page_content not in seen_contents:
seen_contents.add(doc.page_content)
unique_docs.append(doc)
return unique_docs[:k] # 返回最多 k 个解法二:混合检索(Hybrid Search)
from rank_bm25 import BM25Okapi
class HybridRetriever:
def __init__(self, vectorstore, documents: list[str]):
self.vectorstore = vectorstore
# 构建 BM25 索引(关键词检索)
tokenized_docs = [doc.split() for doc in documents]
self.bm25 = BM25Okapi(tokenized_docs)
self.documents = documents
def retrieve(self, query: str, k: int = 5) -> list[str]:
# 向量检索
vector_results = self.vectorstore.similarity_search_with_score(query, k=k*2)
# BM25 检索
tokenized_query = query.split()
bm25_scores = self.bm25.get_scores(tokenized_query)
bm25_top_indices = bm25_scores.argsort()[-k*2:][::-1]
# RRF(Reciprocal Rank Fusion)融合排序
doc_scores = {}
for rank, (doc, _) in enumerate(vector_results):
doc_scores[doc.page_content] = doc_scores.get(doc.page_content, 0) + 1/(rank + 60)
for rank, idx in enumerate(bm25_top_indices):
content = self.documents[idx]
doc_scores[content] = doc_scores.get(content, 0) + 1/(rank + 60)
# 按融合分数排序
sorted_docs = sorted(doc_scores.items(), key=lambda x: x[1], reverse=True)
return [doc for doc, _ in sorted_docs[:k]]问题三:上下文窗口浪费——"塞了太多无关内容"
现象:每次查询 Top-5,但 5 个结果里有 2-3 个其实不相关,这些内容占据了 context 空间,反而干扰模型。
根本原因:相似度阈值没设好,或者 Top-K 数量设得太多。
解法:Re-ranking 精排
from sentence_transformers import CrossEncoder
# CrossEncoder 比双塔模型精度更高,但速度慢,适合做精排
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3") # 支持中英文
def retrieve_and_rerank(query: str, initial_k: int = 20, final_k: int = 5) -> list[str]:
# 1. 粗排:向量检索,多取一些
candidate_docs = vectorstore.similarity_search(query, k=initial_k)
# 2. 精排:CrossEncoder 重新打分
pairs = [(query, doc.page_content) for doc in candidate_docs]
scores = reranker.predict(pairs)
# 3. 按精排分数重排序
doc_score_pairs = sorted(
zip(candidate_docs, scores),
key=lambda x: x[1],
reverse=True
)
# 4. 取 Top-K
return [doc.page_content for doc, score in doc_score_pairs[:final_k]
if score > 0.5] # 分数低于 0.5 的也不要问题四:答案幻觉——"说了文档里没有的内容"
现象:模型回答了一些听起来合理但实际上文档里没有的内容,这在技术/法律/医疗场景是严重问题。
根本原因:模型的"补全本能",在文档信息不足时会用训练数据里的知识来填充。
解法:明确的忠实性约束 + 引用验证
FAITHFUL_SYSTEM_PROMPT = """
你是一个基于文档的问答助手。
【核心约束】
1. 只能基于【参考文档】中的内容回答
2. 如果参考文档中没有相关信息,必须明确说"参考文档中没有这方面的信息"
3. 每个关键陈述后面用[文档片段序号]标注来源
4. 绝对禁止根据推断、常识或训练数据中的知识来补充文档中没有的内容
参考文档:
{context}
"""
def rag_with_citation(query: str) -> dict:
docs = retrieve_and_rerank(query)
# 给每个文档片段编号
numbered_context = "\n\n".join([
f"[{i+1}] {doc}" for i, doc in enumerate(docs)
])
response = llm.invoke([
SystemMessage(content=FAITHFUL_SYSTEM_PROMPT.format(context=numbered_context)),
HumanMessage(content=query)
])
answer = response.content
# 验证引用是否真实
citations = extract_citations(answer) # 提取 [1], [2] 等引用
verified = verify_citations(answer, docs, citations)
return {
"answer": answer,
"sources": docs,
"citation_verified": verified,
"warning": None if verified else "部分引用无法在原文中找到对应内容,请谨慎参考"
}问题五:多跳推理失败——"需要跨多个文档推理的问题答不好"
现象:用户问"A型号设备的配件B的最大承压是多少",A型号信息在文档1,配件B信息在文档2,两个文档都检索到了但模型没有把信息关联起来。
根本原因:Naive RAG 是单次检索,对于需要多步推理的问题,一次检索的结果往往不够。
解法:迭代检索(Iterative RAG)
def iterative_rag(initial_query: str, max_iterations: int = 3) -> str:
context_so_far = []
current_query = initial_query
for iteration in range(max_iterations):
# 检索
new_docs = retrieve_and_rerank(current_query, final_k=3)
context_so_far.extend(new_docs)
# 判断是否已经有足够信息
check_prompt = f"""
用户问题:{initial_query}
已收集到的信息:
{chr(10).join(context_so_far)}
请判断:
1. 当前信息是否足以回答用户问题?(yes/no)
2. 如果不够,还需要查找什么信息?(一句话描述)
返回 JSON:{{"sufficient": true/false, "next_query": "..."}}
"""
result = llm.invoke(check_prompt)
check = parse_json(result.content)
if check["sufficient"]:
break
# 用新的子查询继续检索
current_query = check["next_query"]
# 最终生成答案
final_answer = llm.invoke([
SystemMessage(content=FAITHFUL_SYSTEM_PROMPT.format(
context="\n\n".join(set(context_so_far)) # 去重
)),
HumanMessage(content=initial_query)
])
return final_answer.content踩坑实录(额外一条)
坑:优化过度——每个优化都加进来反而变慢变贵
现象:把上面所有优化都叠加上去,延迟从 500ms 飙到了 8s,成本增加了 10 倍,但效果提升只有 15%。
原因:Query 改写要额外调用一次 LLM,Re-ranking 要调用 CrossEncoder,Iterative RAG 要多次检索……每个优化都有代价。
解法:按问题复杂度分级处理:
def adaptive_rag(query: str) -> str:
complexity = assess_complexity(query) # SIMPLE/MEDIUM/COMPLEX
if complexity == "SIMPLE":
# 直接向量检索 + 生成,最快
return simple_rag(query)
elif complexity == "MEDIUM":
# 加 Re-ranking
return rag_with_reranking(query)
else:
# 完整的多跳迭代检索
return iterative_rag(query)优化效果汇总
以我实际项目的数据为例(技术文档问答,1000 条测试集):
| 方案 | 准确率 | 平均延迟 | 成本/千次 |
|---|---|---|---|
| Naive RAG(基础版) | 61% | 850ms | ¥8 |
| + 语义分块 | 72% | 870ms | ¥8 |
| + 混合检索 | 79% | 1100ms | ¥10 |
| + Re-ranking | 83% | 1800ms | ¥15 |
| + 查询改写 | 85% | 2400ms | ¥22 |
| 分级自适应方案 | 84% | 1200ms | ¥13 |
分级自适应方案在准确率损失很小的情况下,把延迟和成本都控制在合理范围内,是我推荐的生产方案。
知识更新问题——被低估的运维挑战
很多团队在上线 RAG 之后,忽视了一个非常实际的问题:知识库里的内容会过时。
产品规格更新了、价格变了、政策调整了、老员工离职了新员工入职了——这些变化都需要反映到知识库里,否则 AI 给出的是过时的回答,比没有 AI 还坏。
增量更新策略:
不要每次都全量重建索引,那太慢了(几千文档可能要几小时)。建立增量更新机制:
def update_document(doc_id: str, new_content: str, metadata: dict):
"""更新单个文档的索引"""
# 1. 删除旧的 chunk
vectorstore.delete(filter={"doc_id": doc_id})
# 2. 重新分块和索引
chunks = smart_split(new_content)
documents = [
Document(
page_content=chunk,
metadata={
"doc_id": doc_id,
"updated_at": datetime.now().isoformat(),
**metadata
}
)
for chunk in chunks
]
vectorstore.add_documents(documents)
# 3. 记录更新日志
update_log.append({
"doc_id": doc_id,
"action": "update",
"chunk_count": len(chunks),
"timestamp": datetime.now()
})结合文档管理系统,当文档被修改时自动触发增量更新,而不是手动操作。
时效性元数据:
给每个文档片段加上时效性信息:
metadata = {
"doc_id": doc_id,
"valid_from": "2025-01-01", # 文档生效时间
"valid_until": "2025-12-31", # 文档失效时间(可选)
"version": "v2.0",
"last_updated": "2025-01-15"
}
# 检索时过滤过期文档
search_request = SearchRequest(
query=question,
filter=f"valid_until > '{today}' OR valid_until is null"
)这样即使索引还没更新,也不会把明确过期的内容返回给用户。
冷启动问题——知识库内容不足时怎么办
新项目刚开始时,知识库里内容不多,检索质量差,用户体验不好,这是典型的冷启动困境。
几个应对策略:
策略一:用通用知识兜底。当检索相似度低于阈值时,不是直接用 RAG 结果,而是让模型用自身的通用知识作答,但在回答里注明"本条回答基于通用知识,可能与您的具体情况有所不同,建议咨询相关人员确认"。
策略二:快速建立初始知识库。让业务专家集中用 2-3 天整理核心 FAQ,哪怕只有 100 条,也比空库强很多。这 100 条要覆盖最常见的问题。
策略三:从历史对话中学习。如果有历史的人工客服对话记录,这是最有价值的知识来源——真实用户问过的问题、真实客服给过的回答,直接索引进来,命中率会比从零开始的文档高很多。
策略四:设置明确的兜底话术。当置信度不足时,与其给出低质量回答,不如直接说"我暂时没有找到相关信息,建议您拨打客服热线 400-XXX-XXXX 获取帮助"。这比给一个半对半错的答案体验要好。
RAG 的未来趋势:多模态向量检索
现在大多数 RAG 系统只处理文字,但很多企业文档里有大量图表、流程图、示意图,这些信息用 OCR 提取出来会有大量信息损失。
下一代 RAG 会走向多模态:文字和图像都能作为检索单元,问题既可以是文字,也可以是图片。比如用一张设备故障照片去知识库里检索相关维修手册。
目前已经有了一些初步的实现方案,主要是用 CLIP 这类多模态 Embedding 模型同时处理文字和图片。这个方向值得关注,虽然目前工程化程度还不高,但 2025 年应该会有更成熟的框架出来。
