代码路径看上去有点像搜索系统的反面:产品数据用几个 JSON 文件发布,进程启动时把它们读成 dict,每次查询都让完整候选池走一遍过滤条件。没有倒排索引,也没有先取向量 Top K

如果把它概括成“用 JSON 做搜索”,就把最重要的边界说反了。请求阶段不读磁盘,JSON 只是版本化的分发载体。真正的搜索数据面是进程内的完整产品快照。

版本化 JSON
  -> 启动时解析和清洗
  -> 进程内 prod_mapper
  -> 请求内存过滤

在几万个保险产品的规模上,内存全量过滤仍在在线请求可以承受的范围内。搜索引擎如果在硬条件执行前截断候选,正确产品可能永远进不了后面的精筛。

flowchart LR
    A["用户自然语言需求"] --> B["Product Search Agent"]
    B --> C["结构化查询计划"]
    C --> D["concurrent_search"]
    D --> E["进程内 prod_mapper 快照"]
    E --> F["场景产品池"]
    F --> G["结构化谓词"]
    G --> H["名称 / 责任 / 详情扫描"]
    H --> I["核保、预算、利益结果求交"]
    I --> M["当前轮完整结果池"]
    M -->|"继续搜索"| B
    M -->|"条件充分"| J["业务排序与同名去重"]
    J --> K["有限候选"]
    K --> L["Filter Agent 精选"]

LLM 写查询计划,代码做布尔判断

AgenticOne 的产品搜索不让模型逐个阅读产品。搜索 Agent 把用户的自然语言编译成结构化工具参数:

{
  "task_type": "product",
  "product_params": {
    "conditions_map": {
      "产品分类": "住院医疗",
      "销售状态": "在售",
      "最高投保年龄": {
        "op": ">=",
        "val": "60岁"
      },
      "免赔额": {
        "op": "<=",
        "val": 10000
      }
    },
    "fuzzy_prod_info_pattern": "质子重离子|特药"
  }
}

搜索 Agent 每轮只产出工具参数。它判断用户说的是年龄下限、保费上限,还是某项责任;本地代码逐个判断产品是否满足。同一个 product task 里的条件使用 AND,数值条件必须经过单位归一化和结构化比较,不交给模糊文本匹配。

conditions_map 最终被执行为一组布尔谓词:

产品分类 == 住院医疗
AND 销售状态 == 在售
AND 最高投保年龄 >= 60岁
AND 免赔额 <= 10000

“无需健告”和“有健告”在语义空间里很近,在投保判断里却是两个相反的布尔值。“日本可保”与“日本除外”、“保证续保二十年”与“保险期间二十年”也一样。这些地方要的是字段、操作符和单位,不是一个综合相似度分数。

ReAct 循环渐进收窄产品池

用户条件明确时,搜索 Agent 通常只调用一次 concurrent_search,把所有已知条件放进同一批 search_tasks。工具结果过宽、过窄,或还需补充约束时,它可以根据这一轮结果继续调用,最多三轮;任务中包含核保查询时,整个搜索只执行一轮。

第一轮可以先建立产品池:

{
  "search_tasks": [
    {
      "task_type": "product",
      "product_params": {
        "conditions_map": {
          "产品分类": "其他医疗",
          "所属保司": {
            "op": "fuzzy_like",
            "val": "某保司"
          },
          "最高投保年龄": {
            "op": ">=",
            "val": "60岁"
          }
        }
      }
    }
  ]
}

工具返回候选后,如果还要增加“住院津贴每天至少 100 元”这项约束,第二轮只追加新谓词:

{
  "scope": "last",
  "search_tasks": [
    {
      "task_type": "product",
      "product_params": {
        "fuzzy_liability_pattern": "住院津贴",
        "liability_conditions_map": {
          "每日津贴金额": {
            "op": ">=",
            "val": 100
          }
        }
      }
    }
  ]
}

继续收窄时,scope="last" 让工具读取上一轮尚未经过展示截断的完整结果池。旧条件已经体现在这个结果池里,第二轮不必重复:

第一轮完整结果池
  ∩ 含住院津贴责任
  ∩ 每日津贴金额 >= 100

搜索时看到的是内存快照

产品快照可以由对象存储或其他发布系统产生,服务启动时下载并解析,后续请求直接复用全局内存对象。加载阶段顺手做掉这些工作:

  • 按产品编号建立映射;
  • 统一分类别名和单位;
  • 展开搜索所需的投保信息;
  • 清理无效责任名和废弃字段;
  • 派生“是否有健告”等布尔字段;
  • 建立业务优先级与热度的辅助映射。

