欢迎光临
我们一直在努力

【随笔】RAG查询改写:补全上下文,别改丢用户条件

用户上一轮问“月付套餐怎么退款”,下一轮只说“那年付呢?”如果把后一句直接送进向量检索,检索器既不知道“那”指什么,也缺少退款这个主题;如果改写时照搬上一轮套餐,又会找回月付规则。

RAG查询改写解决的是检索表达问题:把当前问题和必要上下文组织成检索器能使用的查询,同时保留用户真正提出的条件。本文沿着“补全指代→保留约束→验证候选→执行检索”的顺序,给出一个可以直接运行的例子。

一、改写、压缩与扩展分别做什么

查询改写位于检索前。它可以消除口语赘词、补充明确的指代、把用户用语对应到知识库术语,但不能提前编造答案。

操作输入示例产物
对话补全 上轮退款问题+“那年付呢?” 年付套餐的退款规则
检索改写 “钱扣了不想用了咋退” 订阅退款条件与办理流程
术语对齐 用户说“关掉下月扣款” 关闭自动续费
多查询扩展 一个复杂问题 若干保持条件一致的检索表达

Spring AI2.0.2把对话压缩、查询改写和多查询扩展作为不同组件:CompressionQueryTransformer用于结合历史形成独立问题,RewriteQueryTransformer用于调整检索表达,MultiQueryExpander产生多种查询形式。这说明它们可以组合,但职责不同。Spring AI的RAG模块说明

改写后的查询是检索输入,原始问题仍然保留。 最终回答要面对用户原本的请求;排查错答时也需要知道,偏差发生在理解、改写、检索还是生成阶段。

二、先确定哪些条件不能变

