AnySearch 在 Agent 多轮查询中的上下文重写与结果去重

文章导读
Agent 多轮对话里,用户后一句常常省略前面已经说清的主语。比如先问 AnySearch 支持哪些检索方式,再问价格呢,如果直接把“价格呢”交给搜索接口,结果几乎不会命中定价说明。处理方向很明确:在调用搜索前把最近对话拼起来做查询重写,让查询可以独立执行;在拿到结果后按内容指纹去重,避免同一个信息反复出现。重写与去重步骤不假设搜索工具自带能力,只描述由 Agent 程序在调用前后自行完成。
📋 目录
  1. 收集最近两轮对话作为重写输入
  2. 用通用规则或调用语言模型重写查询
  3. 对返回结果按内容指纹去重
  4. 在重写前后分别执行搜索并对比结果
  5. 固化到 Agent 工具逻辑中
A A

Agent 多轮对话里,用户后一句常常省略前面已经说清的主语。比如先问 AnySearch 支持哪些检索方式,再问价格呢,如果直接把“价格呢”交给搜索接口,结果几乎不会命中定价说明。处理方向很明确:在调用搜索前把最近对话拼起来做查询重写,让查询可以独立执行;在拿到结果后按内容指纹去重,避免同一个信息反复出现。重写与去重步骤不假设搜索工具自带能力,只描述由 Agent 程序在调用前后自行完成。

上下文重写与结果去重适合放在 AnySearch 调用层,作为 Agent 工具逻辑的一部分。适用场景:用户后续问题包含省略、指代或依赖前文名词。操作动作:取最近两轮用户与助手消息,拼接后用规则或 LLM 补全查询;对返回结果的标题与摘要计算哈希并去重。验证方式:重写前后分别搜索,对比命中关键词差异。风险边界:重写过度会改变原意,需保留原文作对照;去重阈值需要结合具体内容调整。

收集最近两轮对话作为重写输入

重写输入不需要整段历史。通常取最近两轮对话已经足够,也就是包含用户上一条、助手上一条,以及当前这条用户消息。太长的上下文会把早期无关主题带进查询,导致搜索范围被放大。从会话存储中取出用户和助手消息,按顺序拼接成待重写文本,放给后续重写函数使用。

def build_rewrite_context(messages, recent_n=4):
    parts = []
    for msg in messages[-recent_n:]:
        if msg['role'] in ('user', 'assistant'):
            parts.append(msg['role'] + ': ' + msg['content'])
    return ' | '.join(parts)

recent_n 取 4,因为两条用户消息加两条助手消息刚好覆盖两轮。如果会话中有工具调用返回片段,建议先过滤或截断,避免把长文本直接拼进上下文。拼接后的文本应当和当前用户消息一起交给重写步骤。

用通用规则或调用语言模型重写查询

重写查询的目的是把省略和指代补全,使查询可以独立执行。两种方式可以先用规则兜底,再用模型处理更模糊的指代。规则层面,遇到当前消息里的“它”“这个”“那个”,取上一轮用户消息里的核心名词替换;当前消息缺少动词时,把上一轮用户消息的动词补上;专有名词和非缩写词一律保留。这样处理成本低,适合高频固定场景。

AnySearch 在 Agent 多轮查询中的上下文重写与结果去重

如果规则不好覆盖,可以调用语言模型做改写。提示词骨架如下:

你是查询改写助手。给定最近对话,把最后一条用户消息改写成一个可以独立搜索的查询。
要求:不添加原文没有的信息;保留专有名词;只补全省略和指代。

最近对话:
{context}

当前用户消息:
{query}

改写后的查询:

调用时把上一步拼接的 context 和当前 query 填入占位符。如果模型返回空或明显不相关,建议退回原始查询,不要强行使用改写结果。

对返回结果按内容指纹去重

搜索接口可能返回多条指向同一内容的记录,只是标题或摘要略有差异。去重策略可以基于标题和摘要的拼接文本计算哈希,过滤已经出现过的记录。保留最早记录还是最新记录,取决于场景:如果想保留首条原始来源,用最早;如果想保留更新版本,用最新。在去重前先按发布时间或相关度排序,去重时才能正确保留。

AnySearch 在 Agent 多轮查询中的上下文重写与结果去重
from hashlib import sha256

def dedup_results(results, keep='earliest'):
    seen = set()
    out = []
    for item in results:
        raw = (item.get('title', '') + ' ' + item.get('snippet', '')).strip().lower()
        fp = sha256(raw.encode('utf-8')).hexdigest()
        if fp in seen:
            continue
        seen.add(fp)
        out.append(item)
    return out

如果结果字段里有唯一 URL 或资源 ID,优先用这些字段做指纹比文本拼接更可靠,因为不同页面可能引用同一份摘要。没有唯一标识时再退回标题与摘要哈希。

在重写前后分别执行搜索并对比结果

重写是否有效,需要在同一搜索接口上分别用原始查询和重写查询执行一次,然后对比结果。记录两种查询下的命中关键词差异:原始查询是否泛化,重写查询是否引入了前文专有名词。例如原始查询“价格呢”的结果里可能有大量通用价格词,而重写后的“AnySearch 的定价方案是什么”应该能命中“AnySearch”与“定价”同时出现的结果。关键要看专有名词是否进入结果,而不是追求命中数量增加。

AnySearch 在 Agent 多轮查询中的上下文重写与结果去重
def compare_queries(original, rewritten, search_func):
    r1 = search_func(original)
    r2 = search_func(rewritten)
    hit1 = count_keyword_hits(original, r1)
    hit2 = count_keyword_hits(rewritten, r2)
    log_event('rewrite_compare', {
        'original': original,
        'rewritten': rewritten,
        'hit_diff': hit2 - hit1
    })
    return r2 if hit2 >= hit1 else r1

count_keyword_hits 需要自己实现,可以从标题与摘要中做简单包含判断。对比时需要固定同一个搜索函数和同样的结果条数,否则比较没有参考价值。

固化到 Agent 工具逻辑中

重写和去重不应该由每个业务页面各自实现。建议把这两个操作封装成可复用函数,让 Agent 在调用 AnySearch 前先执行重写,拿到结果后执行去重。主流程如下:

def search_with_context(query, messages, search_func):
    context = build_rewrite_context(messages)
    rewritten = rewrite_query(context, query)
    raw_results = search_func(rewritten)
    deduped = dedup_results(raw_results, keep='earliest')
    log_event('anysearch_rewrite', {'original': query, 'rewritten': rewritten})
    log_event('anysearch_dedup', {'before': len(raw_results), 'after': len(deduped)})
    return deduped

其中 rewrite_query 可以是规则函数,也可以封装 LLM 调用;dedup_results 为上面第三节的去重函数。日志埋点至少要记录原始查询、重写查询、去重前后的结果数量,方便后续判断重写是否带来收益。如果重写接口超时或报错,直接把原始查询传给搜索函数,不要因为重写失败阻断主流程。