上线过一套 RAG 系统之后你会发现,教程里的三步流程——切块、向量化、召回喂给模型——在实际业务里远远不够用。
用户输的问题千奇百怪,同样的意思有十种表达方式;文档里的答案有时候藏在几段毫不相关的地方;模型拿到召回内容,有时候给你编一个根本不在文档里的答案。
把这些问题一个个踩过去之后,积累下来的经验大概是这些。
一、查询端:用户输入的问题往往召回不了想要的内容
问题改写(Query Rewriting)
用户说"这玩意怎么用",向量库里存的是"XX 功能的使用方法"。语义上接近,但向量距离不一定近,因为表达太口语化、太模糊。
让模型在检索前先把问题改写成更规范的表述,效果立竿见影:
rewrite_prompt = """
将用户问题改写为更适合文档检索的标准表述,保留核心意图,去掉口语化措辞。
用户问题:{question}
改写结果:
"""
rewritten = llm(rewrite_prompt.format(question=user_query))
results = vector_store.search(rewritten)
问题分解(Query Decomposition)
"公司的报销流程和差旅标准是什么"——这是两个问题。一次检索两件事,往往两件事都检索不好。
把复合问题拆成子问题,分别检索,最后合并答案:
原问题:公司的报销流程和差旅标准是什么?
子问题1:公司的报销流程是什么?
子问题2:公司的差旅标准是什么?
HyDE(假设性文档嵌入)
这是一个反直觉但有效的技巧:不直接把用户问题去检索,而是先让模型按这个问题生成一个假设性的回答,再用这个假设答案去检索。
道理很简单:用户的问题和文档的表达方式往往不一样,但模型生成的答案和文档内容的表达方式更接近。
hypothesis_prompt = "假设你知道答案,请给出一个合理的回答(不需要准确,只需要形式像):{question}"
hypothesis = llm(hypothesis_prompt.format(question=user_query))
results = vector_store.search(hypothesis) # 用假设答案去检索
在专业领域(法律、医疗、金融)里,HyDE 的召回提升往往比换 embedding 模型更明显。
二、索引端:文档切块是召回质量的天花板
切块方式决定了召回的上限
固定大小切块(每 512 个 token 一段)是最常见的做法,也是最容易出问题的做法。重要信息经常被切断在两段之间,两段分别召回的时候都缺了上下文。
更好的做法是按语义边界切:段落、章节、Q&A 对、表格单独处理。如果是 Markdown 或 HTML,按结构切而不是按长度切。
父子分块(Parent-Child Chunking)
存储时存小块(比如 200 token),检索时用小块,喂给模型时换成它的父块(比如 500-1000 token)。
小块精准,大块上下文完整,两全其美:
# 存储阶段
for parent_chunk in split_by_paragraph(doc):
parent_id = store_parent(parent_chunk)
for child_chunk in split_into_sentences(parent_chunk):
child_embedding = embed(child_chunk)
store_child(child_chunk, child_embedding, parent_id)
# 检索阶段
child_results = vector_store.search(query, top_k=10)
# 拿到子块后,去取父块内容喂给模型
parent_results = [get_parent(r.parent_id) for r in child_results]
给每个块加上元数据
块里只有正文内容,没有"这段话来自哪里"的信息,召回之后模型没有上下文。
每个块存储时附上来源信息:文件名、章节名、页码、创建时间、文档类型——这些元数据既可以用于后续的元数据过滤,也可以直接拼进 prompt 让模型知道引用来源。
三、检索端:纯向量检索不够用
混合检索(Hybrid Search)
向量检索擅长语义相似,关键词检索(BM25)擅长精确匹配。两者互补:用户查"JWT Token 过期错误码是多少",向量检索找的是语义相似的段落,可能就是找不到那个具体的数字;BM25 直接找包含"JWT Token 过期"这几个词的段落,一下就命中了。
Elasticsearch、Weaviate、Milvus 都支持混合检索,一行参数的事:
results = collection.search(
query=user_query,
search_type="hybrid", # 向量 + BM25
alpha=0.5 # 两者权重各一半,可以调
)
alpha 参数控制向量和关键词的权重比例,一般根据你的文档类型来调:文档偏技术文档(有大量专有名词和代码)就让关键词权重高一点;文档偏叙述性文字就让向量权重高一点。
重排序(Reranking)
向量召回 top-20,不代表前 5 个就是最相关的。向量相似度是个粗筛,用 cross-encoder 重排序做精筛,准确率提升显著。
cross-encoder 和 bi-encoder(用来生成 embedding 的那种)的区别:bi-encoder 分别编码问题和文档再算相似度,快但粗;cross-encoder 把问题和文档拼在一起输入,一次性算相关度,慢但准。
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
# 召回阶段,宽召回(top 20)
candidates = vector_store.search(query, top_k=20)
# 重排阶段,精排(取 top 5)
pairs = [(query, doc.text) for doc in candidates]
scores = reranker.predict(pairs)
reranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
final_results = [doc for doc, _ in reranked[:5]]
实测在大多数场景下,召回 20 重排取 5,比直接召回 5 要准得多。
四、生成端:召回到了,模型还是会乱答
上下文压缩(Contextual Compression)
召回到的 5 段文本,每段 500 token,总共 2500 token 全部塞进 prompt。其中可能只有 3 句话和问题真正相关,其他都是噪音。模型注意力被噪音分散,容易答偏。
压缩的做法:每段召回内容先让模型提取和问题相关的句子,只把相关句子拼进最终 prompt:
compress_prompt = """
根据以下问题,从文档中提取直接相关的句子,不相关的内容不要包含。
问题:{question}
文档:{document}
相关内容:
"""
compressed = [llm(compress_prompt.format(question=user_query, document=doc)) for doc in results]
final_context = "\n".join(compressed)
引导模型只用召回内容回答
prompt 工程里最重要的一条:明确告诉模型,如果答案不在提供的资料里,就说"根据提供的资料无法回答",不要编。
不加这条,模型会用训练数据里的知识来补充答案,你根本分不清哪些内容来自你的文档,哪些是模型自己发挥的。
system_prompt = """
你是一个只基于提供资料回答问题的助手。
规则:
1. 只使用提供的【参考资料】中的信息来回答问题
2. 如果参考资料中没有足够信息,请直接说"根据提供的资料无法回答此问题"
3. 回答时注明信息来源(如:根据第X段资料)
4. 不要使用资料以外的知识进行推断或补充
"""
五、评估:怎么知道 RAG 系统在变好还是变坏
很多团队靠"感觉"来判断 RAG 效果,改了个参数,随便试几个问题,感觉好像好了,就上线了。这是最危险的做法。
RAGAS(RAG Assessment)是目前最常用的自动评估框架,提供四个维度的量化指标:
Faithfulness(忠实度):模型的回答是否完全基于召回内容,没有幻觉 Answer Relevancy(答案相关性):回答是否和问题相关 Context Precision(上下文精准度):召回内容里,有多少比例是真正有用的 Context Recall(上下文召回率):所有应该召回的内容,实际召回了多少
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision, context_recall
result = evaluate(
dataset=test_dataset,
metrics=[faithfulness, answer_relevancy, context_precision, context_recall]
)
print(result)
做任何改动(换 embedding 模型、调切块大小、开重排序)之前和之后,都跑一次评估,用数字说话。
最后说一句
RAG 不是一个一次性的工程,是一个持续迭代的系统。
生产里最常见的问题分布大概是这样:30% 是切块策略不对导致关键内容被切断,30% 是查询和文档的表达方式不一致导致召回失败,30% 是模型拿到召回内容后没有忠实使用导致幻觉,10% 是其他。
优先级从高到低:先把切块做好,再做混合检索,再做重排,最后做查询改写。不要一上来就换更大的 embedding 模型,那通常不是瓶颈。