对于套餐退款场景,可以把输入拆成三个部分:

  • 原始问句:保留用户表达和本轮更正。
  • 业务条件:套餐、地区、主题、时间或产品版本。
  • 访问范围:当前租户、权限策略、可访问知识版本。
  • 本轮明确说“年付”,就要覆盖上一轮的“月付”;本轮没有变更地区,可以继续使用已确认的地区。若上下文中同时存在多个产品,“它”的指向无法确定,就应追问,而不是替用户选一个。

    否定、数字与精确标识尤其容易在概括中丢失。 “禁止自动续费”不能变成“自动续费”,“v2迁移到v3”不能交换方向,错误码、订单号和类名也不能随意替换成近义词。

    小橘提出年付追问,栗子保留退款主题并将套餐条件由月付更新为年付

    图中的“年付”来自本轮明确输入,“退款”来自当前对话主题。两者的来源不同,更新规则也不同。

    权限字段应由服务端可信上下文提供。让模型生成一整段查询,再从自然语言里猜测租户过滤条件,会把表达处理和访问控制混在一起。

    三、一条可靠的改写流水线

    保留原始问题后,先解析明确条件,再让改写组件建议检索词。对候选做校验,最后由程序把确定条件与检索词组合起来:

    原始问题和可信条件进入改写流程,候选经校验后进入检索,不通过则重试或追问

    这里的校验至少覆盖四类变化:

    • 是否遗漏已确认条件,例如年付、地区和产品版本。
    • 是否把本轮更正覆盖回旧值。
    • 是否添加没有来源的事实,例如“七天无条件退款”。
    • 是否把限制或否定改成相反含义。

    结构化约束比只写一句“不要改变原意”更容易检查。 可以要求候选返回套餐、主题和检索词等字段,用服务端已确认值逐项核对。字段正确仍不保证任意自由文本都正确,因此关键条件应由程序重新拼接,而不直接信任模型生成的整段查询。

    校验失败时要区分原因:候选漂移可以重新生成或退回已确认的规范表达;用户信息缺失则需要追问。把“那年付呢”原样退回检索,仍然没有补齐问题。

    四、完整示例:年付追问不能搜成月付退款

    下面使用Python3.10及以上版本,不依赖模型服务。示例用人工构造的候选模拟改写组件输出,重点验证条件更新、候选校验和检索范围。套餐与主题假定已从用户明确表达中确认;真实应用需要单独实现并评估这一解析步骤。

    from dataclasses import dataclass, replace

    @dataclass(frozen=True)
    class Request:
    plan: str | None
    topic: str
    region: str
    tenant: str
    version: str

    @dataclass(frozen=True)
    class Proposal:
    plan: str
    topic: str
    terms: tuple[str, ...]

    ALLOWED = {'退款': {'退款', '退订', '退款条件'},
    '禁止自动续费': {'禁止自动续费', '关闭自动续费'}}

    def resolve(history: Request, explicit_plan: str | None) –> Request:
    return replace(history, plan=explicit_plan or history.plan)

    def validate(req: Request, candidate: Proposal) –> None:
    if req.plan is None:
    raise ValueError('需要确认套餐')
    if candidate.plan != req.plan or candidate.topic != req.topic:
    raise ValueError('改写改变了关键条件')
    if not candidate.terms or not set(candidate.terms) <= ALLOWED[req.topic]:
    raise ValueError('扩展词不在业务词表内')

    def build_query(req: Request, candidate: Proposal) –> str:
    validate(req, candidate)
    # 确定条件由程序拼接,模型只建议受限的检索词。
    terms = ' '.join(dict.fromkeys(candidate.terms))
    return f'{req.region} {req.plan} {req.topic} {terms}'

    DOCS = [
    dict(id='M', tenant='t1', version='v2', region='中国大陆', plan='月付', topic='退款'),
    dict(id='Y', tenant='t1', version='v2', region='中国大陆', plan='年付', topic='退款'),
    dict(id='OLD', tenant='t1', version='v1', region='中国大陆', plan='年付', topic='退款'),
    dict(id='OTHER', tenant='t2', version='v2', region='中国大陆', plan='年付', topic='退款'),
    ]

    def scope_candidates(req: Request) –> list[str]:
    # 教学用元数据筛选,实际系统还需在该范围内做关键词/向量检索。
    return [d['id'] for d in DOCS if all(
    d[k] == getattr(req, k)
    for k in ('tenant', 'version', 'region', 'plan', 'topic'))]

    if __name__ == '__main__':
    previous = Request('月付', '退款', '中国大陆', 't1', 'v2')
    current = resolve(previous, '年付')
    good = Proposal('年付', '退款', ('退款条件', '退订'))
    print('query:', build_query(current, good))
    print('candidates:', scope_candidates(current))
    assert scope_candidates(current) == ['Y']
    for label, req, candidate in [
    ('错误沿用月付', current, Proposal('月付', '退款', ('退款',))),
    ('缺少套餐', replace(current, plan=None), good),
    ('丢失否定', replace(current, topic='禁止自动续费'),
    Proposal('年付', '自动续费', ('自动续费',))),
    ('添加无依据条件', current, Proposal('年付', '退款', ('七天无条件退款',))),
    ]:
    try:
    build_query(req, candidate)
    raise AssertionError('错误候选未被阻止')
    except ValueError as e:
    print(label + ': ' + str(e))

    运行:

    python rewrite_demo.py

    本次执行输出:

    query: 中国大陆 年付 退款 退款条件 退订
    candidates: ['Y']
    错误沿用月付: 改写改变了关键条件
    缺少套餐: 需要确认套餐
    丢失否定: 改写改变了关键条件
    添加无依据条件: 扩展词不在业务词表内

    1. 本轮显式条件覆盖历史值

    resolve只处理已经确认的套餐更新。本轮“年付”覆盖历史“月付”,其他字段保留。它没有用关键词猜测任意自然语言,也没有替缺失套餐设默认值。

    2. 检索表达与访问范围分开

    build_query负责构造检索文本;scope_candidates使用租户、知识版本、地区、套餐和主题筛选文档。样例中的旧版本OLD和其他租户OTHER因此被排除,月付文档M也不属于本轮范围。

    此处返回的是元数据候选,不是语义相关性排名。实际检索要在合规候选范围内执行关键词或向量搜索,再做重排与证据检查。权限过滤必须在所有查询分支保持一致。

    3. 词表只解决可明确约束的部分

    受控词表适合套餐、业务动作、产品术语等边界清晰的场景。开放知识问答不能靠一个小词表覆盖全部表达,需要更多语义校验和回归样本。即使使用模型校验,也要用实际检索结果评估,不能把另一次模型判断当作绝对保证。

    示例中的“退订”用于扩展召回,它不表示退款一定获批,也不等于关闭自动续费。最终答案仍要依据取回的具体规则,区分这些动作。

    五、接入模型时,把输入和输出约定写清楚

    可以把改写请求组织为以下结构,再由程序验证结果:

    任务:生成用于知识库检索的查询词,不回答问题。
    原始问题:那年付呢?
    已确认条件:地区=中国大陆;套餐=年付;主题=退款
    允许参考:上一轮讨论的是套餐退款。
    输出:plan、topic、terms三个字段。
    要求:不新增退款期限、资格或金额;保留否定、数字和精确标识。

    租户及访问权限无需交给模型决定,直接在检索调用中绑定可信过滤条件。对话历史只提供与当前问题有关的部分;历史回答中的未经验证说法,不应升级成新的事实条件。

    如果使用Spring AI,可以在检索前接入查询变换组件;若要实现这里的字段校验,应在应用层增加明确的输入输出结构与验证逻辑,不能仅凭组件名称推断它已替业务保留所有约束。

    多查询扩展还会放大成本。每多一条查询,就可能多一次Embedding或搜索;合并结果时需要按文档或片段ID去重,限制候选总量,并确保所有分支使用相同权限与版本。不能让一个漏掉过滤条件的分支混入最终证据。

    六、怎样判断改写真的有用

    用同一组问题比较原始查询和改写查询,关注三个层面:

    层面需要检查的内容
    意图保持 套餐、否定、数字、版本和方向是否保留
    检索质量 已知相关证据是否进入Top K,错误范围资料是否被排除
    整体收益 回答是否正确,额外延迟和调用成本是否值得

    测试集至少包含明确单轮问题、指代追问、本轮更正、缺失条件、否定表达和精确错误码。不要只展示改写后更通顺的句子;文字流畅与证据召回是两个不同的验收项。

    简单问题不必强制改写。 清晰的错误码查询可以直接检索;含明确产品名和版本的短问题,也应先比较原始查询效果。需要补全时再启用相应步骤,避免每个请求都多走一次模型调用。

    记录原始问题、规范条件、候选、校验结果与检索证据ID,可以帮助定位失败环节。日志应避免保留不必要的个人信息,并遵循应用自己的访问与保留策略。

    七、🧠 思维导图

    #mermaid-svg-kO91dLE63oJybnOC{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kO91dLE63oJybnOC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kO91dLE63oJybnOC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kO91dLE63oJybnOC .error-icon{fill:#552222;}#mermaid-svg-kO91dLE63oJybnOC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kO91dLE63oJybnOC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kO91dLE63oJybnOC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kO91dLE63oJybnOC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kO91dLE63oJybnOC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kO91dLE63oJybnOC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kO91dLE63oJybnOC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kO91dLE63oJybnOC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kO91dLE63oJybnOC .marker.cross{stroke:#333333;}#mermaid-svg-kO91dLE63oJybnOC svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kO91dLE63oJybnOC p{margin:0;}#mermaid-svg-kO91dLE63oJybnOC .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-kO91dLE63oJybnOC .cluster-label text{fill:#333;}#mermaid-svg-kO91dLE63oJybnOC .cluster-label span{color:#333;}#mermaid-svg-kO91dLE63oJybnOC .cluster-label span p{background-color:transparent;}#mermaid-svg-kO91dLE63oJybnOC .label text,#mermaid-svg-kO91dLE63oJybnOC span{fill:#333;color:#333;}#mermaid-svg-kO91dLE63oJybnOC .node rect,#mermaid-svg-kO91dLE63oJybnOC .node circle,#mermaid-svg-kO91dLE63oJybnOC .node ellipse,#mermaid-svg-kO91dLE63oJybnOC .node polygon,#mermaid-svg-kO91dLE63oJybnOC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kO91dLE63oJybnOC .rough-node .label text,#mermaid-svg-kO91dLE63oJybnOC .node .label text,#mermaid-svg-kO91dLE63oJybnOC .image-shape .label,#mermaid-svg-kO91dLE63oJybnOC .icon-shape .label{text-anchor:middle;}#mermaid-svg-kO91dLE63oJybnOC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kO91dLE63oJybnOC .rough-node .label,#mermaid-svg-kO91dLE63oJybnOC .node .label,#mermaid-svg-kO91dLE63oJybnOC .image-shape .label,#mermaid-svg-kO91dLE63oJybnOC .icon-shape .label{text-align:center;}#mermaid-svg-kO91dLE63oJybnOC .node.clickable{cursor:pointer;}#mermaid-svg-kO91dLE63oJybnOC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kO91dLE63oJybnOC .arrowheadPath{fill:#333333;}#mermaid-svg-kO91dLE63oJybnOC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kO91dLE63oJybnOC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kO91dLE63oJybnOC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kO91dLE63oJybnOC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kO91dLE63oJybnOC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kO91dLE63oJybnOC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kO91dLE63oJybnOC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kO91dLE63oJybnOC .cluster text{fill:#333;}#mermaid-svg-kO91dLE63oJybnOC .cluster span{color:#333;}#mermaid-svg-kO91dLE63oJybnOC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kO91dLE63oJybnOC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kO91dLE63oJybnOC rect.text{fill:none;stroke-width:0;}#mermaid-svg-kO91dLE63oJybnOC .icon-shape,#mermaid-svg-kO91dLE63oJybnOC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kO91dLE63oJybnOC .icon-shape p,#mermaid-svg-kO91dLE63oJybnOC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kO91dLE63oJybnOC .icon-shape .label rect,#mermaid-svg-kO91dLE63oJybnOC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kO91dLE63oJybnOC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kO91dLE63oJybnOC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kO91dLE63oJybnOC :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    RAG查询改写

    理解当前问题

    补全指代与本轮更正

    保留约束

    否定数字版本

    权限独立绑定

    验证候选

    阻止遗漏与臆造

    重试或追问

    评估效果

    证据召回与意图保持

    延迟和成本

    八、总结

    总结要点

    查询改写服务于检索。 它补齐表达,不提前决定答案;原始问题始终保留,最终回答仍然要满足用户的真实条件。

    约束要有来源和优先级。 本轮明确更正覆盖旧值,缺失条件先确认,租户与权限由可信上下文绑定。模型建议的检索词需要经过检查。

    效果要用证据和成本衡量。 在同一测试集上检查意图保持、相关证据召回及额外延迟,让改写真正改善检索,而不只是把句子写得更像书面语。

    下一篇讨论RAG上下文压缩:证据已经检索出来,怎样删掉无关内容,同时保留条件、数字和引用位置。

    👉 如果你觉得这篇文章对你有所帮助,欢迎点赞、收藏、分享!😊

    赞(0)
    未经允许不得转载:171主机测评 » 【随笔】RAG查询改写:补全上下文,别改丢用户条件
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址