请求链路因此只剩下函数调用和内存遍历。没有对象存储请求,没有搜索集群 RPC,也不需要再用产品编号回查另一份详情数据。快照更新则通过版本、全量重载和增量修改管理,不让正在执行的请求看到半新半旧的数据。

字段有边界,详情扫描靠后

场景白名单、产品名、结构化条件和责任条件先缩小产品池。候选还需要搜索产品详情时,才扫描字段与责任原文。

场景产品池
  -> 产品名 / 结构化条件 / 责任条件
  -> 目的地与地区安全过滤
  -> 产品字段和责任详情正则扫描
  -> 排序、同名去重、精简输出

它不会把整个产品序列化成一条字符串再搜。产品名、责任名和产品详情有不同的扫描边界;产品编号、内部权重、废弃别名和除外字段会被排除。否则“除外承保国家”里出现一次日本,就可能被误读成“日本可保”。

多路召回有明确的集合代数

concurrent_search 可以并发执行产品、核保、预算和利益等任务。多个产品方向代表用户可接受的替代选项,它们取并集;核保、预算和利益条件是候选产品必须继续通过的门,它们逐个取交集。

最终候选
  = product 任务结果的并集
  ∩ 每一个 underwriting 结果集
  ∩ 每一个 budget 结果集
  ∩ 每一个 benefit 结果集

集合运算完成后,确定性代码再按在售状态、业务优先级和产品热度等信号排序、同名去重,截取有限候选交给 Filter Agent 精选。这次截断发生在全库硬条件执行之后,留下的产品已经通过召回条件。

为什么不先让搜索引擎取 Top K

假设搜索引擎先从数万产品中取几百个,再执行年龄、健告、免赔额和续保年限等完整条件。在真正的业务谓词开始工作前,绝大多数产品已经被一个相似度排名丢掉了。

先语义 Top K:
  最终候选 ⊆ 搜索引擎 Top K
 
先全量硬过滤:
  最终候选 = {完整快照中所有满足硬条件的产品}

正确产品如果在语义排名里刚好落在截断线之后,后面的精筛无论多么严谨都救不回它。搜索引擎并非不能执行结构化过滤,但如果硬条件最后仍然要在本地 mapper 上再判定一次,前面的索引就变成一个有损且可失败的关卡。

搜索引擎还会让产品事实变成两份:

prod_mapper 原始快照
  -> ETL
  -> 搜索索引 document

之后每次产品上下架、字段增加、单位归一化和版本回滚,都多了一次同步是否成功的追问。本地快照让过滤、排序和结果解释共用同一个版本。

几万条数据的线性遍历并不夸张

内部测量里,常见结构化查询和名称扫描都能放进当前在线链路的延迟预算,责任详情扫描更慢,但仍未迫使系统增加外部搜索依赖。精确耗时受机器、字段长度和查询条件影响,不能直接当成另一套环境的生产 SLA

本地扫描的请求路径很短:

函数调用 -> 内存遍历 -> 返回结果

外部搜索则需要构造查询、网络请求、索引解析、结果序列化、产品详情回查,还要准备超时、降级和索引刷新策略。当产品只有几万个时,这些运维成本可能比省下的那段 CPU 遍历更贵。

内存账单

Python 对象的内存放大很明显,多 worker 部署时还可能随进程数增长。责任原文越长,正则扫描的 CPU 成本也越高。快照更新还要解决原子切换、失败回滚和版本观测,不能靠覆盖文件碰运气。

容量上升时,优先做的不必是引入搜索集群:

  1. 从完整产品快照派生更紧凑的 search projection
  2. 为分类、状态、保司和渠道建立本地 setbitmap 索引;
  3. 预先归一化年龄、期限和金额;
  4. 先对本地字段索引求交,再扫描剩余候选;
  5. 让长责任详情按产品编号延迟加载。

这些优化仍然保留“完整候选域先执行硬条件”的性质。

搜索引擎应该晚一点出现

产品进入百万量级、数据无法装入单机内存、全量扫描已经成为明确的 CPUQPS 瓶颈,或查询主要变成长文本语义发现时,搜索引擎才真正开始划算。

此时结构化条件仍应下推到搜索引擎的 filter 阶段,不能先用 BM25 或向量相似度截取一批产品,再去碰运气地找硬条件。

eligible = full_snapshot.filter(hard_constraints)
display  = rank(eligible).take(k)

Top K 可以控制算力和展示预算,但它应该出现在硬条件之后。顺序不要反过来。