先固定提示词模板再调检索参数——LangChain 的 RAG 效果波动从这两处入手

文章导读
同一个问题两次问出不一样的答案时,先别急着改措辞或换模型。把变量拆成两组:一组是提示词模板(模板正文、变量拼接、上下文插入位置、是否带分支),另一组是检索参数(召回条数、相似度过滤、检索器类型)。可行的做法是一次只动一侧,另一侧写死,再对比输出差异落在哪边。顺序上建议先固定模板,确认最终提示词字符串没有变化,再去调检索参数;反过来先调检索,模板还在漂,观测到的差异没法归因。
📋 目录
  1. 把提示词模板固定成一个字符串常量
  2. 固定问题与知识库,连续多次调用同一问答链
  3. 单独调整召回条数观察命中内容变化
  4. 调整相似度过滤再复测同一问题
  5. 把差异最大的两次回答与各自召回文档并排比对
A A

同一个问题两次问出不一样的答案时,先别急着改措辞或换模型。把变量拆成两组:一组是提示词模板(模板正文、变量拼接、上下文插入位置、是否带分支),另一组是检索参数(召回条数、相似度过滤、检索器类型)。可行的做法是一次只动一侧,另一侧写死,再对比输出差异落在哪边。顺序上建议先固定模板,确认最终提示词字符串没有变化,再去调检索参数;反过来先调检索,模板还在漂,观测到的差异没法归因。

建议的排查顺序是先固定提示词模板,再调检索参数。把模板写成字符串常量、只保留 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 是逐字相同还是只是措辞不同。判断口径是——召回集合相同而回答不同,波动偏向生成侧或采样设置;召回集合不同,波动偏向检索侧。文档顺序变化也要记下来,它可能改变模型对上下文的重视顺序。

单独调整召回条数观察命中内容变化

只改召回条数,模板、问题、知识库保持不变。不同框架里控制条数的位置不一样,有的是检索器构造参数,有的是检索方法的参数,按你使用的接口替换。

先固定提示词模板再调检索参数——LangChain 的 RAG 效果波动从这两处入手
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 / 摘要新增或消失的文档回答结论(截断)答案是否变化备注
11
22
34
48

重点看「新增或消失的文档」这一列:如果答案随着某个文档进入上下文才变化,说明答案依据是它;如果加到较大 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 和阈值两项;提示词与召回集合都相同、只有回答不同,问题在生成侧或采样配置。三者都不变仍抖动的情况,需要结合你当时的索引状态和调用日志再确认,不建议在没排除前面几项之前就下结论。