在基于检索增强生成(RAG)的问答链路里,模型给出的答案由检索到的上下文决定。用户问上季度华东区退货率为什么上升,如果直接把整句话做向量检索,命中的很可能是讲退货流程、退货政策甚至其他区域的文档。生成器拿到这些片段后,只能把它们拼成一段看起来相关、实际上偏离问题的文字。要降低答非所问的比例,应该把精力放在进入检索器之前的问题理解上,而不是反复调整模型参数。问题重写和查询扩展就是两类成本较低、见效较快的手段。

一、为什么原始问题直接检索会跑偏
自然语言问句和知识库文档的语言风格通常存在明显差异。用户习惯用口语表达,比如系统挂了怎么办、硬盘读不出来,而技术文档里写的往往是故障恢复、存储卷挂载异常、I/O超时。原始问句直接进入向量检索,相似度计算很容易被这些非核心词带偏,导致召回的第一批片段与真正答案无关。更麻烦的是,多轮对话中的省略和指代会让查询信息严重不足。
举一个典型例子。用户先问对象存储支持哪些备份方式,接着追问它恢复时要多久。如果只把后面这句话拿去检索,向量模型并不知道这里的它指代的是对象存储备份恢复,召回结果可能包含数据库恢复、虚拟机恢复甚至业务系统恢复。生成器收到这些文档片段后,为了给出回答,会硬着头皮组织语言,最终呈现出的就是答非所问。问题不是模型不会回答,而是它拿到的上下文从一开始就错了。
还有一种情况是问题本身包含多个意图。例如查询A服务部署失败且B接口超时的排查步骤,原始问句很长,但向量检索希望输入尽量简洁、重点突出。长问句经过嵌入模型压缩后,真正关键的故障点可能被稀释。因此,在检索前对问题进行结构化处理,比直接依赖嵌入模型的鲁棒性要可靠得多。
二、问题重写:补全、消解与拆解
问题重写的核心思路是让大模型在理解上下文的基础上,把用户原始问题改写成更适合检索的短句。改写时需要完成三件事:补全省略的主语和宾语、把口语词汇替换为正式表达、将多跳问题拆成多个独立子查询。这样既能让检索器聚焦核心实体,也能减少无关词的干扰。
下面是一个可复用的改写提示词示例。它把历史对话和当前问题一起交给模型,要求输出只包含改写后的查询,避免模型发挥太多造成信息丢失。
prompt = '''
你是一个查询改写助手。请把用户问题改写成适合向量检索的短句。
要求:
1. 补全省略的主语和宾语;
2. 将口语词汇替换为正式表达;
3. 如果包含多个问题,拆成多个子查询,用换行分隔;
4. 不要改变时间、数字和专有名词。
历史对话:
{history}
用户问题:
{question}
输出改写结果:
'''
以刚刚的对象存储场景为例,历史对话中已经出现过对象存储备份,模型就可以把问题重写为对象存储备份恢复需要多长时间。这个查询再进入向量库,命中的片段就会集中在备份恢复流程和恢复时间上。改写后的查询通常比原始问句短,但它携带的检索信息反而更完整。
实施时需要注意,改写模型不能随意改变关键约束。例如用户问上季度华东区退货率,如果模型把上季度改写成最近一段时间,就会引入时间噪声;如果漏掉华东区,召回结果会混入全国数据。因此提示词里要明确要求保留时间、区域、版本号等限定词。实际项目中还可以加一道校验:对比改写前后的实体集合,若关键实体丢失,则回退到原始查询。
三、查询扩展:从词典、模型到伪相关反馈
问题重写解决的是查询不完整、不正式的问题,但如果用户用的词本身就和文档术语差异很大,单靠改写可能还不够。查询扩展的作用是在原始查询或改写查询的基础上,生成一批语义相近的候选查询,通过提高覆盖率来减少漏召回。最简单的做法是维护一个同义词词典,比如把挂载扩展为mount、附加,把磁盘扩展为硬盘、卷。
下面这段代码演示了基于词典的查询扩展。它会对核心词进行替换,生成多个查询,再统一去重后提交给检索系统。
def expand_query(query, synonyms):
expansions = [query]
for word, replacements in synonyms.items():
if word in query:
for rep in replacements:
expansions.append(query.replace(word, rep))
return list(set(expansions))
queries = expand_query('挂载磁盘失败', {
'挂载': ['mount', '附加'],
'磁盘': ['硬盘', '卷'],
'失败': ['报错', '异常']
})
for q in queries:
print(q)
词典方式可控性强,但需要持续维护,而且难以覆盖新词和上下文相关的同义表达。更灵活的做法是用大模型直接生成扩展查询。可以给模型一个指令,让它为当前查询生成三个含义相同或更具体的检索语句。模型生成的结果往往比静态词典更贴合具体语境。
def generate_expansions(query, llm):
prompt = '为以下检索查询生成3个同义或更具体的查询,用换行分隔。\n查询:' + query
text = llm.invoke(prompt)
return [line.strip() for line in text.split('\n') if line.strip()]
伪相关反馈也是一种可选方案。先用原始查询做一次检索,把Top N结果当作相关文档,从中提取高频词或关键短语,再把这些词补充进查询。这种方式不需要外部词典,但效果依赖第一次检索的质量。如果第一次召回已经严重跑偏,扩展反而会放大噪声。因此伪相关反馈通常与改写和词典扩展组合使用,而不是单独承担召回任务。
四、效果评估与过扩展控制
问题重写和查询扩展并不是越多越好。过度扩展可能引入大量无关候选词,导致检索结果发散。例如把磁盘扩展成存储、文件系统、数据库,虽然相关,但可能把问题范围拉得太宽,最终生成器无法锁定具体答案。控制扩展数量的常用方法是限制每个查询的扩展数量,并对多个查询的召回结果做融合排序。
倒数排名融合(Reciprocal Rank Fusion,RRF)适合处理多查询召回结果。它不关心各查询的原始相似度分数,只看文档在各个结果列表中的位置,能够把多个查询的召回结果合并成一个更稳定的排序。
def reciprocal_rank_fusion(result_lists, k=60):
scores = {}
for results in result_lists:
for rank, doc_id in enumerate(results):
if doc_id not in scores:
scores[doc_id] = 0
scores[doc_id] += 1 / (k + rank + 1)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
评估效果时不能只看检索命中率,还要关注最终答案的准确率。可以构造一组包含指代、省略和专业术语差异的测试问题,分别记录原始查询、仅改写、仅扩展、改写加扩展四种策略下的答案准确率。很多时候,单用改写可以提升精度,单用扩展可以提升召回,但两者结合后需要通过消融实验找到最佳比例。
另外一个实用建议是保留原始查询的结果权重。改写后查询虽然更规范,但有时会丢失用户原话中的特定表达。可以把原始查询、改写查询和扩展查询的结果一起送入重排序模型,让重排序器决定哪些片段真正与用户问题相关。这样即使改写环节出现偏差,原始查询仍然有机会把正确答案捞回来。
从工程落地角度看,问题重写和查询扩展都不需要替换底层检索模型,也不涉及复杂的索引改造。它们作为检索前的预处理模块,可以独立上线、独立回滚。对于答非所问比例较高的场景,先花少量成本把这两步做好,往往比继续微调生成模型更划算。