同一个问题两次问出不一样的答案时,先别急着改措辞或换模型。把变量拆成两组:一组是提示词模板(模板正文、变量拼接、上下文插入位置、是否带分支),另一组是检索参数(召回条数、相似度过滤、检索器类型)。可行的做法是一次只动一侧,另一侧写死,再对比输出差异落在哪边。顺序上建议先固定模板,确认最终提示词字符串没有变化,再去调检索参数;反过来先调检索,模板还在漂,观测到的差异没法归因。
建议的排查顺序是先固定提示词模板,再调检索参数。把模板写成字符串常量、只保留 context 与 question 两个占位,打印最终提示词确认逐字不变;固定同一问题与同一知识库连续多次调用,比较召回文档 id 与回答。若召回集合稳定而回答仍变,波动偏向生成侧或采样设置;若召回集合本身在变,波动偏向检索侧,再用召回条数与相似度阈值逐项对照。适用前提是知识库内容、索引与 embedding 模型在排查期间没有被重建,否则历史记录不可比。
把提示词模板固定成一个字符串常量
模板里只留两个占位,其它文字全部写死,不要从配置文件、数据库或环境变量里读模板,也不要临时拼接系统提示。放在调用链构造之前,作为一次性常量。
# 通用接入骨架,按你实际使用的模型客户端替换 llm / retriever
PROMPT_TEMPLATE = (
'你是问答助手,只依据下面的资料回答。\n'
'资料:\n{context}\n'
'问题:{question}\n'
'资料里没有答案时,直接回答「不知道」。'
)
def build_prompt(context: str, question: str) -> str:
# 只保留 context 与 question 两个占位
return PROMPT_TEMPLATE.format(context=context, question=question)
验证方式是打印最终提示词本身,而不是打印模板常量。同一问题、同一批召回文档,连续打印两三次,比较字符串长度和内容是否逐字符相同:
p1 = build_prompt(context, question)
p2 = build_prompt(context, question)
print(repr(p1))
print(len(p1), len(p2), p1 == p2)
如果两次结果不同,常见原因是召回文档顺序变了,或者文档块之间的分隔符里带了时间戳、随机 id。先把分隔符固定成 '\n---\n' 这类确定字符串,再继续下一步。
固定问题与知识库,连续多次调用同一问答链
问题、知识库、模板都不动,只重复调用,看输出是否稳定。建议把采样温度设成 0 或最低档,否则模型本身的随机采样也会造成措辞变化,无法区分是检索问题还是生成问题。
import json
QUESTION = '把你的测试问题原样写在这里'
results = []
for i in range(5):
docs = retriever.invoke(QUESTION) # 检索侧本轮不动
context = '\n---\n'.join(d.page_content for d in docs)
prompt = build_prompt(context, QUESTION)
answer = llm.invoke(prompt) # 换成你的调用方式
results.append({
'run': i,
'doc_ids': [d.metadata.get('id') or d.page_content[:40] for d in docs],
'prompt_len': len(prompt),
'answer': answer if isinstance(answer, str) else answer.content,
})
json.dump(results, open('rag_runs.json', 'w', encoding='utf-8'),
ensure_ascii=False, indent=2)
需要观察的字段:召回文档 id 列表及其顺序、prompt_len 是否漂移、answer 是逐字相同还是只是措辞不同。判断口径是——召回集合相同而回答不同,波动偏向生成侧或采样设置;召回集合不同,波动偏向检索侧。文档顺序变化也要记下来,它可能改变模型对上下文的重视顺序。
单独调整召回条数观察命中内容变化
只改召回条数,模板、问题、知识库保持不变。不同框架里控制条数的位置不一样,有的是检索器构造参数,有的是检索方法的参数,按你使用的接口替换。
for k in [1, 2, 4, 6, 8]:
docs = retriever.invoke(QUESTION, k=k) # 或重新构造带 k 的检索器
context = '\n---\n'.join(d.page_content for d in docs)
answer = llm.invoke(build_prompt(context, QUESTION))
print(k, [d.metadata.get('id') for d in docs], answer)
记录表建议每次运行填一行,字段固定:
| 运行序号 | k 值 | 命中文档 id / 摘要 | 新增或消失的文档 | 回答结论(截断) | 答案是否变化 | 备注 |
|---|---|---|---|---|---|---|
| 1 | 1 | — | ||||
| 2 | 2 | |||||
| 3 | 4 | |||||
| 4 | 8 |
重点看「新增或消失的文档」这一列:如果答案随着某个文档进入上下文才变化,说明答案依据是它;如果加到较大 k 后回答开始跑偏,通常是噪声文档把关键片段挤掉了。
调整相似度过滤再复测同一问题
先确认你的向量库能不能把分数打出来。很多检索器有带分数的检索方法,命名相近但返回格式不同,需要按实际接口替换:
scored = vectorstore.similarity_search_with_score(QUESTION, k=8)
for doc, score in scored:
print(score, doc.metadata.get('id') or doc.page_content[:60])
要注意分数的方向:有的库返回距离,值越小越相似;有的返回余弦相似度,值越大越相似。阈值方向必须按你使用的库确认,不要直接照搬别处的数字。确认方向后,按阈值从松到紧各跑一遍同一问题,记录命中文档的增减与内容变化。若收紧阈值后关键文档被过滤掉、回答变成「不知道」,偏向召回不足;若放宽阈值后混进大量无关片段、回答被带偏,偏向噪声过多。这两种情况的处理方向不同,不要用同一个阈值同时解决。
把差异最大的两次回答与各自召回文档并排比对
从前面几轮记录里挑出输出差异最大的两次运行,把输入、召回内容、回答并排放在一起看,波动归因才有落点。对照记录格式建议每行一次运行:
| 运行序号 | k / 阈值配置 | 最终提示词长度或哈希 | 召回文档 id + 首句 | 回答结论 | 与另一次的具体差异 | 归因 |
|---|---|---|---|---|---|---|
| A | 基准 | — | ||||
| B |
归因按三条线索判断:提示词字符串或哈希不同,问题在模板侧,检查是否有分支、是否拼接了时间或用户信息;提示词相同但召回文档 id 集合不同,问题在检索侧,回到 k 和阈值两项;提示词与召回集合都相同、只有回答不同,问题在生成侧或采样配置。三者都不变仍抖动的情况,需要结合你当时的索引状态和调用日志再确认,不建议在没排除前面几项之前就下结论。