加载中...
  • 检索优化:语义相似不等于任务相关 loading

    吃透 AI Agent 开发 · 第 21 篇 · 第四章 · Context Engineering

    用户问的是“我现在该改哪里”,检索却可能返回一堆“概念相似”的旧文章。任务、权限、时间和证据都要进入排序。

    用户问的是“我现在该改哪里”,检索却可能返回一堆“概念相似”的旧文章。任务、权限、时间和证据都要进入排序。

    相似度只回答了一个小问题

    向量相似度可以找到“说法相近”的文本,却不知道用户是在排查故障、准备提交、回顾历史,还是寻找当前仍有效的配置。检索优化的目标不是让第一名更像问题,而是让候选更能支持下一步动作。

    用任务条件改写排序

    flowchart TB
      Question[自然语言问题] --> Intent[任务类型]
      Question --> Entities[文件/模块/时间]
      Intent --> Hybrid[关键词 + 语义召回]
      Entities --> Hybrid
      Hybrid --> Filter[权限与新鲜度过滤]
      Filter --> Rerank[证据重排]
      Rerank --> Answer[引用或拒答]
    

    一个“怎么部署”的问题,应该优先当前版本、当前环境和可访问的操作指南;一个“为什么当时这样做”的问题,旧的决策记录反而更有价值。排序必须知道任务,而不只是知道词。

    先把查询拆成特征

    interface RetrievalQuery {
      text: string
      intent: 'find-file' | 'explain-decision' | 'fix-failure' | 'verify-fact'
      paths: string[]
      asOf?: string
      requiredTags: string[]
    }
    
    function boost(query: RetrievalQuery, item: { path: string; tags: string[]; updatedAt: string }): number {
      const pathHit = query.paths.some((path) => item.path.startsWith(path)) ? 3 : 0
      const tagHit = query.requiredTags.filter((tag) => item.tags.includes(tag)).length
      const timePenalty = query.asOf && item.updatedAt > query.asOf ? 2 : 0
      return pathHit + tagHit - timePenalty
    }
    

    这不是完整排序器,却提醒我们把任务特征显式保存。模型说“相关”时,应用还要检查路径、时间和标签,避免一段语义相近的全局说明压过用户当前打开的文件。

    用离线样本比较优化是否有效

    版本 召回范围 额外条件 应看指标
    A 向量 top-k 相关性基线
    B 关键词 + 向量 路径过滤 当前任务命中
    C B 的结果 版本、权限、意图重排 可引用证据率
    D C 的结果 低置信度拒答 错误行动率

    不要只看 top-1 命中。Agent 可能需要两条互补证据,也可能因为没有可靠结果而应该停止。把“拒绝误用”作为指标,往往比把相似度再提高几个百分点更有价值。

    q-code 里寻找候选如何被收窄

    阅读顺序 路径 观察点
    1. 知识操作 src/gitlab-kb/operations.ts 查询和权限条件是否分离
    2. 文件引用 src/mentions/file-mentions.ts 路径和用户意图怎样共同排序
    3. 索引缓存 src/mentions/file-index-cache.ts 旧索引如何刷新和失效
    4. 记忆选择 src/context/memory/selection.ts 相关性和年龄如何共同决定注入

    失败反例:用户问“现在”,系统返回“曾经”

    用户问的是“我现在该改哪里”,检索却可能返回一堆“概念相似”的旧文章。模型看到内容完整、语气肯定,就把历史方案当成当前约束,最终修改了已经迁移的模块。

    修复要把时间当成查询的一部分,并在结果里显示来源版本。没有满足时间条件的候选时,返回“需要确认当前版本”,不要让模型自己猜哪一份更近。

    设计一个可解释的检索实验

    练习:收集十五个真实问题,给每个问题标注意图、目标路径、允许的时间范围和最小证据。对四个检索版本跑同一批问题,记录命中、引用、拒答和误导动作。最后挑一个失败案例,写出排序器为什么选错,以及新增的条件会不会伤害其他问题。

    先看一张“第一名为什么错”的打分单

    用户问“当前仓库的发布脚本在哪”。向量第一名是两年前的《发布脚本迁移方案》,因为标题和正文反复出现“发布脚本”;真正答案是 scripts/release.mjs,文本很短,语义分数反而低。

    candidate,semantic,bm25,path,freshness,authority,final
    docs/archive/release-migration.md,0.91,8.4,0,0.10,0.40,0.58
    scripts/release.mjs,0.66,3.1,1.0,1.00,0.95,0.87
    README.md#release,0.72,5.8,0.4,0.92,0.80,0.78
    

    优化不是简单给 freshness 加权。先从意图识别出 find-file,文件路径和当前仓库 authority 就应该成为硬特征;历史迁移文档可以作为解释证据,却不该占答案位置。

    Hybrid Retrieval 解决词和意思的两种缺口

    BM25 擅长精确符号、错误码和产品名,向量检索擅长同义表达。用户贴出 ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY 时,关键词应占主导;用户说“依赖锁文件似乎不完整”,语义召回可以找到同一故障。

    两路分数尺度不同,直接相加容易被某一路范围支配。Reciprocal Rank Fusion 用排名而非原始分数合并,简单且稳定;随后再用任务特征和重排模型精排小集合。

    精确路径、工单号、函数名可提取为 must-match 或强 boost。抽取失败时保留原查询,不要让模型改写删掉唯一错误码。

    Query Rewrite 要防止意图漂移

    “为什么当时没有用 Redis”是决策回顾,不是“Redis 怎么配置”。改写器应保留时间和否定关系,生成如 decision record cache storage no Redis,同时搜索 ADR、会议记录和历史版本。

    可以把改写结果与原查询并行召回,最终候选标记来自哪条 query。若只有改写命中且实体发生变化,降低置信度。用户问题包含代码、路径或引号内容时,原样短语应作为不可改写 token。

    模型改写不是必需。规则能识别错误码、路径和日期时先做确定提取,LLM 只补任务意图和同义词,失败则回退原查询。

    过滤要区分 hard filter 和 soft penalty

    无权限、租户不符、明确失效是 hard filter,候选不能进入模型。稍旧、路径较远、来源权威较低可以 soft penalty,因为历史问题或现状缺资料时仍可能有价值。

    把新鲜度全做 hard filter,会让“2024 年为什么迁移”查不到历史;把权限做 soft penalty,则可能把敏感标题带进结果。每个特征的语义先定清楚,再调权重。

    时间意图也影响新鲜度。asOf=2025-01-01 时,发布日期晚于该时点的文档应排除;当前问题则优先最新版,并把 superseded 文档降级为历史来源。

    重排需要看到任务,而不只是 query 和 chunk

    通用 reranker 常只输入问题与片段。Agent 场景还可以提供 task type、当前路径、所需证据类型和用户权限摘要。例如 fix-failure 任务优先含错误原因与可执行检查的片段,verify-fact 任务优先权威来源。

    重排输出最好包含 reason tags,不必让模型写长解释:path-matchcurrent-versioncontains-exceptionsecondary-source。调试时能看到为何被提升,也能发现某个 tag 被滥用。

    对于相似度接近的结果,来源多样性很重要。top-5 全是同一文档相邻 chunk,不如保留步骤、例外和另一份权威确认。可用 MMR 或按 canonical source 限额去重。

    top-k 应随任务和证据密度变化

    查一个函数定义,top-3 通常够;研究一个架构决策,可能需要十几条来源。固定 top-20 会让简单任务浪费上下文,固定 top-3 又让复杂任务证据不足。

    先召回较大候选池,在重排后依据分数间隔、来源覆盖和预算动态截断。若第一名明显领先且是当前权威源,可以少取;若候选互相冲突,应保留双方并提示验证。

    注入预算按正文 token 计算,不按条数。五个超长 chunk 可能比十五个短证据更贵,必要时抽取支持句并保留回原文的引用。

    线上点击不是自动的“正确标签”

    用户打开第一条结果,可能只是因为它排第一;复制了模型回答,也可能没发现错误。隐式反馈可以辅助发现问题,不能直接当 ground truth 自我强化。

    更有价值的信号包括:用户明确选择另一来源、指出文档过期、工具随后读取了哪个文件、最终修改涉及哪个模块、引用被人工接受或驳回。进入训练集前去除敏感信息并抽样复核。

    检索日志只保存 query hash、特征摘要、候选 ID 和排名理由;原始用户文本是否保留要服从隐私策略。

    评测指标要对应 Agent 的下一步

    Recall@k 衡量必需证据是否进入候选,MRR/NDCG 衡量排序,citation coverage 衡量回答引用,wrong-action rate 衡量错误检索是否诱发错误工具。最后一项往往最重要。

    一个系统 top-1 准确率稍低,但低置信度会拒答并继续读取,实际副作用可能更安全;另一个 top-1 很高,却在错误时自信修改文件。离线指标和端到端 Eval 应一起看。

    每次调权重都跑按意图分桶的样本,防止修好 find-file 却伤害 explain-decision。检索优化不是追求一个全局分数,而是让不同任务拿到适合行动的证据。

    缓存一次排序结果时,把上下文条件放进 key

    同一句“怎么发布”在项目 A、项目 B、管理员和访客眼里应返回不同内容。只按 query 文本缓存,会把跨项目或高权限结果复用给错误用户。Cache key 至少包含 corpus snapshot、tenant/project、权限摘要、intent、asOf 和排序版本。

    文档更新、ACL 变化、reranker 升级后 key 自动失效,不需要全局清空。缓存正文仍在返回前做当前权限检查,防止权限撤销后 TTL 内继续可见。

    一次真实事故可以这样定位:访客问发布流程,命中管理员缓存,模型没有执行工具但回答了内部命令。Trace 里 retrieval result 的 cacheHit=true、aclSnapshot 与当前 user scope 不同,说明问题在缓存隔离,不在模型。

    修复后增加成对 Eval:同 query、同 corpus,两个权限身份必须得到不同候选;撤销权限后旧 key 不命中;历史 asOf 查询仍能取旧文档,当前查询只能取最新版。检索优化因此也包含缓存一致性和安全,不只是向量分数。

    Hard Negative 比随机无关文档更能训练排序器

    评测里放一篇菜谱作为负例,检索器很容易区分,却不能代表生产难度。真正有价值的 hard negative 是:标题几乎相同但属于旧版本、同名模块但另一个项目、包含错误码却只是事故复盘、管理员政策对当前用户不可见。

    每个查询保存至少一个 hard negative,并标注排除原因。排序器不仅要把正例提上来,还要给旧版、错 scope、无权限和仅背景材料正确降权/过滤。失败报告才能说是哪类边界没学会。

    训练或调权重时按来源分组切分,避免同一文档相邻 chunk 同时进入训练与测试造成虚高。时间型语料采用按时间切分,验证新版本出现后旧规则是否退位。

    标注争议本身也是检索信号

    两位专家对“这条证据是否足够”意见不同,不要强行平均成一个标签。保留 disagreement、角色和理由,区分事实检索与行动建议。候选可以相关,却不足以授权操作。

    离线指标按一致样本报告主分数,争议样本单独观察拒答和多证据组合。系统若在争议处给出保守引用与查证,比强行猜唯一答案更符合 Agent 场景。

    Hard negative 与争议集会随着架构变化过期。每次源文档 major version 更新,重新审查标签与 asOf;错误标签比模型波动更容易让优化走错方向。

    本文目录
    本文目录