【元宝搜索开发者征文大赛】搜到 ≠ 能证:我用元宝搜索做了一个敢下结论的取证引擎
本文参与「元宝搜索开发者征文大赛」
项目地址:VeriTrace(零依赖 Node.js,装好 Node 就能跑)
项目亮点速览
如果你只来得及看一段:这是一个把"搜到"变成"能证"的取证引擎,核心四件事——
- 可控参数:Industry / Freshness / Site 让检索从"问一句"变成"说清楚要什么"。
- 可标识调用:每次请求带 RequestId,检索过程可回溯、可追责。
- 审计封存:全链路 JSONL + SHA-256,半年后仍能证明结论没被改过。
- 诚实留白:四态判定明确给出"已证伪 / 查不清",不替你下注。
一、先说那个让我卡住的问题
去年底,朋友问我一件很具体的事:他在考虑给一家工厂下代工订单,金额不小,问我这家公司"靠不靠谱"。
我打开浏览器,搜了二十多分钟。
- 官网说"深耕行业十五年,服务客户超百家";
- 一个行业媒体号说"该公司去年完成 B 轮融资";
- 某个问答平台上有人说"这家拖过供应商货款";
- 还有个聚合站把这几个说法混在一起,写成了"行业新星"。
四种说法,三个来源,彼此打架。更麻烦的是——我没有任何一条能拿去当证据。融资那条不知道是哪一年的,拖款那条是个匿名回答,官网的"十五年"没法验证。
我最后跟他说了一句很没底气的话:“你再问问同行。”
问题不在于我搜不到。搜到了一堆,但没有一条我敢拿来做决定。
这件事让我意识到:搜索产品和决策之间,缺的不是信息量,是证据。
二、"搜到"和"能证"之间差四件事
把那段经历拆开,差的是四件事:
1. 出处不可回溯。
聊天框里的搜索是模型替你决定的:搜什么词、拆成几段、翻到第几页、挑哪几条放进上下文。最后给你一段总结,中间每一步你都看不见,也没法复查。
2. 结果不稳定。
同一个问题今天问和下周问,返回的页面可能完全不同——而且你不会知道它换过。判断建立在这批材料上,材料却悄悄变了。
3. 范围不受控。
我想只看政府公示系统,或者只要最近一年的新闻。聊天框里做不到,只能靠措辞"求"它。
4. 没查到被当成没问题。
搜一圈没发现异常,工具就默认"这家没问题"。但"没人提过"和"确实没事"是两件完全不同的事。
要解决这四件事,需要一个可编程、参数可控、每次调用可标识的检索接口。
这就是我找上元宝搜索的原因。
三、元宝搜索给的是什么
腾讯元宝的搜索能力在腾讯云上以wsa服务开放,核心接口是SearchPro。它和"在 App 里搜一下"最大的区别在于:请求是你发的,参数是你定的,结果原样回来。
两条接入路径
| 方式 | 域名 | 鉴权 |
|---|---|---|
| API KEY | api.wsa.cloud.tencent.com/SearchPro | Authorization: Bearer |
| TC3 签名 | wsa.tencentcloudapi.com | X-TC-*头 + HMAC-SHA256 |
报名活动送的是 6000 次/月额度,走 API KEY 模式即可。我在项目里把两条都实现了,签名逻辑手写不引 SDK——原因后面讲。
六个参数,正好对上四个诉求
| 参数 | 取值 | 对我意味着 |
|---|---|---|
Query | 必填,≤1500字节 | 检索词 |
Mode | 0 公开网页 / 1 权威垂类 / 2 混合 | 要不要拿权威信源卡片 |
Site | qq.com或exclude:a.com``/b.com | 范围受控(正查 / 反查) |
Cnt | 10 / 20 / 30 / 40 / 50 | 一次拿够,少发请求 |
Industry | gov / news / acad / finance | 直接锁定垂直领域 |
Freshness | dN / mN / yN / 日期 / 区间 | 时效受控 |
Industry这个参数是我最意外的。政府、新闻、学术、财经四类分开,等于把"我要查官方信息"这件事变成了一个枚举值,不用靠措辞碰运气。
返回里有个字段,决定了整个项目的走向
响应结构里,Pages是一个字符串数组——每一项是 JSON 字符串,得自己再JSON.parse一次。解析出来每条包含:
{"title":"...","url":"...","date":"2025/02/26 09:37:00","passage":"标准摘要","content":"动态摘要","site":"中国网信网","score":0.84889734,"authority_level":4,"requestId 在响应顶层"}authority_level(0–5,仅旗舰版返回)这个字段是关键。
在此之前,所有做信源分级的项目都在自己拍脑袋:搞一份域名白名单,政府网站算一等、媒体算二等、论坛算三等。这套规则永远不全,也永远滞后。
元宝搜索直接给了一个权威度评分。于是我的分级逻辑可以变成:有authority_level就用它,没有(标准版/极速版)才回落到域名规则。
加上响应顶层的RequestId——每一次调用都有一个唯一标识。
这两样东西凑齐,我才敢往下设计。
四、四个设计决定
决定一:把"一次尽调"拆成 N 个必须回答的命题
不直接搜"某公司 怎么样",而是先定义这张表:
| 命题 | 权重 | 时效 | 检索参数 |
|---|---|---|---|
| 主体存续与工商状态 | 10 | 2 年 | Industry=gov |
| 行政处罚记录 | 9 | 3 年 | Industry=gov |
| 涉诉与被执行 | 9 | 3 年 | Industry=gov |
| 负面曝光(欠薪/跑路/裁员) | 9 | 2 年 | Freshness=y2 |
| 近一年经营动态 | 8 | 1 年 | Industry=news, Freshness=m12 |
| 融资与股权 | 6 | 2 年 | Industry=finance |
| 技术能力佐证 | 5 | 不限 | Industry=acad |
| 员工真实评价 | 5 | 1 年 | Site=exclude:... |
| 客户案例可验证性 | 4 | 不限 | 通用 |
每个命题带三个字段:need(什么才算证据)、askIf(查不清时该问对方什么)、refuteIf(什么情况算反证)。这三个字段后来成了报告里最有用的部分。
决定二:检索预算必须显式管理
这是我认为整个项目最容易被忽略、但最该做的一块。
免费额度 6000 次/月。上面那张表如果每个命题 2–3 条检索式,一次完整尽调 21 次调用。一天跑 20 家就是 420 次,一个月 12600 次——超了。
所以必须做分配。我写了一个两轮分配器:
R1 保底轮:每个命题至少保留 1 条最强检索式(保证不漏项) R2 加强轮:剩余预算按 (剩余检索式数 × 权重) 的比例分配 超支时:按权重从低到高砍,而不是平均砍并且报告里会明说放弃了什么:
▶ 预算:计划 12 次 / 上限 12 次(预算构成约束,按权重裁剪) 削减退化:9 个命题共 9 条次要检索式未执行(按权重从低到高)一个不知道自己放弃了什么的取证,是不可信的。
决定三:四态判定,"查不清"与"已证伪"都是正式结论
只允许四种输出:已证实 / 已证伪 / 部分证实 / 查不清。其中"已证伪"由命题表的refuteIf反证模式触发——当一手来源明确给出与主张相反的事实时,结论不是"查不清",而是"已证伪"。
判定规则写死了一条:
if(usable.length===0){state=UNKNOWN;notes.push('无可用证据');}elseif(freshIndependent===0){state=UNKNOWN;notes.push('证据存在但全部超出时效');}elseif(refuteHit){// refuteHit:命题表的 refuteIf 反证模式命中一手来源state=REFUTED;notes.push('命中反证条件,已证伪');}elseif(hasFreshTier1&&freshIndependent>=2){state=PROVEN;}elseif(!hasFreshTier1&&freshIndependent>=2){state=PARTIAL;notes.push('仅二等来源,缺一手材料');}elseif(hasFreshTier1&&freshIndependent===1){state=PARTIAL;notes.push('有一手来源但仅 1 个独立站点,存在孤证风险');}else{state=UNKNOWN;notes.push('仅 1 个二手来源,撑不起结论');}注意这里用的是freshIndependent而不是independentSources——过期的证据不能算数。一个 2020 年的处罚公示,对"现在这家公司有没有问题"没有证明力。
还有一条规则是刻意的:覆盖度算法不把「查不清」计入得分。
constcoverage=total?(proven+refuted+partial*0.5)/total:0;所以一份"搜了很多但全都没结论"的报告会得到很低的覆盖度。这是设计意图——让"搜了等于没搜"显形。
决定四:审计流水 + 哈希封存
每次调用写一行 JSONL,带RequestId:
{"ts":1793497073796,"query":"某公司 行政处罚","params":{"Industry":"gov","Cnt":20},"requestId":"aa36f6ca-031d-4b73-81eb-6f0f111d2855","nResults":17,"elapsedMs":204}跑完把整条流水按时间排序,算一个 SHA-256 封存:
seal(){constbody=this.records.sort((a,b)=>a.ts-b.ts).map(r=>({ts:r.ts,query:r.query,requestId:r.requestId,nResults:r.nResults}));return{algo:'sha256',itemCount:body.length,digest:crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex'),};}半年后有人质疑某个结论,拿出audit.jsonl重算哈希,一致就说明流水没被改过。
五、六个文档里没写、但一定会踩的坑
这些是我写参数校验时逐条啃出来的,官方文档要么没提,要么一句话带过。
坑 1:Cnt是枚举,不是区间
第一反应会觉得Cnt就是个数字,传15拿 15 条。
constCNT_ALLOWED=[10,20,30,40,50];if(!CNT_ALLOWED.includes(cnt))thrownewParamError(...);只能这五个值。传 15 或 25 直接报参数错误,而且错误信息不会告诉你"这是枚举"。
坑 2:Query的上限是字节,不是字符
文档写的是「UTF-8 字节数不超过 1500(约 500 个中英文字符)」。
'中'.repeat(500) = 1500 字节 ✓ 刚好压线 '中'.repeat(600) = 1800 字节 ✗ 报错中文一个字 3 字节,实际可用的中文字数只有 500 字,不是 1500 字。按字符数校验的代码在这里会静默越界。
constbytes=Buffer.byteLength(q,'utf8');if(bytes>1500)thrownewParamError(`Query 超出 1500 字节(当前${bytes})`);坑 3:Freshness五种形态不能混用,且区间起点不能晚于终点
接受五种:dN、mN、yN、yyyy-mm-dd、yyyy-mm-dd,yyyy-mm-dd。
问题在于如果传2026-08-20,2026-08-10(起点晚于终点),接口返回的错误很难直接看懂是哪里错了。所以我在本地先校验:
consta=Date.UTC(+y1,+m1-1,+d1);constb=Date.UTC(+y2,+m2-1,+d2);if(a>b)thrownewParamError(`Freshness 区间起点晚于终点:${s}`);另外y的取值是y1–y5,m是m1–m12,d是d1–d30。写y2拿到的是"2026 和 2025"两年,不是"近 24 个月"。
坑 4:Site=exclude:最多五个域名,而且和Mode一起用时只对自然结果生效
Site=exclude:a.com|b.com|c.com|d.com|e.com ✓ 五个 Site=exclude:a.com|b.com|c.com|d.com|e.com|f.com ✗ 报错更要紧的是这句:Site与Mode共同使用时,仅对公开网页信源结果(自然结果)生效,对优质权威垂直信源结果(VR 卡)不生效。
意思是:你设了exclude:想挡掉内容农场,但如果Mode让权威垂类卡片进来了,那些卡片不受你的 exclude 约束。做"只要干净信源"的场景时必须知道这一条。
坑 5:Pages里每一项是 JSON 字符串,不是对象
// 错误写法:全是 undefinedres.Pages.map(p=>p.title)// 正确写法res.Pages.map(p=>JSON.parse(p).title)而且单条解析失败不应该拖垮整批:
exportfunctionparsePages(rawPages){constout=[];for(constitemofrawPages||[]){try{constobj=typeofitem==='string'?JSON.parse(item):item;if(obj&&typeofobj==='object')out.push(obj);}catch{out.push({title:'',url:'',passage:String(item).slice(0,200),_parseError:true});}}returnout;}坑 6:authority_level只有旗舰版有,极速版连Mode/Cnt/Industry都不支持
这几个字段的文档标注加粗写着「极速版不支持该参数」/「仅旗舰版返回」。
如果你的分级逻辑强依赖authority_level,那么换了版本就会全线崩。所以必须做回落:
if(al!==null){if(al>=4)tier=1;elseif(al>=2&&tier===3)tier=2;}else{reasons.push('无 authority_level(标准版/极速版不返回)');// 回落到域名规则:.gov.cn / .edu.cn 判一等}六、几个不是 API 的坑,是场景本身的坑
坑 7:41 个来源,可能只是同一篇文章被转了 41 次
第一次看到"独立来源 41 个"时我觉得数字大得反常。查下去才发现,中文社区大量互相搬运同一篇帖子。
改成按站点聚合 + 正文指纹去重:
exportfunctionfingerprint(doc){constbody=String(doc.content||doc.passage||'').replace(/\s+/g,'');if(body.length>=30)return`b:${body.slice(0,80)}`;// 前 80 字当指纹return`u:${doc.url||''}`;// 太短就退回用 URL}宁可把两篇不同的帖子误判成同一篇,也不能把同一篇数两遍。前者只是保守,后者会让结论虚高。
坑 8:搜索结果提到了关键词,不等于提到了这家公司
查「某公司 行政处罚」,回来一堆"如何申请行政复议"的科普页——每页都相关,但没有一页在讲这家公司。
所以在分级里加了一道主体校验:正文里没出现目标主体的,最高只能算二等,且标记_irrelevant,不进证据池。
if(target&&!hay.includes(target)){reasons.push('正文未出现目标主体,降为线索');doc._irrelevant=true;}坑 9:数字引对了,出处可能错
这是这类系统最高发的错误:招聘页写 25–40K,公司官网挂 20–30K,某张对比表里写 98%,点进去发现是另一家公司另一年的数据。
要解决这个需要做数值核对:把25-40K/2.5万/25000统一量纲,按数字边界切开比较(避免 80 匹配到 8000),再检查数字附近有没有同时出现主体、指标和限定词。
这块我目前只完成了设计,尚未实现(诚实标注:仍是项目主要缺口)。设计稿分三步:①量纲归一化——把25-40K/2.5万/25000统一成同一单位与数值区间;②边界感知比较——按数字上下界切分匹配,避免80误命中8000;③共现校验——数字附近必须同时出现主体、指标与限定词,才认定为同一事实。骨架如下:
// 设计稿(尚未实现):数值核对三步functionnormalize(raw){/* 25-40K / 2.5万 / 25000 → 统一数值区间 */}functionrangeMatch(a,b){/* 按上下界相交判定,避免 80 误命中 8000 */}functioncoOccur(text,subject,metric,qualifier){// 数字附近须同时出现主体、指标、限定词,才认定为同一事实returnnear(text,subject)&&near(text,metric)&&near(text,qualifier);}七、跑起来是什么样
演示模式下(--dry-run,不消耗额度,用内置样例数据验证链路):
▶ 场景:供应商尽调 ▶ 目标:示例公司 ▶ 模式:演示(不消耗额度) ▶ 预算:计划 12 次 / 上限 12 次(预算构成约束,按权重裁剪) 削减退化:9 个命题共 9 条次要检索式未执行(按权重从低到高) [ 1/12] 主体存续与工商状态 5 条 示例公司 营业执照 统一社会信用代码 [ 2/12] 行政处罚记录 6 条 示例公司 行政处罚 [ 3/12] 涉诉与被执行 4 条 示例公司 被执行人 ... ──────────────────────────────────────────────────────── 结论:建议终止 覆盖度 100% 状态:已证实 7 | 已证伪 2 | 部分 0 | 查不清 0 调用:12 次(缓存命中 0),平均 213ms,共 44 条结果 封存哈希:d4d4113d7cef7ff3fac02563a20a7f93… ────────────────────────────────────────────────────────生成的report.md长这样:
### ✗ 行政处罚记录 已证伪 来源统计:一等 8 | 二等 0 | 三等 1 | 独立站点 5 判定依据:一等来源 8 条,独立站点 5 个;命中反证条件 | 等级 | 标题 | 站点 | 时间 | 权威度 | | --- | --- | --- | --- | --- | | T1 | 示例公司 因违反市场监管规定被处罚 罚没款共计 12.6 万元 | 国家市场监督管理总局 | 2026/04/15 | 5 | | T1 | 示例公司 因违反市场监管规定被处罚 罚没款共计 12.6 万元(2) | 信用中国 | 2025/09/05 | 4 | ...必须说明:上面的数据是演示模式的样例,不是真实检索结果。每次演示运行,报告开头都会强制打印「本次为演示模式,数据为内置样例,非真实检索结果」。换成真实 KEY 后,同一条命令走的就是真实接口。
八、最有用的不是结论
用下来发现,报告里最值钱的不是那句"建议终止",而是最后那份待确认清单:
## 四、待当面确认清单 - **行政处罚记录**:近三年是否受到过市场监管、税务、环保方面的行政处罚 - **涉诉与被执行**:是否存在未结诉讼或被执行记录,金额多少 - **负面曝光**:是否存在工资拖欠、供应商货款延期的情况「查不清」不是程序出错了。它的意思是:**公开信息撑不起这个判断,别搜了,该找活人聊了。** 而且它告诉你该找谁、问什么。 这也是我对"AI 搜索"这件事的基本看法:搜索引擎的终点不该是一段看起来很像答案的总结,而应该是一份**你知道该信到什么程度**的材料。 --- ## 九、局限,说清楚 1. **演示数据是内置样例**,只用于验证链路。真实效果依赖你自己的 API KEY 跑。 2. **数值核对设计已完成、实现待补**(坑 9),仍是当前主要缺口,设计稿见第五部分坑 9。 3. **`authority_level` 依赖旗舰版**,标准版下分级精度会降。 4. **它不做判断,只做取证**。最终决策仍然是人来做——我只是让它知道自己在押什么注。 --- ## 十、元宝搜索自身的短板(诚实测评) 作为一篇测评,必须说清工具边界。下面几条是我在真实调用里反复撞到的,不是官方缺陷列表,而是使用侧的诚实记录: - \`site:\` 算子支持弱——想限定"只要某站"时常常不生效,只能靠后置过滤兜底。 - 镜像农场内容混入——同一篇稿子被几十个聚合站转载,指纹去重必须开,否则覆盖度被虚假撑高。 - 英文检索偏弱——跨国主体、海外信源场景要谨慎,中文生态才是它的主战场。 - 返回摘要 token 量大——长摘要喂给 LLM 很香,但也吃上下文与成本,所以我有意做了两轮预算分配。 应对思路都写进了工程里:用 **Industry** 锁定 gov 官方源、用 **Freshness** 控时效、用两轮预算压成本、用主体校验过滤镜像农场。短板不是拒绝使用的理由,而是决定"怎么用才稳"的依据。 ## 十一、复现 ```bash git clone <repo> cd veritrace # 不需要 npm install,零依赖 node --test test/core.test.mjs # 27 项测试 # 演示模式 node cli.mjs -s supplier -t "示例公司" --dry-run # 真实检索 export WSA_API_KEY="你的 KEY" node cli.mjs -s supplier -t "某某科技有限公司" -b 120项目结构:
veritrace/ ├── cli.mjs 编排全流程 ├── lib/wsa.mjs 元宝搜索客户端(双通道 + 参数校验) ├── lib/budget.mjs 两轮预算分配 ├── lib/grade.mjs 分级 + 指纹去重 + 主体校验 ├── lib/verify.mjs 四态判定 ├── lib/audit.mjs 流水 + SHA-256 封存 ├── scenarios/supplier.mjs 供应商尽调命题库 └── scenarios/stack.mjs 技术选型命题库最后
元宝搜索这个接口真正有意思的地方,不是它"能搜",而是它把搜索拆成了可控的参数和可标识的调用。
Industry让你能说"我只要官方信息",Freshness让你能说"我只要一年内的",Site让你能说"别给我这些站",RequestId让你能说"这一次调用,我记住了"。
当检索变成这样,它就不再是"问一句答一句",而是一段可以做证据的过程。
搜到很容易。能证,才是难的。
项目基于 Node.js 18+,零第三方依赖。测试 27 项全通过。
本文所述参数与字段均以腾讯云元宝搜索官方文档为准,具体行为以实际接口为准。