beacon-mfg-mcp 1.5.4 → 1.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -9
- package/RELEASE.md +81 -3
- package/package.json +1 -1
- package/server.py +263 -33
package/README.md
CHANGED
|
@@ -13,10 +13,17 @@
|
|
|
13
13
|
|------|------|----------|
|
|
14
14
|
| `search_vendors` | 按 关键词 / 城市 / 国标码 检索,返回精简档案 | `query` `city` `gb` `limit` `offset` |
|
|
15
15
|
|
|
16
|
-
> **搜索语义(v1.
|
|
17
|
-
> -
|
|
18
|
-
>
|
|
19
|
-
>
|
|
16
|
+
> **搜索语义(v1.5.5 起:原话直传)**:
|
|
17
|
+
> - **`query` 直接传用户原话即可**,不必自己拆词。服务端会先归一化:
|
|
18
|
+
> ① **抽城市**(「找一下**苏州**做AI的企业」→ `city=苏州`,城市词不再参与关键词匹配);
|
|
19
|
+
> ② **剔填充词**(的 / 找 / 一下 / 企业 / 有没有 …);
|
|
20
|
+
> ③ **认词条**(国标别名 / 国标类名 / **能力词** 如 AI·人工智能·机器学习·大模型)→ 走定向召回;
|
|
21
|
+
> 剩下的部分才作为通用关键词做 AND 匹配。返回体用 `query_parsed` 回显解析结果。
|
|
22
|
+
> 实测:`search_vendors(query="苏州做AI的企业")` → 命中 `赤兔智能工业(苏州)有限公司`(之前是 **0 条**)。
|
|
23
|
+
> - 仍需注意的是**未被识别的连写词**:`query="精密加工"`(四字连写、不在别名表也不在类名里)
|
|
24
|
+
> 仍按**连续出现**匹配,召回少于 `"精密 加工"`(`city=苏州` 实测 2 条 vs 26 条)。
|
|
25
|
+
> 遇到这种生词,正确做法是**先 `suggest_filters`** 拿候选国标码再改用 `gb=`,
|
|
26
|
+
> 而不是反复试同义词 —— 服务端会在命中 <5 条时用 `hint` / `suggested_gb` 主动提示这条路。
|
|
20
27
|
> - **检索架构(v1.2.0 起:按需拉取,不再全量冷启动)**:
|
|
21
28
|
> 早期实现每次查询都要把**全部 fp 分片**拉下来在客户端建索引,复杂度 O(数据总量) —— 11.8 万条约 5.8s,线性外推千万级约 **491s / 2.2GB 内存**,不可用。
|
|
22
29
|
> v1.2.0 改为 **O(命中量)**:索引在**发布侧预构建**(`scripts/gen_search_index.py`,随 `postfetch` 的 `searchindex` 步骤产出 `skills/registry/index/`),
|
|
@@ -26,7 +33,7 @@
|
|
|
26
33
|
> 千万量级下只需增大桶数(如 4096)并按城市对大分片二级切分,首次查询耗时仍与总量基本无关。
|
|
27
34
|
| `get_vendor` | 按 id(+国标码)取完整中文档案 | `id`(必填) `gb`(可选,自动反查) |
|
|
28
35
|
| `get_capability_card` | 按 id 取能力卡(工艺/设备/产能/认证等) | `id`(必填) |
|
|
29
|
-
| `suggest_filters` | **用户原话 → 候选国标小类码**(拆词 + 别名 +
|
|
36
|
+
| `suggest_filters` | **用户原话 → 候选国标小类码**(拆词 + 别名 + 类名 + **能力域**),供改用 `gb=` | `query`(必填) `city` `limit` |
|
|
30
37
|
| `list_industries` | **国标小类货架**(只列有数据的类,附企业条数),供按语义自己挑码 | `keyword` `parent` `limit` `offset` |
|
|
31
38
|
| `start_sourcing` | 【客户 agent 自动触发】识别找厂/代工/采购/询价意图 → 品类识别→宽召回→需求解析→生成 1~2 轮澄清问题,返回 `session_id` | `demand_text`(必填) `audience_id` |
|
|
32
39
|
| `answer_sourcing` | 续接澄清轮次:写回客户答案,返回下一轮澄清问题或初选供应商列表 | `session_id`(必填) `answers` |
|
|
@@ -37,7 +44,7 @@
|
|
|
37
44
|
|
|
38
45
|
所有返回都是 JSON 文本。找不到时返回 `{ "error": ... }` 或 `has_card:false`,不会抛协议错。
|
|
39
46
|
|
|
40
|
-
### 用户原话 → 国标码:给 agent 的调用协议(v1.5.
|
|
47
|
+
### 用户原话 → 国标码:给 agent 的调用协议(v1.5.5)
|
|
41
48
|
|
|
42
49
|
**为什么需要**:`query` 是**字面子串**匹配,而名录按国标小类归档 —— 客户说「机加工」,
|
|
43
50
|
库里写的是「机械零部件加工」,字面不通。实测(`city=嘉兴`):
|
|
@@ -75,9 +82,11 @@
|
|
|
75
82
|
**方式 0 — npm 安装(推荐:无需 clone、无需填路径)**
|
|
76
83
|
|
|
77
84
|
```bash
|
|
78
|
-
npm i -g beacon-mfg-mcp
|
|
85
|
+
npm i -g beacon-mfg-mcp
|
|
79
86
|
```
|
|
80
87
|
|
|
88
|
+
macOS / Linux:
|
|
89
|
+
|
|
81
90
|
```jsonc
|
|
82
91
|
{
|
|
83
92
|
"mcpServers": {
|
|
@@ -89,8 +98,27 @@ npm i -g beacon-mfg-mcp # 或不安装直接用 npx beacon-mfg-mcp
|
|
|
89
98
|
}
|
|
90
99
|
}
|
|
91
100
|
```
|
|
92
|
-
|
|
93
|
-
|
|
101
|
+
|
|
102
|
+
Windows(**必须走 `cmd /c`**):
|
|
103
|
+
|
|
104
|
+
```jsonc
|
|
105
|
+
{
|
|
106
|
+
"mcpServers": {
|
|
107
|
+
"beacon-mfg": {
|
|
108
|
+
"command": "cmd",
|
|
109
|
+
"args": ["/c", "beacon-mfg-mcp"],
|
|
110
|
+
"env": { "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8" }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
> **Windows 上 `"command": "beacon-mfg-mcp"` 会 ENOENT**:npm 全局装出来的是
|
|
116
|
+
> `beacon-mfg-mcp.cmd`(`.cmd` 包装器),无 shell 的 `spawn()` 解析不了它,客户端只会
|
|
117
|
+
> 表现成「连不上」。`npx -y beacon-mfg-mcp` 同理(`npx` 自身也是 `.cmd`)。
|
|
118
|
+
> 2026-10-06 实测:`beacon-mfg-mcp` ❌ / `npx` ❌ / `cmd /c beacon-mfg-mcp` ✅(8 工具)。
|
|
119
|
+
>
|
|
120
|
+
> `env` 里的两个变量**不必填**——启动器(`bin/beacon-mfg-mcp.js`)已默认注入,写出仅为显式/可覆盖;
|
|
121
|
+
> `server.py` 另有 UTF-8 兜底,所以**即使宿主把子进程直接拉起来、没带任何环境变量**也不会乱码或崩。
|
|
94
122
|
|
|
95
123
|
不填 `BEACON_REPO` 时默认走 `https://beacon-mfg.pages.dev`,即开即用。
|
|
96
124
|
|
package/RELEASE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Beacon-MFG 只读 MCP 服务 · v1.5.
|
|
1
|
+
# Beacon-MFG 只读 MCP 服务 · v1.5.5
|
|
2
2
|
|
|
3
3
|
让任意支持 MCP 的主流 agent(Claude Desktop / Cline / Continue / WorkBuddy 等)能够
|
|
4
4
|
**检索与调用**已发布到 Cloudflare Pages(或 GitHub)的灯塔工厂供应商数据,
|
|
@@ -23,7 +23,85 @@ MCP 只做**只读检索**。后端数据采集、英文翻译、库维护等流
|
|
|
23
23
|
后三个构成「采购寻源」多轮会话链路 `start_sourcing → answer_sourcing → refine_sourcing`,
|
|
24
24
|
依赖仓库平级的 `skills/rfq-kernel` 桥接模块。
|
|
25
25
|
|
|
26
|
-
## 本次更新(v1.5.
|
|
26
|
+
## 本次更新(v1.5.5)
|
|
27
|
+
**让「用户原话」真的能用。无破坏性变更:入参、三种接入方式、返回体既有字段全部不变(只增不减)。**
|
|
28
|
+
|
|
29
|
+
### 1. 中文整句直传必然 0 条 —— 关键词切分只有 `q.split()`
|
|
30
|
+
|
|
31
|
+
症状:`search_vendors(query="苏州做AI的企业")` 返回 **0 条**,
|
|
32
|
+
而手工拆成 `query="人工智能" + city="苏州"` 就有 —— 等于把「理解自然语言」的责任
|
|
33
|
+
推给了调用方(agent)和用户。
|
|
34
|
+
|
|
35
|
+
根因:`q` 只按**空白**切分。中文没有空格,整句话是一个 token:
|
|
36
|
+
`["苏州做ai的企业"]`。而关键词是 **AND** 语义(每个 token 都要出现在记录的检索面里),
|
|
37
|
+
没有任何一条记录会包含「苏州做ai的企业」这个串 → 必然 0 条。
|
|
38
|
+
同时城市没被抽出来、能力词也没被认出来。
|
|
39
|
+
|
|
40
|
+
修法:新增 `_normalize_query()`(详见 `server.py` 的注释),在检索前把一句话拆成
|
|
41
|
+
`(city, keywords, cap_hits, alias_hits, segments)`:
|
|
42
|
+
|
|
43
|
+
| 步骤 | 做法 | 例 |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| 抽城市 | 命中 `city.json` 里的城市名(取**最长**),从关键词里摘掉 | 「找一下**苏州**做AI的企业」→ `city=苏州` |
|
|
46
|
+
| 认词条 | 国标别名 / 国标类名 / **能力词**,最长优先、不重叠 | `ai` → 能力域 `tech_ai` |
|
|
47
|
+
| 剔填充词 | 的 / 找 / 一下 / 企业 / 有没有 … | 剩下 `[]` |
|
|
48
|
+
| 留残词 | 识别不了的才当通用关键词(仍走 AND) | — |
|
|
49
|
+
|
|
50
|
+
- 被识别出的词条**保留进关键词**(不丢弃),所以「厂名里真写了这个词」仍能被字面召回,
|
|
51
|
+
既有行为不变。
|
|
52
|
+
- 纯结构字(`厂/店/家/司`)不作关键词:它们会参与 AND 过滤,把「XX有限公司」整批误滤掉
|
|
53
|
+
(实测「昆山 做CNC的厂」在 cnc 被消费后只剩一个「厂」,退化成整城扫)。
|
|
54
|
+
- 返回体新增 **`query_parsed`**,只在这句话确实被拆过时出现,回显 `city` /
|
|
55
|
+
`city_from_query` / `keywords` / `recognized`。
|
|
56
|
+
|
|
57
|
+
实测:`search_vendors(query="苏州做AI的企业")` → **命中 `赤兔智能工业(苏州)有限公司`**(0.97s)。
|
|
58
|
+
|
|
59
|
+
### 2. 能力词只做「整词精确」匹配,句子里永远认不出
|
|
60
|
+
|
|
61
|
+
`cap_alias_codes()` 只把 `q.split()` 出来的 token 拿去和词表精确比对,
|
|
62
|
+
而**国标别名侧(`alias_codes`)一直是子串匹配** —— 两侧行为不一致本身就是 bug。
|
|
63
|
+
于是「AI」这种词只在用户恰好单独输入「AI」时有效。
|
|
64
|
+
|
|
65
|
+
修法:能力词也做子串扫描(ASCII 词加 `(?<![a-z0-9])…(?![a-z0-9])` 词边界,
|
|
66
|
+
避免 `ai` 命中 `chair`、`cnc` 命中 `cncxx`),整词精确仍然优先。
|
|
67
|
+
同时把**纯汉字能力词**并入切词表(ASCII 的 `c`/`3c` 这类不进,太容易把查询切碎)。
|
|
68
|
+
|
|
69
|
+
### 3. Windows 接入配置:`"command": "beacon-mfg-mcp"` 会 ENOENT
|
|
70
|
+
|
|
71
|
+
npm 全局装出来的是 **`.cmd` 包装器**,而没有 shell 的 `spawn('beacon-mfg-mcp')`
|
|
72
|
+
在 Windows 上解析不了 `.cmd`,客户端只表现为「连不上 / 工具不出现」。
|
|
73
|
+
`npx -y beacon-mfg-mcp` 同理(`npx` 自身也是 `.cmd`)。
|
|
74
|
+
|
|
75
|
+
| 写法 | Windows `spawn`(无 shell) |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `beacon-mfg-mcp` | ❌ ENOENT |
|
|
78
|
+
| `npx -y beacon-mfg-mcp` | ❌ ENOENT |
|
|
79
|
+
| `cmd /c beacon-mfg-mcp` | ✅ 正常(8 个工具) |
|
|
80
|
+
|
|
81
|
+
**Windows 请用 `{"command":"cmd","args":["/c","beacon-mfg-mcp"]}`**;
|
|
82
|
+
macOS / Linux 用 `{"command":"beacon-mfg-mcp","args":[]}`。
|
|
83
|
+
`README.md` 与 `BeaconMFG_MCP_安装指南.md` 已按平台分别给出配置。
|
|
84
|
+
|
|
85
|
+
### 4. `suggest_filters` 在能力域命中时不再让 agent 空手翻货架
|
|
86
|
+
|
|
87
|
+
- 能力域命中但国标侧无候选 → `cap_candidates` + `next_call=search_vendors`(原有分支,
|
|
88
|
+
现在能被更多词触发,例如「手机壳」→ 能力域 `家电数码`)。
|
|
89
|
+
- `_attach_routing_hint` 原先只有「0 条且无候选」和「有候选」两个分支,**0 条时会先把
|
|
90
|
+
能力域这条唯一可执行的线索引掉**;现在 0 条也优先给能力域提示,并在带了城市时
|
|
91
|
+
补一句「去掉 city 再试」(能力域企业在空间上很稀疏)。
|
|
92
|
+
- `suggest_filters` 返回体新增 `city_from_query` / `keywords` / `recognized`。
|
|
93
|
+
|
|
94
|
+
### 验收(发布前全绿)
|
|
95
|
+
|
|
96
|
+
- 协议回归 **16/16**(HTTP / stdio 换行分帧 / adapter 标准分帧 / 死代理容错)
|
|
97
|
+
- 语义路由验收 **14/14**
|
|
98
|
+
- 检索哨兵「机加工@嘉兴」**643 条**(修复前 1 条)、城市正确性 **10/10**、关键词疑似无关 **0 条**
|
|
99
|
+
- 冷检索 5.11s / 热 0.97s;端到端「找到嘉兴机加工厂并拿到电话」**6.81s**
|
|
100
|
+
- 归一化自身开销 **1.4~20ms**(相对热查询 ~1000ms 占比 ≤2%)
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 本次更新(v1.5.4)· 已发布
|
|
27
105
|
**把「宿主根本连不上」和「连上了但结果会误导 agent」这两类问题一次收干净。
|
|
28
106
|
无破坏性变更:八个 tool 的入参、三种接入方式、返回体既有字段全部不变(只增不减)。**
|
|
29
107
|
|
|
@@ -281,7 +359,7 @@ v1.5.0 的 idmap 有个致命假设:片存在但片里没这条 id ⇒ 判「
|
|
|
281
359
|
## 安装(三选一)
|
|
282
360
|
|
|
283
361
|
### 1. 从 GitHub Release 下载(推荐,零依赖)
|
|
284
|
-
下载本 Release 的 `beacon-mfg-mcp-mcp-v1.5.
|
|
362
|
+
下载本 Release 的 `beacon-mfg-mcp-mcp-v1.5.5.tar.gz`,解压后:
|
|
285
363
|
```jsonc
|
|
286
364
|
{
|
|
287
365
|
"mcpServers": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beacon-mfg-mcp",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.5",
|
|
4
4
|
"description": "Read-only MCP server exposing the BeaconMFG灯塔工厂 supplier directory (search vendors, get vendor detail, get capability card, multi-turn sourcing) to any MCP-compatible agent. No API key required.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
package/server.py
CHANGED
|
@@ -283,9 +283,9 @@ def _pkg_version() -> str:
|
|
|
283
283
|
try:
|
|
284
284
|
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "package.json")
|
|
285
285
|
with open(p, "r", encoding="utf-8") as f:
|
|
286
|
-
return json.load(f).get("version", "1.5.
|
|
286
|
+
return json.load(f).get("version", "1.5.5")
|
|
287
287
|
except Exception:
|
|
288
|
-
return "1.5.
|
|
288
|
+
return "1.5.5"
|
|
289
289
|
|
|
290
290
|
|
|
291
291
|
# HTTP 基址列表(含兜底镜像)。默认部署 Cloudflare Pages 不可达时,自动回退到
|
|
@@ -991,7 +991,7 @@ def _gb_shelf() -> List[Dict[str, Any]]:
|
|
|
991
991
|
|
|
992
992
|
|
|
993
993
|
def _seg_patterns() -> List[Tuple[str, str]]:
|
|
994
|
-
"""长词优先的匹配表 [(词, 来源)],来源 ∈ alias / gb_name。"""
|
|
994
|
+
"""长词优先的匹配表 [(词, 来源)],来源 ∈ alias / gb_name / cap。"""
|
|
995
995
|
global _SEG_PATTERNS
|
|
996
996
|
if _SEG_PATTERNS is None:
|
|
997
997
|
pats: Dict[str, str] = {}
|
|
@@ -1001,6 +1001,16 @@ def _seg_patterns() -> List[Tuple[str, str]]:
|
|
|
1001
1001
|
for r in _gb_shelf():
|
|
1002
1002
|
if len(r["name"]) >= 2:
|
|
1003
1003
|
pats.setdefault(r["name"], "gb_name")
|
|
1004
|
+
# 2026-10-06 补 cap(能力词)。在此之前这里只收「国标别名 + 国标类名」,
|
|
1005
|
+
# 而「人工智能 / 机器学习 / 大模型」是**跨门类能力词**,两边都不在 —— 于是
|
|
1006
|
+
# 「找苏州做人工智能的企业」在这里一个字都切不出来,`suggest_filters` 只能
|
|
1007
|
+
# 返回空候选把 agent 赶去翻货架。
|
|
1008
|
+
# 只收 **纯汉字且长度 >= 2** 的能力词:ASCII 侧(ai / cnc / c / 3d…)数量大,
|
|
1009
|
+
# 且「c」「3c」这类单双字符做子串切分会把任意含 c 的串切碎。ASCII 能力词由
|
|
1010
|
+
# `_cap_terms_in` 用词边界单独扫,不进这张切分表。
|
|
1011
|
+
for w in _cap_term_index():
|
|
1012
|
+
if len(w) >= 2 and _is_cjk(w):
|
|
1013
|
+
pats.setdefault(w, "cap")
|
|
1004
1014
|
_SEG_PATTERNS = sorted(pats.items(), key=lambda x: -len(x[0]))
|
|
1005
1015
|
return _SEG_PATTERNS
|
|
1006
1016
|
|
|
@@ -1028,6 +1038,120 @@ def _segment_terms(text: str) -> List[Tuple[str, str]]:
|
|
|
1028
1038
|
return hits
|
|
1029
1039
|
|
|
1030
1040
|
|
|
1041
|
+
# --------------------------------------------------------------------------- #
|
|
1042
|
+
# 原话归一化:把「一句人话」切成可执行的检索参数
|
|
1043
|
+
#
|
|
1044
|
+
# 为什么必须有这一层(2026-10-06 实测):`search_vendors` 的关键词切分原本只有
|
|
1045
|
+
# `q.split()` —— 按空白切。中文用户输入的自然语言没有空格,整句话就是一个 token:
|
|
1046
|
+
# 「苏州做AI的企业」 → tokens = ["苏州做ai的企业"]
|
|
1047
|
+
# 而关键词走的是「每个 token 都必须出现在记录检索面里」(AND)语义,**没有任何一条
|
|
1048
|
+
# 记录**会包含「苏州做ai的企业」这个串,于是必然 0 条。同时城市没被提取、能力词
|
|
1049
|
+
# 'AI' 也认不出。三层叠起来的结果:用户**原话**问什么都是 0 条,必须由 agent 先手工
|
|
1050
|
+
# 拆成 query + city 才行 —— 这是把「理解自然语言」的责任推给了调用方,MCP 该自己扛住。
|
|
1051
|
+
# --------------------------------------------------------------------------- #
|
|
1052
|
+
_STOPWORDS: Tuple[str, ...] = (
|
|
1053
|
+
# 通名/机构词:出现即无信息量(「企业」「公司」本身不是产品词)
|
|
1054
|
+
"生产厂家", "制造商", "加工厂", "制造厂", "供应商", "厂商",
|
|
1055
|
+
"企业", "公司", "工厂", "厂家",
|
|
1056
|
+
# 诉求词
|
|
1057
|
+
"有哪些", "哪几家", "哪家", "几家", "一家",
|
|
1058
|
+
"推荐", "介绍", "查找", "找一下", "查一下", "看一下", "看看",
|
|
1059
|
+
"找找", "查询", "搜索", "检索",
|
|
1060
|
+
# 指代/范围
|
|
1061
|
+
"有没有", "什么样的", "怎么样", "什么", "附近", "周边",
|
|
1062
|
+
"当地", "本地", "这边", "那边", "相关的", "有关的", "有关",
|
|
1063
|
+
# 单字诉求(整词替换,不会伤到词内部)
|
|
1064
|
+
"帮我", "给我", "我要", "我想", "一下",
|
|
1065
|
+
)
|
|
1066
|
+
_STOPWORDS_SET = frozenset(_STOPWORDS)
|
|
1067
|
+
# 只削**两端**的功能字。刻意不含 厂/家/店/司(「酒厂」「酒店」「家电」是合法词),
|
|
1068
|
+
# 也不含 有/用/能/会/可(「有色」「用友」会被削坏)。
|
|
1069
|
+
_EDGE_FILLER = set("的了找做想要请给我你在和与或及是这那们吧呢吗把被让")
|
|
1070
|
+
_RE_RUNSPLIT = re.compile(r"[^0-9a-z\u4e00-\u9fff]+")
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _trim_edges(t: str) -> str:
|
|
1074
|
+
"""削掉 token 两端的功能字,只削两端、不动中间(「做ai的」→「ai」)。"""
|
|
1075
|
+
i, j = 0, len(t)
|
|
1076
|
+
while i < j and t[i] in _EDGE_FILLER:
|
|
1077
|
+
i += 1
|
|
1078
|
+
while j > i and t[j - 1] in _EDGE_FILLER:
|
|
1079
|
+
j -= 1
|
|
1080
|
+
return t[i:j]
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _normalize_query(query: str, city: str = "") -> Dict[str, Any]:
|
|
1084
|
+
"""把用户原话归一成 (city, keywords, cap_hits, alias_hits, segments)。
|
|
1085
|
+
|
|
1086
|
+
一句话(「找一下苏州做AI的企业」)里混着四类东西:
|
|
1087
|
+
① 城市 → 抽出来当 city 过滤(调用方没显式给 city 才抽)
|
|
1088
|
+
② 已识别词 → 国标别名 / 国标类名 / 能力词(`_segment_terms` 切出)
|
|
1089
|
+
③ 停用词 → 「的」「找」「企业」这类无信息量的填充
|
|
1090
|
+
④ 真关键词 → 剔掉 ①②③ 后剩下的
|
|
1091
|
+
② 保留进 keywords(不丢弃):「厂名里真写了这个词」仍能被字面召回,与既有行为
|
|
1092
|
+
一致;③ 必须剔掉,否则「的企业」会参与 AND 匹配,必然 0 条。
|
|
1093
|
+
"""
|
|
1094
|
+
raw = (query or "").strip()
|
|
1095
|
+
low = raw.lower()
|
|
1096
|
+
out: Dict[str, Any] = {"raw": raw, "city": city or "", "city_from_query": "",
|
|
1097
|
+
"tokens": [], "cap_hits": [], "alias_hits": [],
|
|
1098
|
+
"segments": []}
|
|
1099
|
+
if not low:
|
|
1100
|
+
return out
|
|
1101
|
+
|
|
1102
|
+
# 停用词不参与任何「识别」——否则 '企业' 若恰好也是能力词/别名,会被当成
|
|
1103
|
+
# 一个正式检索词塞进 tokens,反而把查询扩大成「整城扫」。
|
|
1104
|
+
cap_hits = [h for h in cap_alias_codes(low)
|
|
1105
|
+
if str(h.get("word") or "").lower() not in _STOPWORDS_SET]
|
|
1106
|
+
alias_hits = [h for h in alias_codes(raw)
|
|
1107
|
+
if str(h.get("word") or "").lower() not in _STOPWORDS_SET]
|
|
1108
|
+
segs = [(w, s) for (w, s) in _segment_terms(low)
|
|
1109
|
+
if w.lower() not in _STOPWORDS_SET]
|
|
1110
|
+
|
|
1111
|
+
# ① 城市就地抽取(只在调用方没显式给 city 时)。取**最长**命中:「苏州」与「昆山」
|
|
1112
|
+
# 同时出现时长者信息量更大。省/自治区不在 city.json(那里只放市/县级),故
|
|
1113
|
+
# 「贵州的酒厂」这类由 agent 走 list_industries 语义挑码。
|
|
1114
|
+
city_found = ""
|
|
1115
|
+
if not out["city"]:
|
|
1116
|
+
try:
|
|
1117
|
+
names = [n for n in _city_names() if n and n.lower() in low]
|
|
1118
|
+
except Exception:
|
|
1119
|
+
names = []
|
|
1120
|
+
if names:
|
|
1121
|
+
city_found = max(names, key=len)
|
|
1122
|
+
out["city"] = city_found
|
|
1123
|
+
out["city_from_query"] = city_found
|
|
1124
|
+
|
|
1125
|
+
# ② 把「已识别的」从原话里剔掉,剩下的才是通用关键词
|
|
1126
|
+
resid = low
|
|
1127
|
+
if city_found:
|
|
1128
|
+
resid = resid.replace(city_found.lower(), " ")
|
|
1129
|
+
for h in cap_hits + alias_hits:
|
|
1130
|
+
w = str(h.get("word") or "").lower()
|
|
1131
|
+
if w:
|
|
1132
|
+
resid = resid.replace(w, " ")
|
|
1133
|
+
for w, _src in segs:
|
|
1134
|
+
resid = resid.replace(w.lower(), " ")
|
|
1135
|
+
for w in sorted(_STOPWORDS, key=len, reverse=True):
|
|
1136
|
+
resid = resid.replace(w, " ")
|
|
1137
|
+
toks: List[str] = []
|
|
1138
|
+
# 纯结构字(厂/店/家/司…)不成词、没有产品信息,不能当关键词:它们会进入
|
|
1139
|
+
# **AND 过滤**,把厂名里恰好不写「厂」的「XX有限公司」整批误滤掉。
|
|
1140
|
+
# 2026-10-06 实测:「昆山 做CNC的厂」里 cnc 被消费后只剩一个「厂」,
|
|
1141
|
+
# 结果文本召回退化成「整城扫所有含厂字的记录」。
|
|
1142
|
+
_noinfo = _FUNC_CHARS | _ORG_SUFFIX
|
|
1143
|
+
for t in _RE_RUNSPLIT.sub(" ", resid).split():
|
|
1144
|
+
t = _trim_edges(t)
|
|
1145
|
+
if t and not all(ch in _noinfo for ch in t):
|
|
1146
|
+
toks.append(t)
|
|
1147
|
+
|
|
1148
|
+
out["tokens"] = list(dict.fromkeys(toks + [w.lower() for w, _ in segs]))
|
|
1149
|
+
out["cap_hits"] = cap_hits
|
|
1150
|
+
out["alias_hits"] = alias_hits
|
|
1151
|
+
out["segments"] = [{"term": w, "source": s} for w, s in segs]
|
|
1152
|
+
return out
|
|
1153
|
+
|
|
1154
|
+
|
|
1031
1155
|
def list_industries(keyword: str = "", parent: str = "", limit: int = 30,
|
|
1032
1156
|
offset: int = 0) -> Dict[str, Any]:
|
|
1033
1157
|
"""国标货架:给 LLM 一张「有哪些行业、各有多少家」的菜单。
|
|
@@ -1072,6 +1196,10 @@ def suggest_filters(query: str, city: str = "", limit: int = 8) -> Dict[str, Any
|
|
|
1072
1196
|
q = (query or "").strip()
|
|
1073
1197
|
if not q:
|
|
1074
1198
|
return {"error": "缺少必填参数 query"}
|
|
1199
|
+
# 原话归一化:抽城市(调用方没给才抽)+ 剔停用词。用户给的是一句话时,
|
|
1200
|
+
# 「苏州做AI的企业」里的「苏州」应当变成 city 条件,而不是当成关键词去 AND。
|
|
1201
|
+
norm = _normalize_query(q, city)
|
|
1202
|
+
city = norm["city"] or city
|
|
1075
1203
|
limit = max(1, min(int(limit), 30))
|
|
1076
1204
|
by_code = {r["code"]: r for r in _gb_shelf()}
|
|
1077
1205
|
cands: Dict[str, Dict[str, Any]] = {}
|
|
@@ -1131,7 +1259,7 @@ def suggest_filters(query: str, city: str = "", limit: int = 8) -> Dict[str, Any
|
|
|
1131
1259
|
# 国标侧没类、能力侧不问 —— 两边都空,agent 只能拿到 candidates:[] 瞎猜。
|
|
1132
1260
|
cap_cands: List[Dict[str, Any]] = []
|
|
1133
1261
|
try:
|
|
1134
|
-
for c in cap_alias_codes(q):
|
|
1262
|
+
for c in (norm.get("cap_hits") or cap_alias_codes(q)):
|
|
1135
1263
|
cap_cands.append({"cap": c["cap"], "name": c["name"], "word": c["word"],
|
|
1136
1264
|
"records": _cap_count(c["cap"])})
|
|
1137
1265
|
except Exception:
|
|
@@ -1140,6 +1268,12 @@ def suggest_filters(query: str, city: str = "", limit: int = 8) -> Dict[str, Any
|
|
|
1140
1268
|
res: Dict[str, Any] = {
|
|
1141
1269
|
"query": q,
|
|
1142
1270
|
"city": city or "",
|
|
1271
|
+
# 把「MCP 是怎么理解这句话的」摊开给 agent 看:抽走了哪个城市、剔了哪些停用词、
|
|
1272
|
+
# 认出了哪些词条。不透明的话,agent 无法判断候选为空到底是「没这行业」还是
|
|
1273
|
+
# 「这句话没被读懂」,只能瞎猜着换词重试。
|
|
1274
|
+
"city_from_query": norm.get("city_from_query") or "",
|
|
1275
|
+
"keywords": norm.get("tokens") or [],
|
|
1276
|
+
"recognized": norm.get("segments") or [],
|
|
1143
1277
|
"candidates": out,
|
|
1144
1278
|
"how_to_use": "挑 1~3 个 code,逐个调 search_vendors(gb=<code>, city=<城市>)"
|
|
1145
1279
|
"(给了 gb 只扫对应分片,最快);多个码的结果自行合并去重。"
|
|
@@ -1161,8 +1295,11 @@ def suggest_filters(query: str, city: str = "", limit: int = 8) -> Dict[str, Any
|
|
|
1161
1295
|
res["note"] = ("国标小类里没有这个词(GB/T 4754 本来就不保证有「%s」这一类),"
|
|
1162
1296
|
"但它命中能力域 %s。直接调 search_vendors(query=\"%s\") 即可,"
|
|
1163
1297
|
"能力域会被自动展开;结果偏少属正常 —— 那是数据厚度问题,"
|
|
1164
|
-
"
|
|
1165
|
-
% (q, "、".join(c["name"] for c in cap_cands), q
|
|
1298
|
+
"不是查询写法问题。%s"
|
|
1299
|
+
% (q, "、".join(c["name"] for c in cap_cands), q,
|
|
1300
|
+
("另外能力域企业在空间上很稀疏(全库常只有几十家),"
|
|
1301
|
+
"按 city=%s 过滤后若为 0 条,去掉 city 再试。" % city)
|
|
1302
|
+
if city else ""))
|
|
1166
1303
|
else:
|
|
1167
1304
|
res["next_call"] = {"tool": "list_industries",
|
|
1168
1305
|
"arguments": {"keyword": q, "limit": 30}}
|
|
@@ -1184,9 +1321,37 @@ def _attach_routing_hint(out: Dict[str, Any], q: str, city: str) -> Dict[str, An
|
|
|
1184
1321
|
try:
|
|
1185
1322
|
s = suggest_filters(q, city, limit=5)
|
|
1186
1323
|
except Exception:
|
|
1187
|
-
|
|
1324
|
+
s = {}
|
|
1188
1325
|
cands = s.get("candidates") or []
|
|
1326
|
+
try:
|
|
1327
|
+
caps = cap_alias_codes(q)
|
|
1328
|
+
except Exception:
|
|
1329
|
+
caps = []
|
|
1330
|
+
|
|
1331
|
+
def _emit_cap() -> None:
|
|
1332
|
+
out["cap_expanded"] = [{"cap": c["cap"], "name": c["name"], "word": c["word"],
|
|
1333
|
+
"records": _cap_count(c["cap"])} for c in caps]
|
|
1334
|
+
txt = ("这个词命中能力域 %s(跨门类的工艺/技术标签,不是国标小类),"
|
|
1335
|
+
"search_vendors(query=…) 已自动展开并计入结果。命中偏少说明该能力"
|
|
1336
|
+
"在名录里确实很薄,**不要理解成「没这家厂」**。放宽可调 "
|
|
1337
|
+
"list_industries(keyword=…) 换近义类目再传 gb=。"
|
|
1338
|
+
% "、".join("%s %d 家" % (c["name"], _cap_count(c["cap"]))
|
|
1339
|
+
for c in caps))
|
|
1340
|
+
# 0 条且带了城市时,最可能的元凶是城市而不是关键词 —— 能力域企业在空间上
|
|
1341
|
+
# 很稀疏(全库可能就十几家),叠一个城市筛掉全部是常态。这一句能省掉 agent
|
|
1342
|
+
# 一整轮「换个城市试」的瞎猜。
|
|
1343
|
+
if city and not out.get("total_matched", 0):
|
|
1344
|
+
txt += ("本次已按 city=%s 过滤;去掉 city 再试一次往往就有。" % city)
|
|
1345
|
+
out["hint"] = txt
|
|
1346
|
+
|
|
1189
1347
|
if out.get("total_matched", 0) == 0 and not cands:
|
|
1348
|
+
# 0 条 + 国标侧无候选 —— 但**能力域可能命中**,这是唯一还可执行的线索。
|
|
1349
|
+
# 旧实现这里直接 return 一句「关键词对不上」:2026-10-06 实测查
|
|
1350
|
+
# 「苏州做AI的企业」时 caps=tech_ai 明明存在,hint 却在更早的分支出局,
|
|
1351
|
+
# agent 于是完全看不到「换个说法/加城市就能召回」这条信息。
|
|
1352
|
+
if caps:
|
|
1353
|
+
_emit_cap()
|
|
1354
|
+
return out
|
|
1190
1355
|
out["hint"] = ("关键词与名录里的写法对不上,且没有可推导的国标码。"
|
|
1191
1356
|
"建议调 list_industries 用语义挑码后改传 gb=。")
|
|
1192
1357
|
return out
|
|
@@ -1202,18 +1367,8 @@ def _attach_routing_hint(out: Dict[str, Any], q: str, city: str) -> Dict[str, An
|
|
|
1202
1367
|
# 2026-10-06 实测:查「苏州做AI的企业」只回 2 条(其中一条是名字里带「人工智能」的
|
|
1203
1368
|
# 餐厅),hint 全程不出现 —— 因为旧逻辑只有「0 条且无候选」和「有候选」两个分支,
|
|
1204
1369
|
# 1~4 条无候选直接掉进空档 return。必须补上。
|
|
1205
|
-
try:
|
|
1206
|
-
caps = cap_alias_codes(q)
|
|
1207
|
-
except Exception:
|
|
1208
|
-
caps = []
|
|
1209
1370
|
if caps:
|
|
1210
|
-
|
|
1211
|
-
"records": _cap_count(c["cap"])} for c in caps]
|
|
1212
|
-
out["hint"] = ("这个词命中能力域 %s(跨门类的工艺/技术标签,不是国标小类),"
|
|
1213
|
-
"search_vendors(query=…) 已自动展开并计入结果。命中偏少说明该能力"
|
|
1214
|
-
"在名录里确实很薄,**不要理解成「没这家厂」**。放宽可调 "
|
|
1215
|
-
"list_industries(keyword=…) 换近义类目再传 gb=。"
|
|
1216
|
-
% "、".join("%s %d 家" % (c["name"], _cap_count(c["cap"])) for c in caps))
|
|
1371
|
+
_emit_cap()
|
|
1217
1372
|
return out
|
|
1218
1373
|
out["hint"] = ("命中偏少且推不出国标码:这几条多半只是**字面撞词**,未必真是你要的行业。"
|
|
1219
1374
|
"建议调 list_industries(keyword=\"…\") 用语义挑 1~3 个国标小类,"
|
|
@@ -1258,17 +1413,72 @@ def _cap_term_index() -> Dict[str, str]:
|
|
|
1258
1413
|
return idx
|
|
1259
1414
|
|
|
1260
1415
|
|
|
1416
|
+
_CAP_SUBSTR_WORDS: Optional[List[Tuple[str, str, Any]]] = None
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def _cap_substr_words() -> List[Tuple[str, str, Any]]:
|
|
1420
|
+
"""能力词表里**可做子串扫描**的词 [(词, 键, 预编译边界正则|None)],长词在前。
|
|
1421
|
+
|
|
1422
|
+
过滤规则都是为了不误命中:
|
|
1423
|
+
· 长度 < 2 一律不要 —— 表里有 'c'、'/' 这种单字符键,子串扫会把任何含 c 的
|
|
1424
|
+
查询都映射成 tst_cpp(一个字符毁掉一条链路);
|
|
1425
|
+
· 只保留「纯字母/数字/汉字」构成的词,带符号的(`.net` / `c++` / `250m汽车配件`
|
|
1426
|
+
里的斜杠)留给整词精确那一档;
|
|
1427
|
+
· ASCII 词预编译 `(?<![a-z0-9])词(?![a-z0-9])` 词边界 —— 否则 'ai' 会命中
|
|
1428
|
+
'chair'、'cnc' 会命中 'cncxx'。汉字不分词、天然成词,无需边界。
|
|
1429
|
+
"""
|
|
1430
|
+
global _CAP_SUBSTR_WORDS
|
|
1431
|
+
if _CAP_SUBSTR_WORDS is None:
|
|
1432
|
+
rows: List[Tuple[str, str, Any]] = []
|
|
1433
|
+
for w, code in _cap_term_index().items():
|
|
1434
|
+
if len(w) < 2:
|
|
1435
|
+
continue
|
|
1436
|
+
if not re.fullmatch(r"[a-z0-9\u4e00-\u9fff]+", w):
|
|
1437
|
+
continue
|
|
1438
|
+
rx = (re.compile(r"(?<![a-z0-9])" + re.escape(w) + r"(?![a-z0-9])")
|
|
1439
|
+
if w.isascii() else None)
|
|
1440
|
+
rows.append((w, code, rx))
|
|
1441
|
+
rows.sort(key=lambda x: -len(x[0]))
|
|
1442
|
+
_CAP_SUBSTR_WORDS = rows
|
|
1443
|
+
return _CAP_SUBSTR_WORDS
|
|
1444
|
+
|
|
1445
|
+
|
|
1261
1446
|
def cap_alias_codes(q: str) -> List[Dict[str, Any]]:
|
|
1262
|
-
"""能力口语词 →
|
|
1447
|
+
"""能力口语词 → 能力键。返回 [{cap, word, name, match}]。
|
|
1448
|
+
|
|
1449
|
+
两档匹配,整词精确优先:
|
|
1450
|
+
① exact 查询按空白切开后某个 token 完全等于能力词(原行为)
|
|
1451
|
+
② substr 能力词出现在查询里的**任意位置**(ASCII 词另要求词边界)
|
|
1452
|
+
|
|
1453
|
+
2026-10-06 补 ②。在此之前只做 ①,而真实用户说的是**一整句人话**:
|
|
1454
|
+
「苏州做AI的企业」按空白切只有一个 token,'AI' 永远匹配不上 —— 能力域召回
|
|
1455
|
+
整条链路对自然人话失效,只能回 0 条并把 agent 赶去翻货架。
|
|
1456
|
+
国标别名侧(`alias_codes`)一直是子串匹配的,两侧行为不一致本身就是 bug。
|
|
1457
|
+
"""
|
|
1263
1458
|
q = (q or "").strip().lower()
|
|
1264
1459
|
if not q:
|
|
1265
1460
|
return []
|
|
1266
1461
|
idx = _cap_term_index()
|
|
1267
1462
|
hits: Dict[str, Dict[str, Any]] = {}
|
|
1463
|
+
# ① 整词
|
|
1268
1464
|
for tok in dict.fromkeys(t for t in q.split() if t):
|
|
1269
1465
|
code = idx.get(tok)
|
|
1270
1466
|
if code:
|
|
1271
|
-
hits[code] = {"cap": code, "word": tok, "name": _cap_name(code)
|
|
1467
|
+
hits[code] = {"cap": code, "word": tok, "name": _cap_name(code),
|
|
1468
|
+
"match": "exact"}
|
|
1469
|
+
# ② 子串(长词优先,一份查询最多认 6 个能力域,免得长句把货架扫爆)
|
|
1470
|
+
for w, code, rx in _cap_substr_words():
|
|
1471
|
+
if code in hits:
|
|
1472
|
+
continue
|
|
1473
|
+
if rx is not None:
|
|
1474
|
+
if not rx.search(q):
|
|
1475
|
+
continue
|
|
1476
|
+
elif w not in q:
|
|
1477
|
+
continue
|
|
1478
|
+
hits[code] = {"cap": code, "word": w, "name": _cap_name(code),
|
|
1479
|
+
"match": "substr"}
|
|
1480
|
+
if len(hits) >= 6:
|
|
1481
|
+
break
|
|
1272
1482
|
return list(hits.values())
|
|
1273
1483
|
|
|
1274
1484
|
|
|
@@ -2306,29 +2516,46 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
2306
2516
|
limit = max(1, min(int(limit), 200))
|
|
2307
2517
|
offset = max(0, int(offset))
|
|
2308
2518
|
q = (query or "").strip().lower()
|
|
2309
|
-
#
|
|
2310
|
-
#
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2519
|
+
# 原话归一化(2026-10-06):用户输入的是**一句人话**,不是空格分隔的关键词表。
|
|
2520
|
+
# 旧实现直接 `q.split()`,中文整句会变成一个 token,而关键词是 AND 语义 —— 没有
|
|
2521
|
+
# 任何记录会包含「苏州做ai的企业」这个串,必然 0 条。归一化顺带把原话里的城市
|
|
2522
|
+
# 抽成 city 过滤条件。详见 `_normalize_query`。
|
|
2523
|
+
norm = _normalize_query(query, city) if q else None
|
|
2524
|
+
if norm:
|
|
2525
|
+
city = norm["city"] or city
|
|
2526
|
+
tokens = norm["tokens"]
|
|
2527
|
+
cap_hits = norm["cap_hits"]
|
|
2528
|
+
alias_hits = norm["alias_hits"]
|
|
2529
|
+
else:
|
|
2530
|
+
tokens, cap_hits, alias_hits = [], [], []
|
|
2531
|
+
# 能力(cap)别名:映射到能力键(如 tech_ai)后**消费**掉该 token(不再做通用
|
|
2532
|
+
# 子串 AND 校验),只走 cap 定向召回 —— 否则「AI」会子串命中 algebraist /
|
|
2533
|
+
# Ashore 等英文名咖啡店造成噪声。
|
|
2534
|
+
# 只消费「会造成子串噪声的短 ASCII token」(ai / cnc …),中文/较长 token 不消费
|
|
2535
|
+
# —— 保留其文本匹配(厂名含「人工智能」的企业不被误丢),最终是「文本 ∪ 能力」并集。
|
|
2320
2536
|
cap_tokens = {h["word"].lower() for h in cap_hits
|
|
2321
2537
|
if len(h["word"]) <= 2 and h["word"].isascii()}
|
|
2322
2538
|
gen_tokens = [t for t in tokens if t not in cap_tokens]
|
|
2323
|
-
|
|
2324
2539
|
# 口语词 → 国标码(2026-09-24 接入别名表)。
|
|
2325
2540
|
# 「输送线/流水线/PCB」这类词在任何记录的厂名/工艺/材料里都不出现,纯子串匹配
|
|
2326
2541
|
# 必然 0 条。别名命中的记录按码定向召回,**豁免 AND token 校验** —— 不豁免的话
|
|
2327
2542
|
# 刚拉进来就又被 _hay 判定「不含输送线」而滤掉,等于白接。
|
|
2328
|
-
alias_hits = alias_codes(q) if q else []
|
|
2329
2543
|
alias_by_code = {a["code"]: a for a in alias_hits}
|
|
2330
2544
|
alias_paths = _fp_paths_for_gbs(list(alias_by_code)) if alias_by_code else []
|
|
2331
2545
|
|
|
2546
|
+
# 只有「原话确实被拆过」时才回显解析结果:一句话查询(抽了城市 / 剔了停用词 /
|
|
2547
|
+
# 识别出词条)必须让 agent 看见 MCP 是怎么理解的,否则它无法判断结果为何是这样。
|
|
2548
|
+
# 普通关键词查询不额外增加返回体字节。
|
|
2549
|
+
parsed: Dict[str, Any] = {}
|
|
2550
|
+
if norm and (norm["city_from_query"] or norm["segments"]
|
|
2551
|
+
or tokens != [t for t in q.split() if t]):
|
|
2552
|
+
parsed["query_parsed"] = {
|
|
2553
|
+
"city": city or "",
|
|
2554
|
+
"city_from_query": norm["city_from_query"] or "",
|
|
2555
|
+
"keywords": gen_tokens,
|
|
2556
|
+
"recognized": norm["segments"],
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2332
2559
|
# 给了国标码:只扫对应分片(最快路径,不构建全量索引)
|
|
2333
2560
|
if gb:
|
|
2334
2561
|
fp_shards = [s for s in _shards_of_type("fp") if s.get("c") == gb]
|
|
@@ -2349,6 +2576,7 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
2349
2576
|
"returned": len(matches[offset:offset + limit]),
|
|
2350
2577
|
"shards_scanned": scanned,
|
|
2351
2578
|
"results": matches[offset:offset + limit],
|
|
2579
|
+
**parsed,
|
|
2352
2580
|
}, q, city)
|
|
2353
2581
|
|
|
2354
2582
|
# 未给国标码:优先走「预构建倒排索引 → 只拉命中分片」(O(命中量))。
|
|
@@ -2364,6 +2592,7 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
2364
2592
|
"shards_scanned": 0,
|
|
2365
2593
|
"via_index": True,
|
|
2366
2594
|
"results": [],
|
|
2595
|
+
**parsed,
|
|
2367
2596
|
}, q, city)
|
|
2368
2597
|
|
|
2369
2598
|
matches: List[Dict[str, Any]] = []
|
|
@@ -2468,6 +2697,7 @@ def search_vendors(query: str = "", city: str = "", gb: str = "",
|
|
|
2468
2697
|
"shards_scanned": scanned,
|
|
2469
2698
|
"via_index": via_index,
|
|
2470
2699
|
"results": matches[offset:offset + limit],
|
|
2700
|
+
**parsed,
|
|
2471
2701
|
# 结果顺序是**有意设计的分档**,不是随机的:按召回纯度递减排列。
|
|
2472
2702
|
# 而 `score` 是**能力画像分**(有已发布能力卡才有分,无卡为 0),**不是相关度**。
|
|
2473
2703
|
# 两者一起看极易误判(实测:一家餐厅 sc=0 排在持卡 L2 企业 sc=40 前面),
|