吃透 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-match、current-version、contains-exception、secondary-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;错误标签比模型波动更容易让优化走错方向。