代码路径看上去有点像搜索系统的反面:产品数据用几个 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 成本也越高。快照更新还要解决原子切换、失败回滚和版本观测,不能靠覆盖文件碰运气。
容量上升时,优先做的不必是引入搜索集群:
- 从完整产品快照派生更紧凑的
search projection; - 为分类、状态、保司和渠道建立本地
set或bitmap索引; - 预先归一化年龄、期限和金额;
- 先对本地字段索引求交,再扫描剩余候选;
- 让长责任详情按产品编号延迟加载。
这些优化仍然保留“完整候选域先执行硬条件”的性质。
搜索引擎应该晚一点出现
产品进入百万量级、数据无法装入单机内存、全量扫描已经成为明确的 CPU 和 QPS 瓶颈,或查询主要变成长文本语义发现时,搜索引擎才真正开始划算。
此时结构化条件仍应下推到搜索引擎的 filter 阶段,不能先用 BM25 或向量相似度截取一批产品,再去碰运气地找硬条件。
eligible = full_snapshot.filter(hard_constraints)
display = rank(eligible).take(k)Top K 可以控制算力和展示预算,但它应该出现在硬条件之后。顺序不要反过来。