AnySearch 这类检索接口,真正花时间的部分往往在调通之后:结果太散或太粗,字段里一半用不上,超时以后不知道该怎么向调用方交代。要减少返工,接入前先把查询粒度、字段白名单、失败语义这三件事写成纸面约定,再动手写代码。查询粒度决定一次请求查一条还是查一批、top_k 给多大;字段白名单决定解析层要写多少个分支;失败语义决定空结果、超时、限流回来之后调用方怎么处理、要不要交给模型。这三件事一旦定下,解析代码、提示词模板、日志字段都能一次写完,不用在联调中途回头重写。
适用场景:准备接入通用检索接口、又不确定一次查一条还是批量查的团队。操作动作:先用两三个真实业务问题写出最小查询语句,用粗、细两种粒度各跑一次做对照,再定字段白名单与必填校验规则,最后把空结果、超时、限流三类失败的返回结构和重试策略固定下来。验证方式:把每项约定变成一条可执行的检查,用日志或页面返回结果逐条核对。风险边界:具体接口支持哪些参数、是否支持批量、字段名如何拼写,都要以实际可用的配置和返回为准,本文给的是通用骨架,需要结合环境确认后再落地。
把业务问题拆成最小查询单元
不要从接口文档的参数出发,从用户真实会问的问题出发。挑两三个高频问题,逐个写成一条最小查询语句:能表达检索意图的最短字符串,加上必要的限定条件。写完之后再判断哪些可以合并成一次请求。
# 问题 1:退款多久到账
query: "退款 到账 时间"
# 问题 2:发票能不能重开
query: "发票 重开 条件"
# 问题 3:会员自动续费怎么关
query: "会员 自动续费 关闭"
可合并的情况通常有三类:几个问题共享同一检索域、同一 top_k、同一字段需求,且结果能按 query 原样回填区分;问题之间只是关键词替换,语义不依赖彼此;批量失败可以接受整体重试,不需要逐条控制超时。不可合并的情况也要写明:问题需要先改写再检索的(比如先要判断用户问的是账单还是功能)、需要逐条独立超时和降级的、以及多个问题共用一条结果会造成归属混乱的。批量请求看起来省事,代价是单条失败无法单独定位,日志里只能看到一批的结果,出问题时要重跑整批。
用两三种粒度各跑一次做对照
同一个问题,用粗粒度和细粒度各跑一次,把结果并排看。粗粒度一般指把用户原话整句丢进去,top_k 给大;细粒度指抽出核心关键词、必要时加域或时间限定,top_k 收小。判断依据不用复杂指标,就看两件事:返回条数是否超出调用方实际需要,前几条是否能直接回答问题。
| 对照项 | 粗粒度(整句 + 大 top_k) | 细粒度(关键词 + 小 top_k) |
|---|---|---|
| 查询语句 | 退款多久才能到账啊 | 退款 到账 时间 |
| 实际返回条数 | 待填 | 待填 |
| 前 3 条是否命中意图 | 待填 | 待填 |
| 是否混入无关内容 | 待填 | 待填 |
| 结论 | 待填 | 待填 |
假设调用方一屏只展示 10 条,粗粒度返回几十条并不会让回答更好,只会让解析层多做无用过滤;反过来,如果细粒度把限定加得太死、返回条数为零,就是粒度削过头了。选哪种取决于调用方的展示位和后续用途:需要给模型提供多样性证据时偏粗,需要直接命中一条明确答案时偏细。这张对照记录要留下来,后面调整参数时才有基准,不要只凭印象说“好像差不多”。
定字段白名单与必填校验
返回字段越多,解析层越容易写出用不上的分支。接入前把白名单列出来,只申请下面这些字段,其余一律不解析、不落库、不拼进提示词。
| 字段 | 用途 | 缺失时的处理 |
|---|---|---|
| title | 结果展示与去重 | 必填,缺失视为无效条目丢弃 |
| url | 溯源与引用 | 必填,缺失视为无效条目丢弃 |
| snippet | 给模型做摘要输入 | 降级为仅标题展示,不阻断 |
| published_at | 时效判断 | 标记为未知,时效敏感场景降低排序 |
必填校验的判定规则要能写成一行判断,不要含糊。建议:title 与 url 任一为空,该条目直接判无效;有效条目数少于调用方约定的最小条数时,整批结果标记为 degraded;snippet 缺失只降级不丢弃。校验失败后的动作固定为三步:先丢弃无效条目并计数,再判断剩余有效条目是否够用,不够则走兜底(换粒度重查一次,或用原始问题再查)。计数要打进日志,否则事后无法判断是接口返回变差还是本地解析写错。
def pick(item, fields=("title", "url", "snippet", "published_at")):
return {k: item.get(k) for k in fields}
def valid(item):
return bool(item.get("title")) and bool(item.get("url"))
定失败语义
空结果、超时、限流三类失败,返回结构最好统一成一个信封,调用方只判断一个字段就能分支。不要在调用方各处写 if 判断 HTTP 状态码,也不要让空结果和超时都表现为“没有内容”。
{
"status": "ok | empty | timeout | rate_limited | error",
"items": [],
"retryable": false,
"degraded": false,
"message": ""
}
| 失败类型 | 返回结构 | 是否重试 | 是否上报给模型 |
|---|---|---|---|
| 空结果 | status=empty,items 为空数组 | 不重试,或换粒度重查一次 | 上报“未检索到相关内容”,不要让模型自行补答案 |
| 超时 | status=timeout,retryable=true | 按约定次数重试,仍失败则降级 | 上报检索不可用,模型应给出保守回复 |
| 限流 | status=rate_limited,带退避提示 | 退避后重试,不立即重发 | 上报服务繁忙,避免连续触发 |
三类失败的边界要说清:空结果不是错误,通常不重试,重试也大概率还是空;超时可能是瞬时也可能是查询本身太重,重试次数要有上限;限流的重试必须带退避,连续重发只会让情况更糟。每一种都要在提示词里留一句对应的话术,否则模型拿到空数组容易编内容。
按清单逐项验证一遍
约定写在文档里不算数,逐条跑一遍才算。下面这张清单可以直接照着做,状态栏按实际情况标“通过”或“待调整”。
| 检查项 | 验证方式 | 状态 |
|---|---|---|
| 最小查询语句能表达意图 | 用真实问题跑一次,人工看前几条是否相关 | 待填 |
| 批量与单条的边界已写清 | 对不可合并的问题单独发一次请求,确认返回可区分 | 待填 |
| 粒度已选定并留对照记录 | 粗、细各跑一次,条数与相关性记入表 | 待填 |
| 字段白名单与接口返回一致 | 打印一条原始返回,逐个核对字段名 | 待填 |
| 必填缺失能正确丢弃 | 构造缺 title 或 url 的条目,确认被判无效并计数 | 待填 |
| 三类失败返回结构正确 | 分别制造空结果、超时、限流,看信封字段 | 待填 |
| 降级路径可用 | 强制触发降级,确认兜底查询被调用且有日志 | 待填 |
验证时最好用同一组问题反复跑,记录改了哪一项、结果有什么变化。有些条目一跑就会发现要调整,比如字段名和接口实际返回不一致、批量请求里单条失败无法定位,这些都属于正常情况,改完再验一次即可。清单全部通过之后再合并代码,比联调中途反复改解析逻辑要省事得多。