websearch-mcpserver 复盘:聚合为什么重做了四次
自建检索服务 2026-03-20 init,六个月 78 个 commit,Go 实现。聚合设计重做了四次,没有一次是计划好的——每次都是撞上一个靠不住的东西才动手。这篇按"是什么靠不住"组织,同时把核心模块的真实实现放进来:引擎接口、KeyPool、apipool、Wigolo 评分管线。工具链的三件套(academicsearch / cleanfetch / pdf_parser)另成一篇。
前提:三个约束
- 国内网络下 Tavily/Exa 这些商业 API 时通时断,Google/DDG 直连基本没戏,代理还得自动检测
- agent 检索是高频操作,付费额度烧得飞快,手里只有各家散落的免费额度
- 搜索结果对 agent 不友好(无摘要、无元数据、条数失控)的话,一次检索吃掉半个上下文窗口
第一版重做:聚合器靠不住
第一版接 searxng,聚合、去重、排序都现成,我只写 MCP 接口的壳。但它把三样东西锁死了:多一个实例的运维、上游引擎在国内的可达性、排序黑盒没法按 agent 场景定制。
结论:聚合逻辑是这个项目唯一的核心竞争力,不能外包。于是有了后面所有版本的地基——
地基:一个 4 方法的引擎接口
所有引擎(百度、Bing、Google、DDG、Tavily、Exa、AnySearch、豆包、9 个学术引擎)实现同一个接口,pkg/search/inf.go:
| |
接口刻意做小,引擎差异靠两个机制吸收:可选能力用单独的小接口(时间范围是 SearchTimeRanger,实现了才有;豆包的 Custom 端点实现了它);各家 score 不可比的问题不管——结构化结果只带原始排名,可比性留给评分管线。
后面加一个引擎的成本是:写一个文件 + factory 一行注册。这个地基决定了后面三次重做都不用推倒接口层。
第二版重做:单家引擎靠不住
直连各引擎自己聚合(e74e427、f0d9a33、5890e66)。多引擎并发、单家失败不阻塞;降级链成型:每个模式主引擎失败自动回退,无 Key 自动降级免费引擎。降级链不是容错补丁,是产品形态本身。
第三版重做:免费额度靠不住 —— KeyPool + apipool
手里散着 anysearch、百度千帆、Tavily、Exa、豆包的 Key,每家免费额度单用不够烧。apipool 每次只调一个供应商,失败自动切换,同一供应商内先试完所有 SK 再换下一家。
两个实现细节值得展开。
KeyError 精确失效。请求失败时要标记"刚才用的那个 Key"失效,但错误在多层传播后容易把 Key 带进日志。实现是一个专用的 error 包装,pkg/search/apipool.go:
| |
KeyPool 冷却自愈。Key 失效不是永久的(限流、欠费都会恢复),所以不是删掉而是打 30 分钟冷却戳,pkg/search/keypool.go:
| |
轮转指针原子递增,冷却到期的 key 自动回池;全部失效时返回最早恢复的那个而不是直接报错——配合百度网页搜索兜底,池子任何情况下不空转。weighted 策略下供应商有效权重 = 配置权重 × 当前可用 SK 数,Key 冷却权重自动下降、恢复自愈。
第四版重做:混合结果靠不住 —— Wigolo 管线
hybrid 模式全引擎并发后,新问题是 9 个引擎的结果混在一起,转载站、镜像站、SEO 农场霸屏。这条线最终长成了完整的本地评分管线,全程纯启发式——搜索本身不能再引入一次 LLM 调用的成本和延迟。
编排层 HybridSearchImpl 支持按引擎配置过滤:每个引擎可以单独设最低分、单引擎条数上限、引擎权重(影响 RRF 融合分):
| |
核心是 RRF(Reciprocal Rank Fusion),pkg/search/enhance.go:
| |
只比排名不比分数——第二版做过分数归一化,但各家 score 口径永远做不齐,RRF 从根上绕开。融合之上叠四层信号:
- 词汇对齐:查询词与标题/内容的词级匹配,停用词表是中英混合场景自己攒的(连 latest/current 这种检索场景特有的口水词都进去了),稀有词和连续短语加权
- 域名品质惩罚:品牌/电商/词典站误匹配降权(独立成
enhance_domain.go) - 共识/权威/时效加分:多引擎都返回的结果天然更可信,
ConsensusBoost按引擎数加性加分 - 低分阈值过滤 + MMR 贪心重排:Token Jaccard 相似度,把转载站和同源博客打散
学术侧是同一套思路的变体,放到工具链那篇细说。
检索之上:意图、摘要与缓存
smartsearch 的 LLM 集成是可选的,注册逻辑跟着配置走:LLM 开着就注册带 intent 参数的版本——agent 可以声明检索目的(查资料/找代码/看新闻),后端按意图调摘要策略;没配 LLM 就注册无 intent 版本,功能不缺。
流式摘要的降级链:LLM 流式摘要失败 → 非流式摘要 → 原始结果直接返回。摘要永远不是必需品,是锦上添花。
缓存用 SQLite(modernc.org/sqlite 纯 Go 驱动,不引 CGO,单二进制能保住),WAL 模式,6 小时过期按最近命中时间算,30 分钟定时清理,学术和非学术结果按参数区分防混用。缓存查询异常就跳过缓存直接搜——缓存也是会坏的,它坏不能拖累主链路。
一直都在的暗线:网络环境靠不住
散落在所有 commit 里的一条线。系统代理检测做了三层:Windows 注册表(ProxyEnable/ProxyServer)+ WinHTTP + 环境变量,后台 30 秒轮询,DynamicProxyTransport 请求级动态解析——Clash/V2RayN 开关系统代理不用重启服务,下一个请求自动跟上。加上 DDG 仅在代理可用时进引擎池、Google 默认禁用、Google Scholar 403/429 指数退避加轮换 UA、CAPTCHA 识别后直接报错不浪费重试、arXiv 内置 1 req/s 限流器。
结论:引擎可用性不是布尔值,是"在哪个网络环境下"的函数,配置里每个开关背后都是一次实测。
无状态开关
2026-07-28 MCP 规范修订把无状态定为方向,8 月底的 v3.2.1(f46b091)跟着加了 mcp_stateless 开关:每个 POST 独立处理,免 initialize 握手与 Mcp-Session-Id,GET SSE 长连直接 405。对这个服务改造成本为零——工具全是请求-响应式,会话本来就用不上。
复盘小结
四次重做不是互相推翻,是上一版把某类问题解决干净后暴露出下一类。这个项目对"失败"的处理已经形成固定套路:引擎失败有降级链、Key 失败有冷却池、供应商死光有百度兜底、摘要失败回退原始结果、缓存坏了跳过——设计里没有假设任何东西可靠。工具链的三件套(academicsearch / cleanfetch / pdf_parser)和安全设计在工具链篇。
