weflow-cli 1.11.1 → 1.12.0

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.
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env python3
2
+ """语义索引的**召回/排序评价**(可复跑、可对比的评测台)。
3
+
4
+ 为什么要有它:2026-10-15 之前,检索质量的结论都来自"手工试问 + 看前几条"——那种判据查不出
5
+ "改完是变好还是变坏",也复现不了。指标一律用业界标准口径,不自创:
6
+
7
+ · Recall@1/5/10 —— 该回来的回来没有(RAG 真正在乎的是 @5/@10)
8
+ · MRR@10 —— 第一条正确结果的倒数名次(单答案查询的主指标,MS MARCO 口径)
9
+ · nDCG@10 —— 排序质量(BEIR / MTEB 的主指标;这里是二值相关性、单条标准答案,
10
+ 所以 nDCG@10 = 1/log2(1+rank),命中在第 10 名之外记 0)
11
+ · Hit@1 —— 与 Recall@1 同义,单列是因为中文规范(《人工智能 检索增强生成系统测评
12
+ 规范》2024003631)里用的是"命中率"这个词
13
+ · 覆盖(coverage)—— **这一条是我们这类语料特有的**:答案在库里根本不存在时,任何排序都救不了,
14
+ 把它算进召回率是在诬赖排序。所以每题先判"库里有没有"。
15
+
16
+ 两套标准答案(都不依赖人工标注、也不把用户数据写进仓库):
17
+ · `frag` —— 从记录**自己正文中段**取 12 字干净片段当问句(唯一性已校验)。测"局部能不能召回整条"。
18
+ · `title` —— 文章用它的标题、聊天用它开头 30 字。这是用户最常干的事(记得那句话)。
19
+ · `question`(可选)—— 你手写的真问题 + 一个"答案里必然出现的字面量"当锚,走 `--queries-file`
20
+ (TSV:问题<TAB>锚<TAB>线)。**该文件请放在仓库外**:锚串取自你的语料。
21
+
22
+ `--dedup` 会先把**同文本**的记录合并再排名(多号转载同一篇时,top-k 里塞满同一份内容会虚高
23
+ 召回、掏空信息量)。它与不合并的两种名次都报,差多少一眼能看出来。
24
+
25
+ 用法(调 embedding 服务,需 --yes):
26
+ python scripts/recall_eval.py --records 30 --yes
27
+ python scripts/recall_eval.py --records 30 --dedup --json --yes
28
+ python scripts/recall_eval.py --queries-file D:/tmp/q.tsv --records 0 --yes
29
+ """
30
+
31
+ import argparse
32
+ import importlib.util
33
+ import json
34
+ import math
35
+ import os
36
+ import random
37
+ import re
38
+ import sys
39
+ from pathlib import Path
40
+
41
+ HERE = Path(__file__).resolve().parent
42
+ sys.path.insert(0, str(HERE))
43
+
44
+ spec = importlib.util.spec_from_file_location('semantic_search', HERE / 'semantic_search.py')
45
+ ss = importlib.util.module_from_spec(spec)
46
+ spec.loader.exec_module(ss)
47
+
48
+ CJK = lambda ch: '\u4e00' <= ch <= '\u9fff'
49
+ BANNED = set(' \t\n\r:/\\"\'|<>()()【】[],;○●·、。!?,')
50
+ JUNK = set('=_&%/.?#@~^`$*+{}[]')
51
+
52
+
53
+ def clean_window(text, mid, length=12):
54
+ """取一段干净的中文片段(≥60% 汉字、不含标点/URL 残片/长数字)——否则会拿 URL 碎片当问句。"""
55
+ for start in range(max(0, mid), max(0, min(len(text) - length, mid + 60))):
56
+ w = text[start:start + length]
57
+ if len(w) < length or set(w) & BANNED or set(w) & JUNK:
58
+ continue
59
+ if sum(1 for c in w if CJK(c)) / len(w) < 0.6 or re.search(r'\d{5,}', w):
60
+ continue
61
+ return w
62
+ return None
63
+
64
+
65
+ def rank_of(order, target_group):
66
+ """目标(或其同文本组)在结果里的名次;不在表里返回 None。"""
67
+ for i, idx in enumerate(order, 1):
68
+ if idx in target_group:
69
+ return i
70
+ return None
71
+
72
+
73
+ def metrics(rank, k=10):
74
+ hit = rank is not None and rank <= k
75
+ return {
76
+ 'hit@1': 1.0 if rank == 1 else 0.0,
77
+ 'recall@1': 1.0 if rank == 1 else 0.0,
78
+ 'recall@5': 1.0 if rank is not None and rank <= 5 else 0.0,
79
+ 'recall@10': 1.0 if hit else 0.0,
80
+ 'mrr@10': (1.0 / rank) if hit else 0.0,
81
+ 'ndcg@10': (1.0 / math.log2(1 + rank)) if hit else 0.0,
82
+ }
83
+
84
+
85
+ def build_cases(meta, texts, per_line, seed):
86
+ """自动造题:每线抽 per_line 条记录,各出 frag / title 两种问句。"""
87
+ groups = {}
88
+ for i, t in enumerate(texts):
89
+ groups.setdefault(hash(t), []).append(i)
90
+
91
+ rng = random.Random(seed)
92
+ cases = []
93
+ for kind, line, minlen in (('article', 'wiki', 220), ('chat', 'chat', 70)):
94
+ pool = [i for i, m in enumerate(meta) if m.get('type') == kind and len(texts[i]) >= minlen]
95
+ rng.shuffle(pool)
96
+ picked = 0
97
+ for i in pool:
98
+ if picked >= per_line:
99
+ break
100
+ t = texts[i]
101
+ body = t[t.find('\n') + 1:] if kind == 'article' and t.find('\n') >= 0 else t
102
+ frag = clean_window(body, len(body) // 3) or clean_window(body, len(body) // 2)
103
+ title = (meta[i].get('title') or '').strip() or clean_window(t, 0, 30)
104
+ if not frag or not title:
105
+ continue
106
+ group = set(groups[hash(t)])
107
+ cases.append({'form': 'frag', 'line': line, 'idx': i, 'group': group, 'query': frag})
108
+ cases.append({'form': 'title', 'line': line, 'idx': i, 'group': group, 'query': title})
109
+ picked += 1
110
+ return cases, groups
111
+
112
+
113
+ def load_questions(path, texts):
114
+ """TSV:问题<TAB>锚<TAB>线 → 标准答案 = 含锚的记录集合(覆盖为 0 的题单独列出,不计入召回)。"""
115
+ cases = []
116
+ for raw in Path(path).read_text(encoding='utf-8').splitlines():
117
+ raw = raw.strip()
118
+ if not raw or raw.startswith('#') or '\t' not in raw:
119
+ continue
120
+ parts = raw.split('\t')
121
+ q, anchor = parts[0].strip(), parts[1].strip()
122
+ line = parts[2].strip() if len(parts) > 2 else 'all'
123
+ truth = {i for i, t in enumerate(texts) if anchor.lower() in t.lower()}
124
+ cases.append({'form': 'question', 'line': line, 'idx': -1, 'group': truth or set(),
125
+ 'query': q, 'anchor': anchor, 'covered': bool(truth)})
126
+ return cases
127
+
128
+
129
+ def main():
130
+ ap = argparse.ArgumentParser(description='语义索引召回/排序评测台')
131
+ ap.add_argument('--records', type=int, default=30, help='每条线抽多少条记录造题(默认 30)')
132
+ ap.add_argument('--queries-file', help='手写问句 TSV(问题<TAB>锚<TAB>线);请放仓库外')
133
+ ap.add_argument('--top-k', type=int, default=10)
134
+ ap.add_argument('--seed', type=int, default=7)
135
+ ap.add_argument('--dedup', action='store_true', help='排名前把同文本记录合并')
136
+ ap.add_argument('--rerank', action='store_true',
137
+ help='调真正的重排(先试 Jev 契约,再退到已配置的对话模型)')
138
+ ap.add_argument('--mmr', action='store_true', help='排名后做 MMR 去冗余(λ=0.7)')
139
+ ap.add_argument('--rerank-pool', type=int, default=ss.RERANK_CANDIDATES,
140
+ help='重排看多少条候选(默认 %d)' % ss.RERANK_CANDIDATES)
141
+ ap.add_argument('--json', action='store_true')
142
+ ap.add_argument('--yes', action='store_true', help='确认调用云端 embedding(每条约十几 tokens)')
143
+ args = ap.parse_args()
144
+
145
+ if not args.yes:
146
+ raise SystemExit('这一步会调用 embedding 服务(花钱、且问句出境)。确认后加 --yes。')
147
+
148
+ meta = json.loads(ss.META_FILE.read_text(encoding='utf-8'))
149
+ vectors = None
150
+ import numpy as np
151
+ vectors = np.load(ss.VECTORS_FILE, mmap_mode='r')
152
+ texts = [m.get('text') or '' for m in meta]
153
+
154
+ cases, groups = build_cases(meta, texts, args.records, args.seed) if args.records else ([], {})
155
+ if args.queries_file:
156
+ cases += load_questions(args.queries_file, texts)
157
+ if not cases:
158
+ raise SystemExit('没有题(--records 0 且没给 --queries-file)')
159
+
160
+ key = ss.get_dashscope_key(ss.load_config())
161
+ embs = ss.get_embeddings([c['query'] for c in cases], key)
162
+ if any(all(v == 0 for v in e) for e in embs):
163
+ raise SystemExit('embedding 服务返回全零 —— 先查 key/网络,别拿这个结果下结论')
164
+
165
+ rows = []
166
+ for c, emb in zip(cases, embs):
167
+ v = np.array(emb, dtype=np.float32)
168
+ v = v / np.linalg.norm(v)
169
+ sims = np.asarray(vectors @ v)
170
+ types = ss.line_types(c['line'])
171
+ if types is not None:
172
+ sims = np.where(np.array([str(m.get('type', '')) in types for m in meta]), sims, -np.inf)
173
+ order = [int(i) for i in np.argsort(sims)[::-1]]
174
+ order = [i for i in order if np.isfinite(sims[i])]
175
+ if args.rerank:
176
+ # 与 `search()` 同一条路:先按原契约(Jev)排,失败再退到已配置的对话模型
177
+ pool = [{**meta[i], '_idx': i, 'score': float(sims[i])}
178
+ for i in order[:args.rerank_pool]]
179
+ ranked = ss.rerank_dispatch(c['query'], pool, ss.create_client(),
180
+ pool_size=args.rerank_pool)
181
+ order = [it['_idx'] for it in ranked] + list(order[args.rerank_pool:])
182
+ if args.mmr:
183
+ pool2 = [{**meta[i], '_row': i, 'rerankScore': r.get('rerankScore')}
184
+ for i, r in zip(order[:args.rerank_pool], ranked)]
185
+ order = [it['_row'] for it in ss.mmr_select(pool2, vectors, args.top_k)]
186
+ if args.dedup:
187
+ seen, keep = set(), []
188
+ for i in order:
189
+ h = hash(texts[i])
190
+ if h in seen:
191
+ continue
192
+ seen.add(h)
193
+ keep.append(i)
194
+ order = keep
195
+ rank = rank_of(order, c['group'])
196
+ # 多样性:最终 top-k 里两两余弦的均值 —— 越低说明结果越不重复。
197
+ # 加它是因为 MMR 这类去冗余与「单答案召回」是相冲的,只看召回会误判。
198
+ top = [i for i in order[:args.top_k] if i < len(texts)]
199
+ div = None
200
+ if len(top) >= 2:
201
+ mat = np.asarray(vectors[top], dtype=np.float32)
202
+ sims = mat @ mat.T
203
+ n = len(top)
204
+ div = float((sims.sum() - np.trace(sims)) / (n * (n - 1)))
205
+ rows.append({**c, 'group': None, 'rank': rank, 'div': div,
206
+ 'metrics': metrics(rank, args.top_k)})
207
+
208
+ def agg(sel):
209
+ covered = [r for r in sel if r['rank'] is not None or (r.get('covered') is not False)]
210
+ scored = [r for r in covered if r['rank'] is not None]
211
+ out = {'n': len(sel), 'n_covered': len(covered),
212
+ 'n_coverage_gap': sum(1 for r in sel if r.get('covered') is False)}
213
+ for k in ('hit@1', 'recall@1', 'recall@5', 'recall@10', 'mrr@10', 'ndcg@10'):
214
+ out[k] = (sum(r['metrics'][k] for r in covered) / len(covered)) if covered else 0.0
215
+ out['n_recalled'] = len(scored)
216
+ divs = [r['div'] for r in sel if r.get('div') is not None]
217
+ out['pairwiseDiversity'] = (sum(divs) / len(divs)) if divs else None
218
+ return out
219
+
220
+ report = {'config': {'records': args.records, 'seed': args.seed, 'dedup': args.dedup,
221
+ 'rerank': args.rerank, 'rerankPool': args.rerank_pool, 'mmr': args.mmr, 'rerankState': dict(ss.RERANK_STATE),
222
+ 'topK': args.top_k, 'indexCounts': ss.line_counts()},
223
+ 'overall': agg(rows), 'byGroup': {}, 'cases': [
224
+ {'form': r['form'], 'line': r['line'], 'query': r['query'], 'rank': r['rank']}
225
+ for r in rows]}
226
+ for form in sorted({r['form'] for r in rows}):
227
+ for line in sorted({r['line'] for r in rows if r['form'] == form}):
228
+ sel = [r for r in rows if r['form'] == form and r['line'] == line]
229
+ if sel:
230
+ report['byGroup']['%s/%s' % (form, line)] = agg(sel)
231
+
232
+ if args.json:
233
+ print(json.dumps(report, ensure_ascii=False, indent=2))
234
+ return
235
+ print('题数 %d(覆盖缺口 %d) dedup=%s' % (report['overall']['n'],
236
+ report['overall']['n_coverage_gap'], args.dedup))
237
+ print('%-16s %6s %8s %8s %8s %8s %8s %9s'
238
+ % ('组', 'n', 'hit@1', 'recall@5', 'recall@10', 'mrr@10', 'ndcg@10', '多样性'))
239
+ for name, m in [('overall', report['overall'])] + list(report['byGroup'].items()):
240
+ div = m.get('pairwiseDiversity')
241
+ print('%-16s %6d %8.2f %8.2f %8.2f %8.2f %8.2f %9s'
242
+ % (name, m['n'], m['hit@1'], m['recall@5'], m['recall@10'], m['mrr@10'],
243
+ m['ndcg@10'], ('%.3f' % div) if div is not None else '-'))
244
+
245
+
246
+ if __name__ == '__main__':
247
+ main()