weflow-cli 1.11.0 → 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.
- package/CHANGELOG.md +200 -0
- package/OPERATIONS.md +120 -1
- package/bin/weflow-cli.ts +53 -3
- package/dist/bin/weflow-cli.js +53 -3
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts.map +1 -1
- package/dist/src/services/assistantEval.js +3 -1
- package/dist/src/services/assistantEval.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +25 -7
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/exportService.d.ts +7 -0
- package/dist/src/services/exportService.d.ts.map +1 -1
- package/dist/src/services/exportService.js +1 -1
- package/dist/src/services/exportService.js.map +1 -1
- package/docs/DECISIONS.md +315 -0
- package/docs/MCP.md +1 -1
- package/docs/NEXT_ACTIONS.md +230 -0
- package/docs/PROJECT_STATE.md +91 -10
- package/docs/RELEASING.md +47 -0
- package/package.json +1 -1
- package/scripts/backfill_articles.py +64 -14
- package/scripts/export_chat_html.py +181 -42
- package/scripts/recall_eval.py +247 -0
- package/scripts/semantic_search.py +659 -46
- package/src/services/assistantEval.ts +3 -1
- package/src/services/assistantTools.ts +2471 -2452
- package/src/services/exportService.ts +14 -2
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
输出: JSON 格式(`search` 这一支是一个对象:`line` / `results` / `indexCounts` / 有时 `note`)
|
|
22
22
|
"""
|
|
23
23
|
|
|
24
|
-
import sys, os, json, hashlib, re, argparse
|
|
24
|
+
import sys, os, json, math, hashlib, re, argparse
|
|
25
25
|
from datetime import datetime, timezone, timedelta
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
|
|
@@ -62,7 +62,7 @@ except:
|
|
|
62
62
|
pass
|
|
63
63
|
|
|
64
64
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
65
|
-
from _utils import load_config, decrypt_lock, get_dashscope_key, LINE_LABELS # noqa: E402
|
|
65
|
+
from _utils import load_config, decrypt_lock, get_dashscope_key, get_api_key, LINE_LABELS # noqa: E402
|
|
66
66
|
from jev_client import create_client
|
|
67
67
|
|
|
68
68
|
OUTPUT_ROOT = 'output'
|
|
@@ -71,6 +71,9 @@ VECTORS_FILE = INDEX_DIR / 'vectors.npy'
|
|
|
71
71
|
META_FILE = INDEX_DIR / 'meta.json'
|
|
72
72
|
EMBEDDING_DIM = 1024 # 阿里云 text-embedding-v4
|
|
73
73
|
BATCH_SIZE = 10 # 阿里云 text-embedding-v4 单次最多 10 条
|
|
74
|
+
# 每多少批往盘上落一次。实测全量重建要跑二十分钟以上,而 CLI 给子进程的超时只有五分钟
|
|
75
|
+
# (`search-index` 那条命令),"攒到最后才写"等于"超时/被杀就全丢"。
|
|
76
|
+
CHECKPOINT_BATCHES = 100
|
|
74
77
|
TZ = timezone(timedelta(hours=8))
|
|
75
78
|
|
|
76
79
|
# 两条知识库线在**索引里**的 `type` 值。索引是一份,这是**故意的**:两条线在同一次嵌入里
|
|
@@ -113,8 +116,13 @@ def json_output(data):
|
|
|
113
116
|
|
|
114
117
|
# ====== Embedding API ======
|
|
115
118
|
|
|
116
|
-
def get_embeddings(texts: list[str], api_key: str) -> list[list[float]]:
|
|
117
|
-
"""Call 阿里云百炼 embedding API (OpenAI-compatible).
|
|
119
|
+
def get_embeddings(texts: list[str], api_key: str, on_batch=None) -> list[list[float]]:
|
|
120
|
+
"""Call 阿里云百炼 embedding API (OpenAI-compatible).
|
|
121
|
+
|
|
122
|
+
`on_batch(done, total, batch_embeddings)` 每批调一次(可选)。建索引靠它**边嵌边落盘**:
|
|
123
|
+
真实窗口上这个调用要跑二十分钟以上,而 CLI 给子进程的超时只有五分钟 —— "攒到最后才写"
|
|
124
|
+
意味着超时或被杀就是"这一次花的钱和时间全丢,重跑还得再付一次"。检索那条路不传它。
|
|
125
|
+
"""
|
|
118
126
|
if not texts:
|
|
119
127
|
return []
|
|
120
128
|
|
|
@@ -143,6 +151,8 @@ def get_embeddings(texts: list[str], api_key: str) -> list[list[float]]:
|
|
|
143
151
|
result = json.loads(resp.read().decode('utf-8'))
|
|
144
152
|
embeddings = [item['embedding'] for item in result['data']]
|
|
145
153
|
all_embeddings.extend(embeddings)
|
|
154
|
+
if on_batch:
|
|
155
|
+
on_batch(len(all_embeddings), len(texts), embeddings)
|
|
146
156
|
except Exception as e:
|
|
147
157
|
err_body = ''
|
|
148
158
|
if hasattr(e, 'read'):
|
|
@@ -150,6 +160,8 @@ def get_embeddings(texts: list[str], api_key: str) -> list[list[float]]:
|
|
|
150
160
|
except: pass
|
|
151
161
|
print(f"[WARN] Embedding API batch {i//BATCH_SIZE + 1} 失败: {e} {err_body}", file=sys.stderr)
|
|
152
162
|
all_embeddings.extend([[0.0] * EMBEDDING_DIM for _ in batch])
|
|
163
|
+
if on_batch: # 失败也要报进度,否则看起来就是"卡住了"
|
|
164
|
+
on_batch(len(all_embeddings), len(texts), [[0.0] * EMBEDDING_DIM] * len(batch))
|
|
153
165
|
|
|
154
166
|
return all_embeddings
|
|
155
167
|
|
|
@@ -256,6 +268,45 @@ def collect_chat_messages(conns, name_map, days=90, per_talker=500):
|
|
|
256
268
|
return items
|
|
257
269
|
|
|
258
270
|
|
|
271
|
+
_ARTICLE_BOILERPLATE = frozenset({
|
|
272
|
+
'在小说阅读器读本章', '去阅读', '在公众号小说中沉浸阅读',
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def extract_article_body(content, limit=500):
|
|
277
|
+
"""从日报 md 里取"像正文"的一段 —— **这就是 2026-10-15 修的那个洞**。
|
|
278
|
+
|
|
279
|
+
这里原来只有一句 `if line.startswith('## 正文')`,而实测最近 30 个日期目录的 10,759 篇里
|
|
280
|
+
**没有一篇**含这个标记(真实结构是 `frontmatter → # 标题 → > 来源 → --- → ## AI 摘要 →
|
|
281
|
+
--- → 重复标题 → 正文`),于是每一篇都退回到 `content[:1000]`:送去 embedding 的是 YAML 头
|
|
282
|
+
(抽样 300 篇:100% 含 `title:`,汉字占比中位 0.16),正文一个字都没进索引。
|
|
283
|
+
症状不是报错,而是**文章线的排序只能靠标题** —— 所以它值一个函数加一条测试,而不是再塞一句 if。
|
|
284
|
+
|
|
285
|
+
取法:切掉 frontmatter,逐行丢掉标题/引用/图片/分隔线/纯符号行,攒到 limit 为止。
|
|
286
|
+
`## AI 摘要` 的正文(一句话概括)会被收进来,它对检索有用。
|
|
287
|
+
"""
|
|
288
|
+
lines = content.split('\n')
|
|
289
|
+
if lines and lines[0].strip() == '---':
|
|
290
|
+
for i in range(1, len(lines)):
|
|
291
|
+
if lines[i].strip() == '---':
|
|
292
|
+
lines = lines[i + 1:]
|
|
293
|
+
break
|
|
294
|
+
kept = []
|
|
295
|
+
total = 0
|
|
296
|
+
for raw in lines:
|
|
297
|
+
s = raw.strip()
|
|
298
|
+
if len(s) < 6 or s.startswith(('#', '>', '![', '|', 'http', '```')):
|
|
299
|
+
continue
|
|
300
|
+
if s in _ARTICLE_BOILERPLATE:
|
|
301
|
+
continue
|
|
302
|
+
if not any(c.isalnum() or '一' <= c <= '鿿' for c in s):
|
|
303
|
+
continue # 纯符号行(分隔线、下划线横线)
|
|
304
|
+
kept.append(s)
|
|
305
|
+
total += len(s)
|
|
306
|
+
if total >= limit:
|
|
307
|
+
break
|
|
308
|
+
return '\n'.join(kept)[:limit]
|
|
309
|
+
|
|
259
310
|
def collect_articles(article_days=30):
|
|
260
311
|
"""Collect articles from biz-daily.
|
|
261
312
|
|
|
@@ -291,7 +342,10 @@ def collect_articles(article_days=30):
|
|
|
291
342
|
if line.startswith('## 正文'):
|
|
292
343
|
body_start = i + 1
|
|
293
344
|
break
|
|
294
|
-
|
|
345
|
+
# 有 `## 正文` 就用它(最精确的标记);没有就按真实结构取正文,
|
|
346
|
+
# 不再退回到 YAML 头 —— 那正是"文章线只能靠标题排"的原因。
|
|
347
|
+
body = ('\n'.join(lines[body_start:body_start + 50]) if body_start
|
|
348
|
+
else extract_article_body(content))
|
|
295
349
|
if title:
|
|
296
350
|
items.append({
|
|
297
351
|
"id": f"article:{f.relative_to(daily_dir)}",
|
|
@@ -309,6 +363,35 @@ def collect_articles(article_days=30):
|
|
|
309
363
|
|
|
310
364
|
# ====== Index Operations ======
|
|
311
365
|
|
|
366
|
+
def _save_index(base_vectors, base_meta, pairs):
|
|
367
|
+
"""把「旧索引 + 这一批已经嵌好的」写出去,供中途落盘与收尾共用。
|
|
368
|
+
|
|
369
|
+
两处讲究都是实测出来的:
|
|
370
|
+
· **先写向量、再写 meta,各自"临时文件 + replace"**。两步之间被杀时,宁可 meta 少一条,
|
|
371
|
+
也不能让它多一条 —— 多出来的那条指向不存在的向量行,检索时 argsort 的下标会越界,
|
|
372
|
+
而 `search()` 里那个 `except: pass` 会把它变成"静默退回关键词",那是最难看出来的一种。
|
|
373
|
+
· 向量**逐行**归一化后再存(原来是整个矩阵一次性归一化)。归一化本来就是逐行的,
|
|
374
|
+
重复做结果不变,所以中途落盘不会改变最终结果。
|
|
375
|
+
"""
|
|
376
|
+
np = require_numpy()
|
|
377
|
+
if pairs:
|
|
378
|
+
new_vectors = np.array([v for _item, v in pairs], dtype=np.float32)
|
|
379
|
+
else:
|
|
380
|
+
new_vectors = np.empty((0, EMBEDDING_DIM), dtype=np.float32)
|
|
381
|
+
vectors = np.vstack([base_vectors, new_vectors]) if base_vectors.size else new_vectors
|
|
382
|
+
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
|
|
383
|
+
norms[norms == 0] = 1 # Avoid division by zero
|
|
384
|
+
vectors = vectors / norms
|
|
385
|
+
meta = list(base_meta) + [item for item, _v in pairs]
|
|
386
|
+
|
|
387
|
+
tmp_v = VECTORS_FILE.with_name(VECTORS_FILE.name + '.tmp.npy')
|
|
388
|
+
np.save(tmp_v, vectors)
|
|
389
|
+
os.replace(tmp_v, VECTORS_FILE)
|
|
390
|
+
tmp_m = META_FILE.with_name(META_FILE.name + '.tmp')
|
|
391
|
+
tmp_m.write_text(json.dumps(meta, ensure_ascii=False), encoding='utf-8')
|
|
392
|
+
os.replace(tmp_m, META_FILE)
|
|
393
|
+
return len(meta)
|
|
394
|
+
|
|
312
395
|
def build_index(api_key: str, full: bool = False, days: int = 90, article_days: int = 30):
|
|
313
396
|
"""Build or update the semantic index.
|
|
314
397
|
|
|
@@ -385,41 +468,56 @@ def build_index(api_key: str, full: bool = False, days: int = 90, article_days:
|
|
|
385
468
|
"chatDays": days, "articleDays": article_days,
|
|
386
469
|
"shardsFailed": shards_failed}
|
|
387
470
|
|
|
388
|
-
# Generate embeddings
|
|
389
|
-
print("生成 embeddings...", file=sys.stderr)
|
|
390
471
|
texts = [item['text'] for item in new_items]
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if not embeddings or all(e == [0.0] * EMBEDDING_DIM for e in embeddings):
|
|
394
|
-
return {"error": "Embedding 生成失败,请检查 API key",
|
|
395
|
-
"chatDays": days, "articleDays": article_days}
|
|
472
|
+
total_new = len(texts)
|
|
473
|
+
print(f"生成 embeddings...({total_new} 条,共 {-(-total_new // BATCH_SIZE)} 批)", file=sys.stderr)
|
|
396
474
|
|
|
397
|
-
#
|
|
475
|
+
# 旧索引先读进来:增量时它是底,中途落盘与收尾都要以它开头。
|
|
398
476
|
if VECTORS_FILE.exists() and not full:
|
|
399
|
-
|
|
400
|
-
|
|
477
|
+
base_vectors = np.load(VECTORS_FILE)
|
|
478
|
+
base_meta = json.loads(META_FILE.read_text(encoding='utf-8'))
|
|
401
479
|
else:
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
#
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
480
|
+
base_vectors = np.empty((0, EMBEDDING_DIM), dtype=np.float32)
|
|
481
|
+
base_meta = []
|
|
482
|
+
|
|
483
|
+
ok_pairs = [] # [(item, vector)] —— 真嵌出来的才配进索引
|
|
484
|
+
state = {'failed': 0, 'flushed': 0}
|
|
485
|
+
|
|
486
|
+
def on_batch(done, total, batch_embs):
|
|
487
|
+
"""边嵌边落盘,并且把"全零"那批(请求失败)挑出来**不写**。
|
|
488
|
+
|
|
489
|
+
两件事都是实测踩出来的:
|
|
490
|
+
· 这个索引要跑二十分钟以上,而 CLI 给子进程的超时是五分钟 —— 攒到最后才写就是
|
|
491
|
+
"超时/被杀等于全丢";每 CHECKPOINT_BATCHES 批落一次盘,被杀也只丢最后那几千条,
|
|
492
|
+
而且重跑 `update` 会从断点接着来(已落盘的 id 就在 meta 里)。
|
|
493
|
+
· 失败批次拿到的是全零向量。照旧写进去的话,那些 id 会永久躺在索引里**再也不会被重嵌**
|
|
494
|
+
(增量只嵌新 id)—— 索引被安静地投毒。所以它们不写,留到下次重跑。
|
|
495
|
+
"""
|
|
496
|
+
start = done - len(batch_embs)
|
|
497
|
+
for k, emb in enumerate(batch_embs):
|
|
498
|
+
if any(emb):
|
|
499
|
+
ok_pairs.append((new_items[start + k], emb))
|
|
500
|
+
else:
|
|
501
|
+
state['failed'] += 1
|
|
502
|
+
if done >= total or done - state['flushed'] >= CHECKPOINT_BATCHES * BATCH_SIZE:
|
|
503
|
+
_save_index(base_vectors, base_meta, ok_pairs)
|
|
504
|
+
state['flushed'] = done
|
|
505
|
+
print(f"[索引] 已嵌入 {done}/{total}(落盘 {len(ok_pairs)} 条,"
|
|
506
|
+
f"失败 {state['failed']} 条)", file=sys.stderr)
|
|
507
|
+
|
|
508
|
+
get_embeddings(texts, api_key, on_batch=on_batch)
|
|
509
|
+
|
|
510
|
+
if not ok_pairs:
|
|
511
|
+
return {"error": "Embedding 生成失败,请检查 API key",
|
|
512
|
+
"embedFailed": state['failed'],
|
|
513
|
+
"chatDays": days, "articleDays": article_days}
|
|
418
514
|
|
|
419
515
|
return {
|
|
420
516
|
"status": "success",
|
|
421
|
-
"total": len(
|
|
422
|
-
"new": len(
|
|
517
|
+
"total": len(base_meta) + len(ok_pairs),
|
|
518
|
+
"new": len(ok_pairs),
|
|
519
|
+
# 嵌不出来的那几个**要说出来**:它们既不在索引里,也是下次重跑的输入
|
|
520
|
+
"embedFailed": state['failed'],
|
|
423
521
|
"chatDays": days,
|
|
424
522
|
"articleDays": article_days,
|
|
425
523
|
# 分片读不全时**要报出来**:`shardsFailed` 是"全读了"与"读了能读到的"之差,
|
|
@@ -494,7 +592,14 @@ def keyword_search(query: str, top_k: int = 10, root=None, line: str = 'all'):
|
|
|
494
592
|
|
|
495
593
|
# 送进重排的候选数。检索是粗筛("语义相近"),重排是细判("真的回答了问题吗"),
|
|
496
594
|
# 所以池子要比 top_k 大,才有可换的空间。
|
|
497
|
-
|
|
595
|
+
# 送进重排的候选数。**2026-10-16 实测把默认从 20 提到 100**:同一批 40 题、只改池子
|
|
596
|
+
# (POOL_CURVE = 20 / 50 / 100 / 200):
|
|
597
|
+
# recall@10 0.82 / 0.88 / 0.95 / 0.95 hit@1 0.65 / 0.68 / 0.72 / 0.75
|
|
598
|
+
# MRR@10 0.72 / 0.75 / 0.80 / 0.82 nDCG@10 0.75 / 0.78 / 0.83 / 0.85
|
|
599
|
+
# 最弱的"碎片/文章"那格 recall@10:0.70 → 0.80 → **1.00** → 1.00。到 100 就饱和,
|
|
600
|
+
# 200 的增益是噪声级。代价是每次重排的提示从 ~2.5k tokens 涨到 ~20k(约 ¥0.01/次),
|
|
601
|
+
# 所以池子做成可调(`--rerank-pool`),嫌贵就调小。
|
|
602
|
+
RERANK_CANDIDATES = 100
|
|
498
603
|
RERANK_EXCERPT_CHARS = 300
|
|
499
604
|
|
|
500
605
|
|
|
@@ -513,6 +618,114 @@ def build_rerank_questions(count):
|
|
|
513
618
|
}
|
|
514
619
|
|
|
515
620
|
|
|
621
|
+
# 这一趟重排是谁排的、有没有降级 —— 必须能带进输出。
|
|
622
|
+
# 原来的写法里"重排失败"只往 stderr 打一行 WARN,而 CLI 不转发子进程 stderr,
|
|
623
|
+
# 于是"没报错"会被读成"重排过了"。状态放这里,主流程把它塞进 JSON。
|
|
624
|
+
RERANK_STATE = {'provider': None, 'degraded': False, 'error': None, 'scored': 0}
|
|
625
|
+
|
|
626
|
+
_RERANK_LLM_RUBRIC = (
|
|
627
|
+
'给每条候选打 0-100 分,判据是**它是否直接回答了问题**("话题相近"不算):'
|
|
628
|
+
'100 = 直接给出所需信息;60-80 = 含大部分相关信息;30-50 = 只沾边;0-20 = 无关。'
|
|
629
|
+
'每条候选都必须有分数。只输出 JSON:{"scores":[{"i":1,"score":85},{"i":2,"score":20}]}。')
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def cleanse_passage(text, limit=RERANK_EXCERPT_CHARS):
|
|
633
|
+
"""送进重排前的段落清洗(照 WeKnora 的 passage cleansing)。
|
|
634
|
+
|
|
635
|
+
不清洗的话,模型有相当一部分注意力花在 `**加粗**`、公众号套话、作者署名、"点击蓝字"这些噪声上,
|
|
636
|
+
而候选摘录本来就只有 300 字。清洗是纯文本操作,不改判据。
|
|
637
|
+
"""
|
|
638
|
+
s = text or ''
|
|
639
|
+
s = re.sub(r'!\[[^\]]*\]\([^)]*\)', ' ', s) # 图片
|
|
640
|
+
s = re.sub(r'\[([^\]]{0,40})\]\([^)]*\)', r'\1', s) # 链接留文字
|
|
641
|
+
s = re.sub(r'\*{1,3}', '', s) # 加粗/斜体标记
|
|
642
|
+
s = re.sub(r'(点击蓝字|关注我们|阅读原文|继续滑动看下一个|轻触阅读原文|设为星标)', ' ', s)
|
|
643
|
+
s = re.sub(r'^\s*原创\s+\S+.*$', ' ', s, flags=re.M) # 署名行
|
|
644
|
+
s = re.sub(r'\s+', ' ', s)
|
|
645
|
+
return s.strip()[:limit]
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def llm_rerank(query, results, pool_size=RERANK_CANDIDATES, key=None, model=None):
|
|
649
|
+
"""用**已配置的对话模型**(OpenAI 兼容)重排。
|
|
650
|
+
|
|
651
|
+
为什么要有它:这条管线本来是"召回 + 重排"两段,而重排在本机一直是**空转**的
|
|
652
|
+
(Typesafe 返 `HTTP 451 not available in your region`),所以排序只有一段。
|
|
653
|
+
契约与 Jev 那版同构:一次请求给全部候选打分;校验不过就**保持原序并把降级说出来**。
|
|
654
|
+
"""
|
|
655
|
+
if len(results) < 2:
|
|
656
|
+
return results
|
|
657
|
+
try:
|
|
658
|
+
cfg = load_config()
|
|
659
|
+
key = key or get_api_key(cfg) or cfg.get('deepseekApiKey') or ''
|
|
660
|
+
model = model or cfg.get('aiModel') or 'deepseek-chat'
|
|
661
|
+
except Exception as error: # noqa: BLE001
|
|
662
|
+
# 读不到配置(这台机器可能还没有 ~/.weflow-cli/config.json)也要**保持原序**:
|
|
663
|
+
# 重排不该让检索失败,更不能一路抛到 `search()` 的 `except: pass` 里
|
|
664
|
+
# —— 那会**静默退回关键词检索**,最难看出来的一种失效。
|
|
665
|
+
print(' [WARN] 重排读不到配置(%s),保持原顺序' % type(error).__name__,
|
|
666
|
+
file=sys.stderr)
|
|
667
|
+
RERANK_STATE.update(provider='llm', degraded=True,
|
|
668
|
+
error='config-unreadable', scored=0)
|
|
669
|
+
return results
|
|
670
|
+
if not key:
|
|
671
|
+
RERANK_STATE.update(provider='llm', degraded=True, error='no-chat-key', scored=0)
|
|
672
|
+
return results
|
|
673
|
+
|
|
674
|
+
pool = results[:pool_size]
|
|
675
|
+
lines = []
|
|
676
|
+
for i, item in enumerate(pool):
|
|
677
|
+
lines.append('【候选%d】%s|%s' % (i + 1, item.get('title') or item.get('source') or '',
|
|
678
|
+
cleanse_passage(item.get('text') or '')))
|
|
679
|
+
payload = {
|
|
680
|
+
'model': model,
|
|
681
|
+
'messages': [{'role': 'user', 'content': '问题:%s\n\n候选:\n%s\n\n%s'
|
|
682
|
+
% (query, chr(10).join(lines), _RERANK_LLM_RUBRIC)}],
|
|
683
|
+
'response_format': {'type': 'json_object'}, 'temperature': 0,
|
|
684
|
+
}
|
|
685
|
+
request = urllib.request.Request('https://api.deepseek.com/v1/chat/completions',
|
|
686
|
+
data=json.dumps(payload, ensure_ascii=False).encode('utf-8'),
|
|
687
|
+
headers={'Authorization': 'Bearer %s' % key,
|
|
688
|
+
'Content-Type': 'application/json'})
|
|
689
|
+
try:
|
|
690
|
+
with urllib.request.urlopen(request, timeout=120) as resp:
|
|
691
|
+
data = json.loads(resp.read().decode('utf-8'))
|
|
692
|
+
scores = {int(row['i']): float(row['score'])
|
|
693
|
+
for row in json.loads(data['choices'][0]['message']['content'])['scores']}
|
|
694
|
+
except Exception as error: # noqa: BLE001 —— 重排不该让检索失败
|
|
695
|
+
print(' [WARN] 重排(%s)失败:%s: %s —— 保持原顺序'
|
|
696
|
+
% (model, type(error).__name__, error), file=sys.stderr)
|
|
697
|
+
RERANK_STATE.update(provider='llm', degraded=True,
|
|
698
|
+
error=type(error).__name__, scored=0)
|
|
699
|
+
return results
|
|
700
|
+
|
|
701
|
+
if set(scores) != set(range(1, len(pool) + 1)):
|
|
702
|
+
# 编号不齐 = 模型把候选搞混了 —— 这正是重排最危险、最不容易察觉的失效方式,宁可原序
|
|
703
|
+
print(' [WARN] 重排返回的候选编号不齐(%d/%d),保持原顺序'
|
|
704
|
+
% (len(scores), len(pool)), file=sys.stderr)
|
|
705
|
+
RERANK_STATE.update(provider='llm', degraded=True, error='incomplete-scores', scored=0)
|
|
706
|
+
return results
|
|
707
|
+
|
|
708
|
+
order = sorted(range(len(pool)), key=lambda i: (-scores[i + 1], i))
|
|
709
|
+
ranked = []
|
|
710
|
+
for i in order:
|
|
711
|
+
item = dict(pool[i])
|
|
712
|
+
item['rerankScore'] = scores[i + 1] # `score` 保持原义,新的放这里
|
|
713
|
+
ranked.append(item)
|
|
714
|
+
RERANK_STATE.update(provider='llm', degraded=False, error=None, scored=len(ranked))
|
|
715
|
+
return ranked + list(results[pool_size:])
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def rerank_dispatch(query, results, client=None, pool_size=RERANK_CANDIDATES):
|
|
719
|
+
"""先按原契约(Jev/Typesafe)重排,不可用或失败则退到已配置的对话模型。
|
|
720
|
+
|
|
721
|
+
两个都失败时返回原序,并把 `RERANK_STATE` 留在"降级"上 —— 别让"没重排"看起来像"重排过了"。
|
|
722
|
+
"""
|
|
723
|
+
RERANK_STATE.update(provider=None, degraded=False, error=None, scored=0)
|
|
724
|
+
if client is not None:
|
|
725
|
+
out = rerank(query, results, client, pool_size=pool_size)
|
|
726
|
+
if not RERANK_STATE['degraded'] and RERANK_STATE['scored']:
|
|
727
|
+
return out
|
|
728
|
+
return llm_rerank(query, results, pool_size=pool_size)
|
|
516
729
|
def rerank(query, results, client=None, pool_size=RERANK_CANDIDATES):
|
|
517
730
|
"""用**一次**决策请求给检索结果重排。
|
|
518
731
|
|
|
@@ -548,6 +761,8 @@ def rerank(query, results, client=None, pool_size=RERANK_CANDIDATES):
|
|
|
548
761
|
# `file=sys.stderr`:`--json` 时 stdout 是机器在解析的那一份(同文件其它 WARN 早就这样)
|
|
549
762
|
print(' [WARN] 重排失败(%s),保持原检索顺序:%s' % (type(error).__name__, error),
|
|
550
763
|
file=sys.stderr)
|
|
764
|
+
RERANK_STATE.update(provider='typesafe', degraded=True,
|
|
765
|
+
error=type(error).__name__, scored=0)
|
|
551
766
|
return results
|
|
552
767
|
|
|
553
768
|
scored = []
|
|
@@ -561,6 +776,8 @@ def rerank(query, results, client=None, pool_size=RERANK_CANDIDATES):
|
|
|
561
776
|
|
|
562
777
|
if all(value is None for value, _i, _item in scored):
|
|
563
778
|
print(' [WARN] 重排没有返回任何可用分数,保持原检索顺序', file=sys.stderr)
|
|
779
|
+
RERANK_STATE.update(provider='typesafe', degraded=True,
|
|
780
|
+
error='no-usable-scores', scored=0)
|
|
564
781
|
return results
|
|
565
782
|
|
|
566
783
|
# 没拿到分数的按原序垫在最后,而不是当成 0 分——"未知"和"不相关"不是一回事。
|
|
@@ -578,11 +795,324 @@ def rerank(query, results, client=None, pool_size=RERANK_CANDIDATES):
|
|
|
578
795
|
entry['rerankScore'] = round(value, 3)
|
|
579
796
|
ordered.append(entry)
|
|
580
797
|
ordered.extend(results[pool_size:])
|
|
798
|
+
RERANK_STATE.update(provider='typesafe', degraded=False, error=None,
|
|
799
|
+
scored=len(ordered))
|
|
581
800
|
return ordered
|
|
582
801
|
|
|
583
802
|
|
|
803
|
+
# 混合检索:向量名次 + 本地关键词名次(BM25),用 RRF 合成。纯本地、不联网、不加依赖。
|
|
804
|
+
#
|
|
805
|
+
# **默认开**,`--no-hybrid` 关 —— 这个默认值是分两步量出来的(都记在 D-110 / D-111):
|
|
806
|
+
# · 第一步(2026-10-15 上午):**无门控**的 RRF 单独用是**有害**的。真实问句 5 条里只在 1 条上更好、
|
|
807
|
+
# 2 条更差;逐字碎片 16 条上 BM25 单独 16/16、RRF 只有 12/16。原因是 RRF 给两侧同等票权,而中文
|
|
808
|
+
# 问句里的虚词(怎么用/有没有/的)会把 BM25 侧变成纯噪声。当时结论:默认关。
|
|
809
|
+
# · 第二步(同日下午,加了门控):只让"命中稀有词、且分数接近最高分"的文档投票;问句里**没有稀有词
|
|
810
|
+
# 就整个弃权**。同一批题(frag 40 / title 40 / 手写问句 12)重测:
|
|
811
|
+
# 碎片 recall@10 0.91 → **1.00**,@5 0.89 → 0.97,hit@1 0.60 → **0.80**
|
|
812
|
+
# 标题 recall@10 0.97(不变),@5 0.91 → 0.94,hit@1 0.83 → 0.86
|
|
813
|
+
# 手写问句 三档全不变(0.75/0.75/0.75)
|
|
814
|
+
# **三个台子都不掉**,于是翻成默认。无门控版同一批题零增益(0.91/0.60,与纯向量相同)——
|
|
815
|
+
# 也就是说:让关键词侧起作用的是**门控**,不是融合本身。
|
|
816
|
+
# · 阈值敏感性(同一批题):宽松 (0.10/0.3) 弱一点(碎片 @5 0.94),紧的 (0.02/0.7) 略好
|
|
817
|
+
# (@5 1.00)—— 平台是平的,说明这个数字不承重;取中间值,且**不许**再拿这一批题去微调它。
|
|
818
|
+
RRF_K = 60 # RRF 的平滑常数:名次差在几十名以内才有区分度(教科书默认值)
|
|
819
|
+
RRF_POOL = 200 # 两侧各取前多少名进融合;再往后的名次贡献 1/(60+200) 已经可以忽略
|
|
820
|
+
BM25_K1 = 1.2
|
|
821
|
+
BM25_B = 0.75
|
|
822
|
+
|
|
823
|
+
# 门控用:稀有词阈值(词出现在 ≤5% 的在范围文档里才算"稀有")与分数相对阈
|
|
824
|
+
HYBRID_RARE_RATIO = 0.05
|
|
825
|
+
HYBRID_SCORE_RATIO = 0.5
|
|
826
|
+
|
|
827
|
+
_CJK_RUN = re.compile(r'[一-鿿]+')
|
|
828
|
+
_ASCII_WORD = re.compile(r'[a-z0-9]{2,}')
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def query_terms(text):
|
|
832
|
+
"""把查询切成检索词:中文按**字符二元组**(没有分词器,二元组对中文足够稳),ASCII 按词。
|
|
833
|
+
|
|
834
|
+
二元组是刻意选的:它不需要词表、不会因为新词失手,代价是"的/了/在"这类高频字也会成词——
|
|
835
|
+
那由 BM25 的 idf 自己压下去(df 越大分越低),不用手写停用词表。
|
|
836
|
+
"""
|
|
837
|
+
terms = []
|
|
838
|
+
for run in _CJK_RUN.findall(text):
|
|
839
|
+
if len(run) == 1:
|
|
840
|
+
terms.append(run)
|
|
841
|
+
else:
|
|
842
|
+
terms.extend(run[i:i + 2] for i in range(len(run) - 1))
|
|
843
|
+
terms.extend(_ASCII_WORD.findall(text.lower()))
|
|
844
|
+
seen, out = set(), []
|
|
845
|
+
for t in terms:
|
|
846
|
+
if t not in seen:
|
|
847
|
+
seen.add(t)
|
|
848
|
+
out.append(t)
|
|
849
|
+
return out
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def keyword_rank(query, meta, in_scope=None, limit=RRF_POOL,
|
|
853
|
+
rare_ratio=None, score_ratio=None):
|
|
854
|
+
"""本地 BM25 名次。返回 [(下标, 分数)],按分数降序、**并列按下标升序**。
|
|
855
|
+
|
|
856
|
+
一遍扫过所有记录,同时算 df 与每条的 tf —— 不建额外索引文件,也就没有"索引与正文不同步"
|
|
857
|
+
这种失效。`in_scope` 是与向量侧**同一个掩码**:关键词这一侧也不能把另一条线的记录带进来。
|
|
858
|
+
|
|
859
|
+
`limit` 只截名次表(RRF 只看名次,第 200 名之后的贡献可以忽略),不影响打分。
|
|
860
|
+
"""
|
|
861
|
+
terms = query_terms(query)
|
|
862
|
+
if not terms:
|
|
863
|
+
return []
|
|
864
|
+
docs = [] # [(下标, 小写文本)],只含在范围内的
|
|
865
|
+
for i, m in enumerate(meta):
|
|
866
|
+
if in_scope is not None and not in_scope[i]:
|
|
867
|
+
continue
|
|
868
|
+
t = (m.get('text') or '').lower()
|
|
869
|
+
if t:
|
|
870
|
+
docs.append((i, t))
|
|
871
|
+
if not docs:
|
|
872
|
+
return []
|
|
873
|
+
|
|
874
|
+
tf, df, total_len = [], {}, 0
|
|
875
|
+
for _i, t in docs:
|
|
876
|
+
total_len += len(t)
|
|
877
|
+
row = {}
|
|
878
|
+
for term in terms:
|
|
879
|
+
c = t.count(term)
|
|
880
|
+
if c:
|
|
881
|
+
row[term] = c
|
|
882
|
+
if row:
|
|
883
|
+
for term in row:
|
|
884
|
+
df[term] = df.get(term, 0) + 1
|
|
885
|
+
tf.append(row or None)
|
|
886
|
+
|
|
887
|
+
n_docs = len(docs)
|
|
888
|
+
avgdl = (total_len / n_docs) or 1.0
|
|
889
|
+
|
|
890
|
+
# 门控(1):**没有稀有词就弃权**。中文问句里的"怎么用/有没有/的"会成词,但它们在语料里
|
|
891
|
+
# 到处都是 —— 只命中这类词的文档不该来投票(无门控的 RRF 正是栽在这里,见 D-110)。
|
|
892
|
+
rare = None
|
|
893
|
+
if rare_ratio is not None:
|
|
894
|
+
rare = {t for t, d in df.items() if d and d / n_docs <= rare_ratio}
|
|
895
|
+
if not rare:
|
|
896
|
+
return []
|
|
897
|
+
|
|
898
|
+
scored = []
|
|
899
|
+
for (idx, t), row in zip(docs, tf):
|
|
900
|
+
if not row:
|
|
901
|
+
continue
|
|
902
|
+
if rare is not None and not (set(row) & rare):
|
|
903
|
+
continue
|
|
904
|
+
score = 0.0
|
|
905
|
+
for term, c in row.items():
|
|
906
|
+
d = df[term]
|
|
907
|
+
idf = math.log(1 + (n_docs - d + 0.5) / (d + 0.5))
|
|
908
|
+
denom = c + BM25_K1 * (1 - BM25_B + BM25_B * len(t) / avgdl)
|
|
909
|
+
score += idf * c * (BM25_K1 + 1) / denom
|
|
910
|
+
scored.append((idx, score))
|
|
911
|
+
|
|
912
|
+
# 门控(2):长词的垃圾二元组("器学""习或")同样稀有 ⇒ 再用"分数接近最高分"筛一道。
|
|
913
|
+
if score_ratio is not None and scored:
|
|
914
|
+
top = max(s for _i, s in scored)
|
|
915
|
+
scored = [(idx, s) for idx, s in scored if s >= score_ratio * top]
|
|
916
|
+
|
|
917
|
+
scored.sort(key=lambda kv: (-kv[1], kv[0]))
|
|
918
|
+
return scored[:limit]
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def rrf_fuse(rank_lists, k=RRF_K):
|
|
922
|
+
"""Reciprocal Rank Fusion:只吃**名次**、不吃分数,所以两侧分数量纲不同也没关系。
|
|
923
|
+
|
|
924
|
+
并列时的次序按下标升序定死 —— 这个仓库对"同一批数据两次跑给出不同顺序"很敏感
|
|
925
|
+
(缓存会把偶然结果固化),所以不让它取决于 dict 的插入顺序。
|
|
926
|
+
"""
|
|
927
|
+
score = {}
|
|
928
|
+
for lst in rank_lists:
|
|
929
|
+
for rank, idx in enumerate(lst):
|
|
930
|
+
score[idx] = score.get(idx, 0.0) + 1.0 / (k + rank + 1)
|
|
931
|
+
return sorted(score.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
932
|
+
|
|
933
|
+
MMR_LAMBDA = 0.7 # 照 WeKnora 的取值:相关性为主、多样性为辅
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def mmr_select(items, vectors, k, lam=MMR_LAMBDA):
|
|
937
|
+
"""在**已经排好序**的候选里挑 k 条:又相关、又彼此不像(Maximal Marginal Relevance)。
|
|
938
|
+
|
|
939
|
+
为什么要有它:这条语料里"同领域邻居"很多(实测 top-5 常有三四条在讲同一件事),它们吃掉名额
|
|
940
|
+
却不增加信息。MMR 给每条的边际价值 = λ×相关性 − (1−λ)×与**已选条目**的最大相似度,逐条贪心挑。
|
|
941
|
+
|
|
942
|
+
相关性取重排分(0-100 归一化到 0-1),没有重排分就退回余弦;相似度用向量点积(存的就是归一化的)。
|
|
943
|
+
条目必须带 `_row`(在 meta/向量里的行号)—— `search()` 在重排前挂上、返回前摘掉。
|
|
944
|
+
与"同文本折叠"(`dedup_results`)不是一回事:那个折叠一模一样的内容,这个压的是**近似**的内容。
|
|
945
|
+
"""
|
|
946
|
+
np = require_numpy()
|
|
947
|
+
if k <= 0 or not items:
|
|
948
|
+
return items
|
|
949
|
+
rows, rel = [], []
|
|
950
|
+
for item in items:
|
|
951
|
+
row = item.get('_row')
|
|
952
|
+
if row is None:
|
|
953
|
+
return items # 拿不到行号就不动它,别猜
|
|
954
|
+
rows.append(int(row))
|
|
955
|
+
score = item.get('rerankScore')
|
|
956
|
+
rel.append((float(score) / 100.0) if isinstance(score, (int, float)) else
|
|
957
|
+
float(item.get('score') or 0.0))
|
|
958
|
+
matrix = np.asarray(vectors[rows], dtype=np.float32)
|
|
959
|
+
rel = np.asarray(rel, dtype=np.float32)
|
|
960
|
+
|
|
961
|
+
chosen, remaining = [], list(range(len(items)))
|
|
962
|
+
max_sim = np.zeros(len(items), dtype=np.float32)
|
|
963
|
+
while remaining and len(chosen) < k:
|
|
964
|
+
gain = lam * rel[remaining] - (1 - lam) * max_sim[remaining]
|
|
965
|
+
best = remaining[int(np.argmax(gain))]
|
|
966
|
+
chosen.append(best)
|
|
967
|
+
remaining.remove(best)
|
|
968
|
+
sims = matrix @ matrix[best]
|
|
969
|
+
max_sim = np.maximum(max_sim, sims)
|
|
970
|
+
rest = [i for i in range(len(items)) if i not in set(chosen)]
|
|
971
|
+
return [items[i] for i in chosen + rest]
|
|
972
|
+
|
|
973
|
+
def dedup_results(results):
|
|
974
|
+
"""同一段文本只留排名最高的那条,被折叠的账号数记在 `duplicateCount` 里。
|
|
975
|
+
|
|
976
|
+
为什么在**取出结果时**做:多号转载是这语料的常态(实测 2026-10-16:20 条问句的 top-5 里 7% 是重复文本,
|
|
977
|
+
6/20 条问句的 top-5 出现过重复),重复占的是名额而不是信息。不做成"建索引时去重"是因为那会破坏
|
|
978
|
+
`--from <公众号>` 这类按号查(被折叠的账号仍在 id 里);所以留着索引,折叠留给展示。
|
|
979
|
+
`duplicateCount` 让用户知道"同一篇被几个号发过",而不是以为丢了什么。
|
|
980
|
+
"""
|
|
981
|
+
seen, out = {}, []
|
|
982
|
+
for item in results:
|
|
983
|
+
text = item.get('text') or ''
|
|
984
|
+
if not text: # 没有正文的记录各算各的:空串之间不是"同一段"
|
|
985
|
+
out.append({**item, 'duplicateCount': 1})
|
|
986
|
+
continue
|
|
987
|
+
h = hash(text)
|
|
988
|
+
if h in seen:
|
|
989
|
+
seen[h]['duplicateCount'] = seen[h].get('duplicateCount', 1) + 1
|
|
990
|
+
continue
|
|
991
|
+
entry = dict(item)
|
|
992
|
+
entry['duplicateCount'] = 1
|
|
993
|
+
seen[h] = entry
|
|
994
|
+
out.append(entry)
|
|
995
|
+
return out
|
|
996
|
+
# 正文里按"行"删掉的公众号套话(只删整行,不动正文句子)
|
|
997
|
+
_BOILERPLATE_LINE = re.compile(r'^\s*(在小说阅读器读本章|去阅读|在公众号小说中沉浸阅读|点击蓝字|关注我们|一键关注|设为星标|预览时标签不可点|阅读原文|微信扫一扫|关注该公众号|写留言|点个在看|点赞|在看|不喜欢|______+|-{3,})\s*$')
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def readable_body(text):
|
|
1001
|
+
"""把正文整理成"能读"的样子:去加粗/斜体标记与公众号套话行,**保留段落**。
|
|
1002
|
+
|
|
1003
|
+
与重排那份摘录(`cleanse_passage`)不同:那个会把空白压成一行、还截断到 300 字,
|
|
1004
|
+
因为它只是喂给模型的;这一份是给人看的,所以保留换行。原始文件路径在 `sourceFile` 里,
|
|
1005
|
+
想核对"有没有被我改过"就打开那个文件对比。
|
|
1006
|
+
"""
|
|
1007
|
+
out = []
|
|
1008
|
+
for raw_line in (text or '').split('\n'):
|
|
1009
|
+
line = re.sub(r'!\[[^\]]*\]\([^)]*\)', ' ', raw_line) # 图片语法
|
|
1010
|
+
line = re.sub(r'\[([^\]]{0,80})\]\([^)]*\)', r'\1', line) # 链接留文字
|
|
1011
|
+
line = re.sub(r'\*{1,3}', '', line).strip()
|
|
1012
|
+
if not line or _BOILERPLATE_LINE.match(line):
|
|
1013
|
+
continue
|
|
1014
|
+
out.append(line)
|
|
1015
|
+
return '\n'.join(out)
|
|
1016
|
+
|
|
1017
|
+
FULL_TEXT_MIN_CHARS = 300 # `--fetch-missing` 的判据:正文短于这个数就算"没抓到正文"
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _fetch_body(url):
|
|
1021
|
+
"""按 url 取正文 —— **复用日报那套带缓存的抓取**(`biz_daily.fetch_article_cached`)。
|
|
1022
|
+
|
|
1023
|
+
不另写一份的理由与仓库里其它"同一份实现"一样:缓存格式、失败语义(只缓存成功)、
|
|
1024
|
+
以及"入库前先看缓存"这件事必须只有一处。懒导入:不抓正文的路径不该被它拖着。
|
|
1025
|
+
"""
|
|
1026
|
+
try:
|
|
1027
|
+
from biz_daily import fetch_article_cached
|
|
1028
|
+
except Exception as error: # noqa: BLE001
|
|
1029
|
+
print(' [WARN] 抓正文不可用(%s),改用本地那份' % type(error).__name__, file=sys.stderr)
|
|
1030
|
+
return None
|
|
1031
|
+
try:
|
|
1032
|
+
body, _cached = fetch_article_cached(url)
|
|
1033
|
+
except Exception as error: # noqa: BLE001
|
|
1034
|
+
print(' [WARN] 抓正文失败(%s):%s' % (type(error).__name__, error), file=sys.stderr)
|
|
1035
|
+
return None
|
|
1036
|
+
return body
|
|
1037
|
+
|
|
1038
|
+
FULL_TEXT_LIMIT = 4000 # `--with-text` 默认给多少字(够一篇公众号全文)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def load_record_text(item, limit=FULL_TEXT_LIMIT):
|
|
1042
|
+
"""把命中的记录还原成本地那份**正文** —— 索引里存的只是"用来匹配的摘录"。
|
|
1043
|
+
|
|
1044
|
+
这是 2026-10-16 用户提出来的:返回标题+分数没有意义,要能给出**某篇公众号的具体内容**。
|
|
1045
|
+
两条来源,取更长的那份:
|
|
1046
|
+
· 日报 md:`output/biz-daily/<id 里那段相对路径>`,正文用同一套 `extract_article_body` 取;
|
|
1047
|
+
· 正文缓存:`output/.cache/fetch/<md5(url)>.md` —— 日报/回填抓来的**完整正文**(比 md 更全)。
|
|
1048
|
+
聊天记录没有这两层:索引里那段**本来就是消息全文**。
|
|
1049
|
+
|
|
1050
|
+
返回 `(text, source_file, url)`;读不到时 text 为 None(**不许**静默当成"这篇没有正文")。
|
|
1051
|
+
"""
|
|
1052
|
+
rid = str(item.get('id') or '')
|
|
1053
|
+
if not rid.startswith('article:'):
|
|
1054
|
+
return (item.get('text') or ''), None, None
|
|
1055
|
+
|
|
1056
|
+
rel = rid[len('article:'):].replace('\\', '/')
|
|
1057
|
+
path = Path(OUTPUT_ROOT) / 'biz-daily' / rel
|
|
1058
|
+
if not path.exists():
|
|
1059
|
+
return None, str(path), None
|
|
1060
|
+
try:
|
|
1061
|
+
content = path.read_text(encoding='utf-8', errors='replace')
|
|
1062
|
+
except OSError:
|
|
1063
|
+
return None, str(path), None
|
|
1064
|
+
|
|
1065
|
+
url = None
|
|
1066
|
+
match = re.search(r'^url:\s*"?([^"\n]+)"?', content, re.M)
|
|
1067
|
+
if match:
|
|
1068
|
+
url = match.group(1).strip()
|
|
1069
|
+
|
|
1070
|
+
body = extract_article_body(content, limit)
|
|
1071
|
+
if url:
|
|
1072
|
+
cached = Path(OUTPUT_ROOT) / '.cache' / 'fetch' / (
|
|
1073
|
+
hashlib.md5(url.encode('utf-8')).hexdigest() + '.md')
|
|
1074
|
+
if cached.exists():
|
|
1075
|
+
try:
|
|
1076
|
+
full = cached.read_text(encoding='utf-8', errors='replace').strip()
|
|
1077
|
+
if len(full) > len(body): # 缓存那份才是完整正文
|
|
1078
|
+
body = full[:limit]
|
|
1079
|
+
except OSError:
|
|
1080
|
+
pass
|
|
1081
|
+
return readable_body(body), str(path), url
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def attach_full_text(results, limit=FULL_TEXT_LIMIT, fetch_missing=0):
|
|
1085
|
+
"""给每条结果挂上 `fullText` / `sourceFile` / `url`(读不到的挂 None,并数出来)。
|
|
1086
|
+
|
|
1087
|
+
`fetch_missing>0` 时,**本地正文短于 `FULL_TEXT_MIN_CHARS` 的那些**会按 `url` 去抓(最多抓这么多条):
|
|
1088
|
+
抓得到的写回 `fullText` 并标 `fetchedFromUrl`,同一份缓存下次直接命中(不重复打扰上游)。
|
|
1089
|
+
默认 0 = 不抓 —— 检索不该默认产生对上游的请求。返回 `(results, missing, fetched)`。
|
|
1090
|
+
"""
|
|
1091
|
+
missing = 0
|
|
1092
|
+
fetched = 0
|
|
1093
|
+
for item in results:
|
|
1094
|
+
text, path, url = load_record_text(item, limit)
|
|
1095
|
+
if text is None:
|
|
1096
|
+
missing += 1
|
|
1097
|
+
if fetch_missing and fetched < fetch_missing and url and len(text or '') < FULL_TEXT_MIN_CHARS:
|
|
1098
|
+
body = _fetch_body(url)
|
|
1099
|
+
if body and body.strip() and len(body) > len(text or ''):
|
|
1100
|
+
text = readable_body(body)[:limit]
|
|
1101
|
+
fetched += 1
|
|
1102
|
+
item['fetchedFromUrl'] = True
|
|
1103
|
+
item['fullText'] = text
|
|
1104
|
+
if path:
|
|
1105
|
+
item['sourceFile'] = path.replace('\\', '/')
|
|
1106
|
+
if url:
|
|
1107
|
+
item['url'] = url
|
|
1108
|
+
return results, missing, fetched
|
|
1109
|
+
|
|
1110
|
+
|
|
584
1111
|
def search(query: str, api_key: str, top_k: int = 10, rerank_results: bool = True,
|
|
585
|
-
line: str = 'all'
|
|
1112
|
+
line: str = 'all', hybrid: bool = True, from_source: str = None,
|
|
1113
|
+
rerank_pool: int = RERANK_CANDIDATES,
|
|
1114
|
+
dedup: bool = True, with_text: int = 0,
|
|
1115
|
+
fetch_missing: int = 0, mmr: bool = False):
|
|
586
1116
|
"""Semantic search with keyword fallback.
|
|
587
1117
|
|
|
588
1118
|
`line` 在**取候选之前**就把不属于这条线的记录挡掉(把它们的相似度压成 -inf 再
|
|
@@ -600,30 +1130,69 @@ def search(query: str, api_key: str, top_k: int = 10, rerank_results: bool = Tru
|
|
|
600
1130
|
np = require_numpy()
|
|
601
1131
|
vectors = np.load(VECTORS_FILE)
|
|
602
1132
|
meta = json.loads(META_FILE.read_text(encoding='utf-8'))
|
|
1133
|
+
# meta 与 vectors 的行数必须一致 —— 中途被杀、或两份文件新旧不一,都可能不一样。
|
|
1134
|
+
# 不一致时**按少的那个截断**:宁可少几条结果,也不能让下标越界,因为越界会被
|
|
1135
|
+
# 下面那个 `except: pass` 变成"静默退回关键词",那是最难看出来的一种失效。
|
|
1136
|
+
rows = min(len(meta), int(vectors.shape[0]))
|
|
1137
|
+
if rows < len(meta):
|
|
1138
|
+
meta = meta[:rows]
|
|
1139
|
+
elif rows < int(vectors.shape[0]):
|
|
1140
|
+
vectors = vectors[:rows]
|
|
603
1141
|
query_embeddings = get_embeddings([query], api_key)
|
|
604
1142
|
if query_embeddings and not all(v == 0 for v in query_embeddings[0]):
|
|
605
1143
|
query_vec = np.array(query_embeddings[0], dtype=np.float32)
|
|
606
1144
|
query_vec = query_vec / np.linalg.norm(query_vec)
|
|
607
1145
|
similarities = np.dot(vectors, query_vec)
|
|
608
|
-
|
|
609
|
-
|
|
1146
|
+
# 关键词那一侧要用**同一个**掩码(`--line wiki` 时聊天不许从这条路上混进来)
|
|
1147
|
+
in_scope_mask = None
|
|
1148
|
+
if types is not None or from_source:
|
|
1149
|
+
in_scope = np.array([True if types is None else str(m.get('type', '')) in types
|
|
1150
|
+
for m in meta], dtype=bool)
|
|
1151
|
+
if from_source:
|
|
1152
|
+
# **按公众号查是属性查询,不该指望向量。** 号名只住在 `id`(文件名
|
|
1153
|
+
# `<公众号>-<标题>.md`)里:2026-10-15 修正文提取时,`source:` 随 YAML 头
|
|
1154
|
+
# 一起离开了嵌入文本。实测(窗口 2026-09-12..10-13):红星新闻 908 篇,
|
|
1155
|
+
# 语义前 10 只回 3 条;这里按 id 精确筛,能筛出全部。
|
|
1156
|
+
want = from_source.strip().lower()
|
|
1157
|
+
by_id = np.array([want in str(m.get('id', '')).lower()
|
|
1158
|
+
or str(m.get('source', '')).lower() == want
|
|
1159
|
+
for m in meta], dtype=bool)
|
|
1160
|
+
in_scope = in_scope & by_id
|
|
610
1161
|
if not in_scope.any():
|
|
611
|
-
#
|
|
1162
|
+
# 一条都没有:**说出来**(是这条线/这个号在索引里没有记录,不是"没命中")
|
|
612
1163
|
return []
|
|
613
1164
|
similarities = np.where(in_scope, similarities, -np.inf)
|
|
1165
|
+
in_scope_mask = in_scope
|
|
614
1166
|
# 先取一个比重排池更大的候选集,重排才有可以换的空间。
|
|
615
|
-
pool_size = max(top_k,
|
|
1167
|
+
pool_size = max(top_k, rerank_pool) if rerank_results else top_k
|
|
616
1168
|
if types is not None:
|
|
617
1169
|
pool_size = min(pool_size, int(in_scope.sum()))
|
|
618
|
-
|
|
1170
|
+
vec_order = [int(i) for i in np.argsort(similarities)[::-1][:RRF_POOL]]
|
|
1171
|
+
vec_order = [i for i in vec_order if np.isfinite(similarities[i])]
|
|
1172
|
+
if hybrid:
|
|
1173
|
+
# 关键词那一侧走**同一个掩码**:`--line wiki` 时聊天记录不许从这条路上混进来。
|
|
1174
|
+
kw_order = [i for i, _s in keyword_rank(query, meta, in_scope_mask,
|
|
1175
|
+
rare_ratio=HYBRID_RARE_RATIO,
|
|
1176
|
+
score_ratio=HYBRID_SCORE_RATIO)]
|
|
1177
|
+
top_indices = [i for i, _s in rrf_fuse([vec_order, kw_order])[:pool_size]]
|
|
1178
|
+
else:
|
|
1179
|
+
top_indices = vec_order[:pool_size]
|
|
619
1180
|
results = []
|
|
620
1181
|
for idx in top_indices:
|
|
621
|
-
if not np.isfinite(similarities[idx]):
|
|
622
|
-
continue # 掩码挡掉的(-inf)不许混进结果
|
|
623
1182
|
item = meta[idx]
|
|
624
|
-
results.append({**item, 'score': float(similarities[idx])})
|
|
1183
|
+
results.append({**item, '_row': int(idx), 'score': float(similarities[idx])})
|
|
625
1184
|
if rerank_results:
|
|
626
|
-
results =
|
|
1185
|
+
results = rerank_dispatch(query, results, create_client(),
|
|
1186
|
+
pool_size=rerank_pool)
|
|
1187
|
+
if dedup:
|
|
1188
|
+
results = dedup_results(results)
|
|
1189
|
+
if with_text:
|
|
1190
|
+
results, _missing, _fetched = attach_full_text(results, with_text,
|
|
1191
|
+
fetch_missing=fetch_missing)
|
|
1192
|
+
if mmr:
|
|
1193
|
+
results = mmr_select(results, vectors, top_k)
|
|
1194
|
+
for _item in results: # `_row` 只是内部用的,别漏进输出
|
|
1195
|
+
_item.pop('_row', None)
|
|
627
1196
|
return results[:top_k]
|
|
628
1197
|
except:
|
|
629
1198
|
pass
|
|
@@ -633,7 +1202,17 @@ def search(query: str, api_key: str, top_k: int = 10, rerank_results: bool = Tru
|
|
|
633
1202
|
line=line)
|
|
634
1203
|
if rerank_results:
|
|
635
1204
|
# 关键词打分比余弦更粗,重排在这里的收益反而更大。
|
|
636
|
-
|
|
1205
|
+
results = rerank_dispatch(query, results, create_client(),
|
|
1206
|
+
pool_size=rerank_pool)
|
|
1207
|
+
if dedup:
|
|
1208
|
+
results = dedup_results(results)
|
|
1209
|
+
if with_text:
|
|
1210
|
+
results, _missing, _fetched = attach_full_text(results, with_text,
|
|
1211
|
+
fetch_missing=fetch_missing)
|
|
1212
|
+
if mmr:
|
|
1213
|
+
results = mmr_select(results, vectors, top_k)
|
|
1214
|
+
for _item in results: # `_row` 只是内部用的,别漏进输出
|
|
1215
|
+
_item.pop('_row', None)
|
|
637
1216
|
return results[:top_k]
|
|
638
1217
|
|
|
639
1218
|
|
|
@@ -699,6 +1278,24 @@ def main():
|
|
|
699
1278
|
p.add_argument('--top-k', type=int, default=10)
|
|
700
1279
|
p.add_argument('--no-rerank', action='store_true',
|
|
701
1280
|
help='不调用决策模型重排,只按向量/关键词相似度返回(回退到引入重排之前)')
|
|
1281
|
+
p.add_argument('--mmr', action='store_true',
|
|
1282
|
+
help='对最终结果做 MMR 去冗余(λ=0.7):压掉近似的同领域邻居,可能牺牲单点召回')
|
|
1283
|
+
p.add_argument('--fetch-missing', nargs='?', type=int, const=3, default=0,
|
|
1284
|
+
metavar='N',
|
|
1285
|
+
help='正文短于 300 字的那些按 url 抓一次(最多 N 条,默认 3);复用日报的缓存')
|
|
1286
|
+
p.add_argument('--with-text', nargs='?', type=int, const=FULL_TEXT_LIMIT,
|
|
1287
|
+
default=0, metavar='N',
|
|
1288
|
+
help='给每条命中挂上本地正文字段 fullText/sourceFile/url(默认不给;'
|
|
1289
|
+
'N 控制每条最多多少字,默认 %d)' % FULL_TEXT_LIMIT)
|
|
1290
|
+
p.add_argument('--no-dedup', action='store_true',
|
|
1291
|
+
help='不折叠同文本的多号转载(默认折叠,并在结果里给 duplicateCount)')
|
|
1292
|
+
p.add_argument('--rerank-pool', type=int, default=RERANK_CANDIDATES,
|
|
1293
|
+
help='重排看多少条候选(默认 %d;池子越大越准、越贵)' % RERANK_CANDIDATES)
|
|
1294
|
+
p.add_argument('--no-hybrid', action='store_true',
|
|
1295
|
+
help='不融合本地 BM25 名次(默认融合:加门控后实测三个台子都只增不减)')
|
|
1296
|
+
p.add_argument('--from', dest='from_source',
|
|
1297
|
+
help='只查某个公众号(按 id/文件名的子串匹配,不走语义)')
|
|
1298
|
+
|
|
702
1299
|
add_line_arg(p)
|
|
703
1300
|
|
|
704
1301
|
args = parser.parse_args()
|
|
@@ -723,18 +1320,34 @@ def main():
|
|
|
723
1320
|
# 空成一片时读的人分不清"没命中"和"这条线根本没进索引"——而两者的下一步
|
|
724
1321
|
# (换个词 / 重建索引)是两回事。
|
|
725
1322
|
rows = search(query, api_key, top_k=args.top_k,
|
|
726
|
-
rerank_results=not args.no_rerank, line=args.line
|
|
1323
|
+
rerank_results=not args.no_rerank, line=args.line,
|
|
1324
|
+
hybrid=not args.no_hybrid, from_source=args.from_source,
|
|
1325
|
+
rerank_pool=args.rerank_pool,
|
|
1326
|
+
dedup=not args.no_dedup,
|
|
1327
|
+
with_text=args.with_text,
|
|
1328
|
+
fetch_missing=args.fetch_missing,
|
|
1329
|
+
mmr=args.mmr)
|
|
727
1330
|
counts = line_counts()
|
|
728
1331
|
result = {
|
|
729
1332
|
'success': True,
|
|
730
1333
|
'line': args.line,
|
|
731
1334
|
'lineLabel': line_label(args.line),
|
|
1335
|
+
'hybrid': not args.no_hybrid,
|
|
1336
|
+
'rerank': dict(RERANK_STATE),
|
|
1337
|
+
'from': args.from_source,
|
|
1338
|
+
'dedup': not args.no_dedup,
|
|
1339
|
+
'withText': args.with_text,
|
|
1340
|
+
'fetchMissing': args.fetch_missing,
|
|
1341
|
+
'mmr': args.mmr,
|
|
732
1342
|
'results': rows,
|
|
733
1343
|
'indexCounts': counts,
|
|
734
1344
|
}
|
|
735
1345
|
if not rows and counts['all'] == 0:
|
|
736
1346
|
result['note'] = ('语义索引还没建(output/.semantic_index 不存在)——'
|
|
737
1347
|
'先跑 weflow-cli search-index')
|
|
1348
|
+
elif not rows and args.from_source:
|
|
1349
|
+
result['note'] = ('索引里没有「%s」的记录 —— 按号筛的是文件名里的号名,'
|
|
1350
|
+
'也可能是建索引那天的窗口没覆盖到它' % args.from_source)
|
|
738
1351
|
elif not rows and args.line != 'all' and counts[args.line] == 0:
|
|
739
1352
|
result['note'] = ('索引里一条「%s」的记录都没有(建索引那天的窗口可能没覆盖到),'
|
|
740
1353
|
'先看 --line all' % line_label(args.line))
|