weflow-cli 1.11.1 → 1.13.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 +378 -0
- package/OPERATIONS.md +359 -11
- package/README.md +4 -0
- package/bin/weflow-cli.ts +289 -37
- package/dist/bin/weflow-cli.js +300 -35
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +5 -0
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +12 -1
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +23 -3
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/core/sqlcipherCore.d.ts +8 -0
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
- package/dist/src/core/sqlcipherCore.js +46 -11
- package/dist/src/core/sqlcipherCore.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/configService.d.ts +3 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +6 -3
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/diagnosticReport.d.ts +80 -0
- package/dist/src/services/diagnosticReport.d.ts.map +1 -0
- package/dist/src/services/diagnosticReport.js +191 -0
- package/dist/src/services/diagnosticReport.js.map +1 -0
- 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/dist/src/services/wechatClient.d.ts +9 -3
- package/dist/src/services/wechatClient.d.ts.map +1 -1
- package/dist/src/services/wechatClient.js +55 -22
- package/dist/src/services/wechatClient.js.map +1 -1
- package/docs/DECISIONS.md +662 -1
- package/docs/MCP.md +1 -1
- package/docs/NEXT_ACTIONS.md +236 -0
- package/docs/POSITIONING.md +68 -0
- package/docs/PROJECT_STATE.md +119 -17
- package/docs/RELEASING.md +14 -0
- package/package.json +88 -86
- package/scripts/_utils.py +120 -4
- package/scripts/backfill_articles.py +64 -14
- package/scripts/biz_daily.py +118 -11
- package/scripts/daily_task.ps1 +132 -0
- package/scripts/export_chat_html.py +79 -31
- package/scripts/install_tasks.cmd +32 -0
- package/scripts/install_tasks.ps1 +91 -0
- package/scripts/jev_client.py +335 -3
- package/scripts/nt_decrypt.py +79 -14
- package/scripts/pipeline.py +21 -0
- package/scripts/recall_eval.py +247 -0
- package/scripts/repro_customer_init.cmd +86 -0
- package/scripts/semantic_search.py +926 -51
- package/src/core/dbPathService.ts +5 -0
- package/src/core/ntCore.ts +34 -3
- package/src/core/sqlcipherCore.ts +47 -11
- package/src/services/assistantEval.ts +3 -1
- package/src/services/assistantTools.ts +2471 -2452
- package/src/services/configService.ts +12 -3
- package/src/services/diagnosticReport.ts +237 -0
- package/src/services/exportService.ts +14 -2
- package/src/services/wechatClient.ts +418 -380
|
@@ -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,14 @@ 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
|
+
# 索引这一路**只用日报 md 那份**(`## 正文` 标记优先,否则按真实结构提取)。
|
|
348
|
+
# 2026-10-16 试过改成「与正文缓存取更长的那份」,实测**没有增益**(DECISIONS D-118):
|
|
349
|
+
# 100 条自造题里唯一中立的一组(标题)持平;两组碎片题各自跟着「题是从谁的文本里
|
|
350
|
+
# 抠的」走,方向相反 —— 自造题裁判不了这件事。正文缓存留给 `--with-text`,索引不吃它。
|
|
351
|
+
body = ('\n'.join(lines[body_start:body_start + 50]) if body_start
|
|
352
|
+
else extract_article_body(content))
|
|
295
353
|
if title:
|
|
296
354
|
items.append({
|
|
297
355
|
"id": f"article:{f.relative_to(daily_dir)}",
|
|
@@ -309,6 +367,35 @@ def collect_articles(article_days=30):
|
|
|
309
367
|
|
|
310
368
|
# ====== Index Operations ======
|
|
311
369
|
|
|
370
|
+
def _save_index(base_vectors, base_meta, pairs):
|
|
371
|
+
"""把「旧索引 + 这一批已经嵌好的」写出去,供中途落盘与收尾共用。
|
|
372
|
+
|
|
373
|
+
两处讲究都是实测出来的:
|
|
374
|
+
· **先写向量、再写 meta,各自"临时文件 + replace"**。两步之间被杀时,宁可 meta 少一条,
|
|
375
|
+
也不能让它多一条 —— 多出来的那条指向不存在的向量行,检索时 argsort 的下标会越界,
|
|
376
|
+
而 `search()` 里那个 `except: pass` 会把它变成"静默退回关键词",那是最难看出来的一种。
|
|
377
|
+
· 向量**逐行**归一化后再存(原来是整个矩阵一次性归一化)。归一化本来就是逐行的,
|
|
378
|
+
重复做结果不变,所以中途落盘不会改变最终结果。
|
|
379
|
+
"""
|
|
380
|
+
np = require_numpy()
|
|
381
|
+
if pairs:
|
|
382
|
+
new_vectors = np.array([v for _item, v in pairs], dtype=np.float32)
|
|
383
|
+
else:
|
|
384
|
+
new_vectors = np.empty((0, EMBEDDING_DIM), dtype=np.float32)
|
|
385
|
+
vectors = np.vstack([base_vectors, new_vectors]) if base_vectors.size else new_vectors
|
|
386
|
+
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
|
|
387
|
+
norms[norms == 0] = 1 # Avoid division by zero
|
|
388
|
+
vectors = vectors / norms
|
|
389
|
+
meta = list(base_meta) + [item for item, _v in pairs]
|
|
390
|
+
|
|
391
|
+
tmp_v = VECTORS_FILE.with_name(VECTORS_FILE.name + '.tmp.npy')
|
|
392
|
+
np.save(tmp_v, vectors)
|
|
393
|
+
os.replace(tmp_v, VECTORS_FILE)
|
|
394
|
+
tmp_m = META_FILE.with_name(META_FILE.name + '.tmp')
|
|
395
|
+
tmp_m.write_text(json.dumps(meta, ensure_ascii=False), encoding='utf-8')
|
|
396
|
+
os.replace(tmp_m, META_FILE)
|
|
397
|
+
return len(meta)
|
|
398
|
+
|
|
312
399
|
def build_index(api_key: str, full: bool = False, days: int = 90, article_days: int = 30):
|
|
313
400
|
"""Build or update the semantic index.
|
|
314
401
|
|
|
@@ -385,41 +472,56 @@ def build_index(api_key: str, full: bool = False, days: int = 90, article_days:
|
|
|
385
472
|
"chatDays": days, "articleDays": article_days,
|
|
386
473
|
"shardsFailed": shards_failed}
|
|
387
474
|
|
|
388
|
-
# Generate embeddings
|
|
389
|
-
print("生成 embeddings...", file=sys.stderr)
|
|
390
475
|
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}
|
|
476
|
+
total_new = len(texts)
|
|
477
|
+
print(f"生成 embeddings...({total_new} 条,共 {-(-total_new // BATCH_SIZE)} 批)", file=sys.stderr)
|
|
396
478
|
|
|
397
|
-
#
|
|
479
|
+
# 旧索引先读进来:增量时它是底,中途落盘与收尾都要以它开头。
|
|
398
480
|
if VECTORS_FILE.exists() and not full:
|
|
399
|
-
|
|
400
|
-
|
|
481
|
+
base_vectors = np.load(VECTORS_FILE)
|
|
482
|
+
base_meta = json.loads(META_FILE.read_text(encoding='utf-8'))
|
|
401
483
|
else:
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
#
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
484
|
+
base_vectors = np.empty((0, EMBEDDING_DIM), dtype=np.float32)
|
|
485
|
+
base_meta = []
|
|
486
|
+
|
|
487
|
+
ok_pairs = [] # [(item, vector)] —— 真嵌出来的才配进索引
|
|
488
|
+
state = {'failed': 0, 'flushed': 0}
|
|
489
|
+
|
|
490
|
+
def on_batch(done, total, batch_embs):
|
|
491
|
+
"""边嵌边落盘,并且把"全零"那批(请求失败)挑出来**不写**。
|
|
492
|
+
|
|
493
|
+
两件事都是实测踩出来的:
|
|
494
|
+
· 这个索引要跑二十分钟以上,而 CLI 给子进程的超时是五分钟 —— 攒到最后才写就是
|
|
495
|
+
"超时/被杀等于全丢";每 CHECKPOINT_BATCHES 批落一次盘,被杀也只丢最后那几千条,
|
|
496
|
+
而且重跑 `update` 会从断点接着来(已落盘的 id 就在 meta 里)。
|
|
497
|
+
· 失败批次拿到的是全零向量。照旧写进去的话,那些 id 会永久躺在索引里**再也不会被重嵌**
|
|
498
|
+
(增量只嵌新 id)—— 索引被安静地投毒。所以它们不写,留到下次重跑。
|
|
499
|
+
"""
|
|
500
|
+
start = done - len(batch_embs)
|
|
501
|
+
for k, emb in enumerate(batch_embs):
|
|
502
|
+
if any(emb):
|
|
503
|
+
ok_pairs.append((new_items[start + k], emb))
|
|
504
|
+
else:
|
|
505
|
+
state['failed'] += 1
|
|
506
|
+
if done >= total or done - state['flushed'] >= CHECKPOINT_BATCHES * BATCH_SIZE:
|
|
507
|
+
_save_index(base_vectors, base_meta, ok_pairs)
|
|
508
|
+
state['flushed'] = done
|
|
509
|
+
print(f"[索引] 已嵌入 {done}/{total}(落盘 {len(ok_pairs)} 条,"
|
|
510
|
+
f"失败 {state['failed']} 条)", file=sys.stderr)
|
|
511
|
+
|
|
512
|
+
get_embeddings(texts, api_key, on_batch=on_batch)
|
|
513
|
+
|
|
514
|
+
if not ok_pairs:
|
|
515
|
+
return {"error": "Embedding 生成失败,请检查 API key",
|
|
516
|
+
"embedFailed": state['failed'],
|
|
517
|
+
"chatDays": days, "articleDays": article_days}
|
|
418
518
|
|
|
419
519
|
return {
|
|
420
520
|
"status": "success",
|
|
421
|
-
"total": len(
|
|
422
|
-
"new": len(
|
|
521
|
+
"total": len(base_meta) + len(ok_pairs),
|
|
522
|
+
"new": len(ok_pairs),
|
|
523
|
+
# 嵌不出来的那几个**要说出来**:它们既不在索引里,也是下次重跑的输入
|
|
524
|
+
"embedFailed": state['failed'],
|
|
423
525
|
"chatDays": days,
|
|
424
526
|
"articleDays": article_days,
|
|
425
527
|
# 分片读不全时**要报出来**:`shardsFailed` 是"全读了"与"读了能读到的"之差,
|
|
@@ -494,7 +596,14 @@ def keyword_search(query: str, top_k: int = 10, root=None, line: str = 'all'):
|
|
|
494
596
|
|
|
495
597
|
# 送进重排的候选数。检索是粗筛("语义相近"),重排是细判("真的回答了问题吗"),
|
|
496
598
|
# 所以池子要比 top_k 大,才有可换的空间。
|
|
497
|
-
|
|
599
|
+
# 送进重排的候选数。**2026-10-16 实测把默认从 20 提到 100**:同一批 40 题、只改池子
|
|
600
|
+
# (POOL_CURVE = 20 / 50 / 100 / 200):
|
|
601
|
+
# recall@10 0.82 / 0.88 / 0.95 / 0.95 hit@1 0.65 / 0.68 / 0.72 / 0.75
|
|
602
|
+
# MRR@10 0.72 / 0.75 / 0.80 / 0.82 nDCG@10 0.75 / 0.78 / 0.83 / 0.85
|
|
603
|
+
# 最弱的"碎片/文章"那格 recall@10:0.70 → 0.80 → **1.00** → 1.00。到 100 就饱和,
|
|
604
|
+
# 200 的增益是噪声级。代价是每次重排的提示从 ~2.5k tokens 涨到 ~20k(约 ¥0.01/次),
|
|
605
|
+
# 所以池子做成可调(`--rerank-pool`),嫌贵就调小。
|
|
606
|
+
RERANK_CANDIDATES = 100
|
|
498
607
|
RERANK_EXCERPT_CHARS = 300
|
|
499
608
|
|
|
500
609
|
|
|
@@ -513,6 +622,126 @@ def build_rerank_questions(count):
|
|
|
513
622
|
}
|
|
514
623
|
|
|
515
624
|
|
|
625
|
+
# 这一趟重排是谁排的、有没有降级 —— 必须能带进输出。
|
|
626
|
+
# 原来的写法里"重排失败"只往 stderr 打一行 WARN,而 CLI 不转发子进程 stderr,
|
|
627
|
+
# 于是"没报错"会被读成"重排过了"。状态放这里,主流程把它塞进 JSON。
|
|
628
|
+
RERANK_STATE = {'provider': None, 'degraded': False, 'error': None, 'scored': 0}
|
|
629
|
+
# 向量那条腿这次到底跑了没有、没跑是为什么、以及顺手算出的每线条数(省一次 13.5 MB 的解析)。
|
|
630
|
+
# 与 `RERANK_STATE` 同一个形状:`search()` 开头重置、`main()` 把它带进返回的 envelope。
|
|
631
|
+
#
|
|
632
|
+
# 为什么要这个:`search()` 原来有一个**裸 `except: pass`**,于是"向量路径任何异常"都会变成
|
|
633
|
+
# "静默只搜关键词",而返回里照样写着 `hybrid: true` —— 这是最难看出来的一种失效,
|
|
634
|
+
# 也让所有"快慢/好坏"的对比都可能落在不同路径上(2026-10-18 审阅时确认)。
|
|
635
|
+
VECTOR_STATE = {'used': False, 'fallback': None, 'counts': None}
|
|
636
|
+
|
|
637
|
+
_RERANK_LLM_RUBRIC = (
|
|
638
|
+
'给每条候选打 0-100 分,判据是**它是否直接回答了问题**("话题相近"不算):'
|
|
639
|
+
'100 = 直接给出所需信息;60-80 = 含大部分相关信息;30-50 = 只沾边;0-20 = 无关。'
|
|
640
|
+
'每条候选都必须有分数。只输出 JSON:{"scores":[{"i":1,"score":85},{"i":2,"score":20}]}。')
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def cleanse_passage(text, limit=RERANK_EXCERPT_CHARS):
|
|
644
|
+
"""送进重排前的段落清洗(照 WeKnora 的 passage cleansing)。
|
|
645
|
+
|
|
646
|
+
不清洗的话,模型有相当一部分注意力花在 `**加粗**`、公众号套话、作者署名、"点击蓝字"这些噪声上,
|
|
647
|
+
而候选摘录本来就只有 300 字。清洗是纯文本操作,不改判据。
|
|
648
|
+
"""
|
|
649
|
+
s = text or ''
|
|
650
|
+
s = re.sub(r'!\[[^\]]*\]\([^)]*\)', ' ', s) # 图片
|
|
651
|
+
s = re.sub(r'\[([^\]]{0,40})\]\([^)]*\)', r'\1', s) # 链接留文字
|
|
652
|
+
s = re.sub(r'\*{1,3}', '', s) # 加粗/斜体标记
|
|
653
|
+
s = re.sub(r'(点击蓝字|关注我们|阅读原文|继续滑动看下一个|轻触阅读原文|设为星标)', ' ', s)
|
|
654
|
+
s = re.sub(r'^\s*原创\s+\S+.*$', ' ', s, flags=re.M) # 署名行
|
|
655
|
+
s = re.sub(r'\s+', ' ', s)
|
|
656
|
+
return s.strip()[:limit]
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def llm_rerank(query, results, pool_size=RERANK_CANDIDATES, key=None, model=None):
|
|
660
|
+
"""用**已配置的对话模型**(OpenAI 兼容)重排。
|
|
661
|
+
|
|
662
|
+
为什么要有它:这条管线本来是"召回 + 重排"两段,而重排在本机一直是**空转**的
|
|
663
|
+
(Typesafe 返 `HTTP 451 not available in your region`),所以排序只有一段。
|
|
664
|
+
契约与 Jev 那版同构:一次请求给全部候选打分;校验不过就**保持原序并把降级说出来**。
|
|
665
|
+
"""
|
|
666
|
+
if len(results) < 2:
|
|
667
|
+
return results
|
|
668
|
+
try:
|
|
669
|
+
cfg = load_config()
|
|
670
|
+
key = key or get_api_key(cfg) or cfg.get('deepseekApiKey') or ''
|
|
671
|
+
model = model or cfg.get('aiModel') or 'deepseek-chat'
|
|
672
|
+
except Exception as error: # noqa: BLE001
|
|
673
|
+
# 读不到配置(这台机器可能还没有 ~/.weflow-cli/config.json)也要**保持原序**:
|
|
674
|
+
# 重排不该让检索失败,更不能一路抛到 `search()` 的 `except: pass` 里
|
|
675
|
+
# —— 那会**静默退回关键词检索**,最难看出来的一种失效。
|
|
676
|
+
print(' [WARN] 重排读不到配置(%s),保持原顺序' % type(error).__name__,
|
|
677
|
+
file=sys.stderr)
|
|
678
|
+
RERANK_STATE.update(provider='llm', degraded=True,
|
|
679
|
+
error='config-unreadable', scored=0)
|
|
680
|
+
return results
|
|
681
|
+
if not key:
|
|
682
|
+
RERANK_STATE.update(provider='llm', degraded=True, error='no-chat-key', scored=0)
|
|
683
|
+
return results
|
|
684
|
+
|
|
685
|
+
pool = results[:pool_size]
|
|
686
|
+
lines = []
|
|
687
|
+
for i, item in enumerate(pool):
|
|
688
|
+
lines.append('【候选%d】%s|%s' % (i + 1, item.get('title') or item.get('source') or '',
|
|
689
|
+
cleanse_passage(item.get('text') or '')))
|
|
690
|
+
payload = {
|
|
691
|
+
'model': model,
|
|
692
|
+
'messages': [{'role': 'user', 'content': '问题:%s\n\n候选:\n%s\n\n%s'
|
|
693
|
+
% (query, chr(10).join(lines), _RERANK_LLM_RUBRIC)}],
|
|
694
|
+
'response_format': {'type': 'json_object'}, 'temperature': 0,
|
|
695
|
+
}
|
|
696
|
+
request = urllib.request.Request('https://api.deepseek.com/v1/chat/completions',
|
|
697
|
+
data=json.dumps(payload, ensure_ascii=False).encode('utf-8'),
|
|
698
|
+
headers={'Authorization': 'Bearer %s' % key,
|
|
699
|
+
'Content-Type': 'application/json'})
|
|
700
|
+
try:
|
|
701
|
+
with urllib.request.urlopen(request, timeout=120) as resp:
|
|
702
|
+
data = json.loads(resp.read().decode('utf-8'))
|
|
703
|
+
scores = {int(row['i']): float(row['score'])
|
|
704
|
+
for row in json.loads(data['choices'][0]['message']['content'])['scores']}
|
|
705
|
+
except Exception as error: # noqa: BLE001 —— 重排不该让检索失败
|
|
706
|
+
print(' [WARN] 重排(%s)失败:%s: %s —— 保持原顺序'
|
|
707
|
+
% (model, type(error).__name__, error), file=sys.stderr)
|
|
708
|
+
RERANK_STATE.update(provider='llm', degraded=True,
|
|
709
|
+
error=type(error).__name__, scored=0)
|
|
710
|
+
return results
|
|
711
|
+
|
|
712
|
+
if set(scores) != set(range(1, len(pool) + 1)):
|
|
713
|
+
# 编号不齐 = 模型把候选搞混了 —— 这正是重排最危险、最不容易察觉的失效方式,宁可原序
|
|
714
|
+
print(' [WARN] 重排返回的候选编号不齐(%d/%d),保持原顺序'
|
|
715
|
+
% (len(scores), len(pool)), file=sys.stderr)
|
|
716
|
+
RERANK_STATE.update(provider='llm', degraded=True, error='incomplete-scores', scored=0)
|
|
717
|
+
return results
|
|
718
|
+
|
|
719
|
+
order = sorted(range(len(pool)), key=lambda i: (-scores[i + 1], i))
|
|
720
|
+
ranked = []
|
|
721
|
+
for i in order:
|
|
722
|
+
item = dict(pool[i])
|
|
723
|
+
item['rerankScore'] = scores[i + 1] # `score` 保持原义,新的放这里
|
|
724
|
+
ranked.append(item)
|
|
725
|
+
RERANK_STATE.update(provider='llm', degraded=False, error=None, scored=len(ranked))
|
|
726
|
+
return ranked + list(results[pool_size:])
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def rerank_dispatch(query, results, client=None, pool_size=RERANK_CANDIDATES):
|
|
730
|
+
"""先按原契约(Jev/Typesafe)重排,不可用或失败则退到已配置的对话模型。
|
|
731
|
+
|
|
732
|
+
两个都失败时返回原序,并把 `RERANK_STATE` 留在"降级"上 —— 别让"没重排"看起来像"重排过了"。
|
|
733
|
+
"""
|
|
734
|
+
RERANK_STATE.update(provider=None, degraded=False, error=None, scored=0, transport=None)
|
|
735
|
+
if client is not None:
|
|
736
|
+
out = rerank(query, results, client, pool_size=pool_size)
|
|
737
|
+
# **`provider` 记的是"哪套契约"**(`typesafe`),不是"谁在服务":`judgeProvider=chat` 时
|
|
738
|
+
# 契约是由 DeepSeek 类客户端满足的,只看 `provider` 会读成"原生通了"(2026-10-18 实测踩到:
|
|
739
|
+
# 同一批查询里有时 provider=typesafe 其实走的是 deepseek-flash)。所以再记一个 `transport`。
|
|
740
|
+
RERANK_STATE['transport'] = getattr(client, 'provider', 'typesafe')
|
|
741
|
+
if not RERANK_STATE['degraded'] and RERANK_STATE['scored']:
|
|
742
|
+
return out
|
|
743
|
+
RERANK_STATE['transport'] = 'llm'
|
|
744
|
+
return llm_rerank(query, results, pool_size=pool_size)
|
|
516
745
|
def rerank(query, results, client=None, pool_size=RERANK_CANDIDATES):
|
|
517
746
|
"""用**一次**决策请求给检索结果重排。
|
|
518
747
|
|
|
@@ -548,6 +777,8 @@ def rerank(query, results, client=None, pool_size=RERANK_CANDIDATES):
|
|
|
548
777
|
# `file=sys.stderr`:`--json` 时 stdout 是机器在解析的那一份(同文件其它 WARN 早就这样)
|
|
549
778
|
print(' [WARN] 重排失败(%s),保持原检索顺序:%s' % (type(error).__name__, error),
|
|
550
779
|
file=sys.stderr)
|
|
780
|
+
RERANK_STATE.update(provider='typesafe', degraded=True,
|
|
781
|
+
error=type(error).__name__, scored=0)
|
|
551
782
|
return results
|
|
552
783
|
|
|
553
784
|
scored = []
|
|
@@ -561,6 +792,8 @@ def rerank(query, results, client=None, pool_size=RERANK_CANDIDATES):
|
|
|
561
792
|
|
|
562
793
|
if all(value is None for value, _i, _item in scored):
|
|
563
794
|
print(' [WARN] 重排没有返回任何可用分数,保持原检索顺序', file=sys.stderr)
|
|
795
|
+
RERANK_STATE.update(provider='typesafe', degraded=True,
|
|
796
|
+
error='no-usable-scores', scored=0)
|
|
564
797
|
return results
|
|
565
798
|
|
|
566
799
|
# 没拿到分数的按原序垫在最后,而不是当成 0 分——"未知"和"不相关"不是一回事。
|
|
@@ -578,11 +811,501 @@ def rerank(query, results, client=None, pool_size=RERANK_CANDIDATES):
|
|
|
578
811
|
entry['rerankScore'] = round(value, 3)
|
|
579
812
|
ordered.append(entry)
|
|
580
813
|
ordered.extend(results[pool_size:])
|
|
814
|
+
RERANK_STATE.update(provider='typesafe', degraded=False, error=None,
|
|
815
|
+
scored=len(ordered))
|
|
581
816
|
return ordered
|
|
582
817
|
|
|
583
818
|
|
|
819
|
+
# 混合检索:向量名次 + 本地关键词名次(BM25),用 RRF 合成。纯本地、不联网、不加依赖。
|
|
820
|
+
#
|
|
821
|
+
# **默认开**,`--no-hybrid` 关 —— 这个默认值是分两步量出来的(都记在 D-110 / D-111):
|
|
822
|
+
# · 第一步(2026-10-15 上午):**无门控**的 RRF 单独用是**有害**的。真实问句 5 条里只在 1 条上更好、
|
|
823
|
+
# 2 条更差;逐字碎片 16 条上 BM25 单独 16/16、RRF 只有 12/16。原因是 RRF 给两侧同等票权,而中文
|
|
824
|
+
# 问句里的虚词(怎么用/有没有/的)会把 BM25 侧变成纯噪声。当时结论:默认关。
|
|
825
|
+
# · 第二步(同日下午,加了门控):只让"命中稀有词、且分数接近最高分"的文档投票;问句里**没有稀有词
|
|
826
|
+
# 就整个弃权**。同一批题(frag 40 / title 40 / 手写问句 12)重测:
|
|
827
|
+
# 碎片 recall@10 0.91 → **1.00**,@5 0.89 → 0.97,hit@1 0.60 → **0.80**
|
|
828
|
+
# 标题 recall@10 0.97(不变),@5 0.91 → 0.94,hit@1 0.83 → 0.86
|
|
829
|
+
# 手写问句 三档全不变(0.75/0.75/0.75)
|
|
830
|
+
# **三个台子都不掉**,于是翻成默认。无门控版同一批题零增益(0.91/0.60,与纯向量相同)——
|
|
831
|
+
# 也就是说:让关键词侧起作用的是**门控**,不是融合本身。
|
|
832
|
+
# · 阈值敏感性(同一批题):宽松 (0.10/0.3) 弱一点(碎片 @5 0.94),紧的 (0.02/0.7) 略好
|
|
833
|
+
# (@5 1.00)—— 平台是平的,说明这个数字不承重;取中间值,且**不许**再拿这一批题去微调它。
|
|
834
|
+
RRF_K = 60 # RRF 的平滑常数:名次差在几十名以内才有区分度(教科书默认值)
|
|
835
|
+
RRF_POOL = 200 # 两侧各取前多少名进融合;再往后的名次贡献 1/(60+200) 已经可以忽略
|
|
836
|
+
BM25_K1 = 1.2
|
|
837
|
+
BM25_B = 0.75
|
|
838
|
+
|
|
839
|
+
# 门控用:稀有词阈值(词出现在 ≤5% 的在范围文档里才算"稀有")与分数相对阈
|
|
840
|
+
HYBRID_RARE_RATIO = 0.05
|
|
841
|
+
HYBRID_SCORE_RATIO = 0.5
|
|
842
|
+
|
|
843
|
+
_CJK_RUN = re.compile(r'[一-鿿]+')
|
|
844
|
+
_ASCII_WORD = re.compile(r'[a-z0-9]{2,}')
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def query_terms(text):
|
|
848
|
+
"""把查询切成检索词:中文按**字符二元组**(没有分词器,二元组对中文足够稳),ASCII 按词。
|
|
849
|
+
|
|
850
|
+
二元组是刻意选的:它不需要词表、不会因为新词失手,代价是"的/了/在"这类高频字也会成词——
|
|
851
|
+
那由 BM25 的 idf 自己压下去(df 越大分越低),不用手写停用词表。
|
|
852
|
+
"""
|
|
853
|
+
terms = []
|
|
854
|
+
for run in _CJK_RUN.findall(text):
|
|
855
|
+
if len(run) == 1:
|
|
856
|
+
terms.append(run)
|
|
857
|
+
else:
|
|
858
|
+
terms.extend(run[i:i + 2] for i in range(len(run) - 1))
|
|
859
|
+
terms.extend(_ASCII_WORD.findall(text.lower()))
|
|
860
|
+
seen, out = set(), []
|
|
861
|
+
for t in terms:
|
|
862
|
+
if t not in seen:
|
|
863
|
+
seen.add(t)
|
|
864
|
+
out.append(t)
|
|
865
|
+
return out
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def keyword_rank(query, meta, in_scope=None, limit=RRF_POOL,
|
|
869
|
+
rare_ratio=None, score_ratio=None):
|
|
870
|
+
"""本地 BM25 名次。返回 [(下标, 分数)],按分数降序、**并列按下标升序**。
|
|
871
|
+
|
|
872
|
+
一遍扫过所有记录,同时算 df 与每条的 tf —— 不建额外索引文件,也就没有"索引与正文不同步"
|
|
873
|
+
这种失效。`in_scope` 是与向量侧**同一个掩码**:关键词这一侧也不能把另一条线的记录带进来。
|
|
874
|
+
|
|
875
|
+
`limit` 只截名次表(RRF 只看名次,第 200 名之后的贡献可以忽略),不影响打分。
|
|
876
|
+
"""
|
|
877
|
+
terms = query_terms(query)
|
|
878
|
+
if not terms:
|
|
879
|
+
return []
|
|
880
|
+
docs = [] # [(下标, 小写文本)],只含在范围内的
|
|
881
|
+
for i, m in enumerate(meta):
|
|
882
|
+
if in_scope is not None and not in_scope[i]:
|
|
883
|
+
continue
|
|
884
|
+
t = (m.get('text') or '').lower()
|
|
885
|
+
if t:
|
|
886
|
+
docs.append((i, t))
|
|
887
|
+
if not docs:
|
|
888
|
+
return []
|
|
889
|
+
|
|
890
|
+
tf, df, total_len = [], {}, 0
|
|
891
|
+
for _i, t in docs:
|
|
892
|
+
total_len += len(t)
|
|
893
|
+
row = {}
|
|
894
|
+
for term in terms:
|
|
895
|
+
c = t.count(term)
|
|
896
|
+
if c:
|
|
897
|
+
row[term] = c
|
|
898
|
+
if row:
|
|
899
|
+
for term in row:
|
|
900
|
+
df[term] = df.get(term, 0) + 1
|
|
901
|
+
tf.append(row or None)
|
|
902
|
+
|
|
903
|
+
n_docs = len(docs)
|
|
904
|
+
avgdl = (total_len / n_docs) or 1.0
|
|
905
|
+
|
|
906
|
+
# 门控(1):**没有稀有词就弃权**。中文问句里的"怎么用/有没有/的"会成词,但它们在语料里
|
|
907
|
+
# 到处都是 —— 只命中这类词的文档不该来投票(无门控的 RRF 正是栽在这里,见 D-110)。
|
|
908
|
+
rare = None
|
|
909
|
+
if rare_ratio is not None:
|
|
910
|
+
rare = {t for t, d in df.items() if d and d / n_docs <= rare_ratio}
|
|
911
|
+
if not rare:
|
|
912
|
+
return []
|
|
913
|
+
|
|
914
|
+
scored = []
|
|
915
|
+
for (idx, t), row in zip(docs, tf):
|
|
916
|
+
if not row:
|
|
917
|
+
continue
|
|
918
|
+
if rare is not None and not (set(row) & rare):
|
|
919
|
+
continue
|
|
920
|
+
score = 0.0
|
|
921
|
+
for term, c in row.items():
|
|
922
|
+
d = df[term]
|
|
923
|
+
idf = math.log(1 + (n_docs - d + 0.5) / (d + 0.5))
|
|
924
|
+
denom = c + BM25_K1 * (1 - BM25_B + BM25_B * len(t) / avgdl)
|
|
925
|
+
score += idf * c * (BM25_K1 + 1) / denom
|
|
926
|
+
scored.append((idx, score))
|
|
927
|
+
|
|
928
|
+
# 门控(2):长词的垃圾二元组("器学""习或")同样稀有 ⇒ 再用"分数接近最高分"筛一道。
|
|
929
|
+
if score_ratio is not None and scored:
|
|
930
|
+
top = max(s for _i, s in scored)
|
|
931
|
+
scored = [(idx, s) for idx, s in scored if s >= score_ratio * top]
|
|
932
|
+
|
|
933
|
+
scored.sort(key=lambda kv: (-kv[1], kv[0]))
|
|
934
|
+
return scored[:limit]
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def rrf_fuse(rank_lists, k=RRF_K):
|
|
938
|
+
"""Reciprocal Rank Fusion:只吃**名次**、不吃分数,所以两侧分数量纲不同也没关系。
|
|
939
|
+
|
|
940
|
+
并列时的次序按下标升序定死 —— 这个仓库对"同一批数据两次跑给出不同顺序"很敏感
|
|
941
|
+
(缓存会把偶然结果固化),所以不让它取决于 dict 的插入顺序。
|
|
942
|
+
"""
|
|
943
|
+
score = {}
|
|
944
|
+
for lst in rank_lists:
|
|
945
|
+
for rank, idx in enumerate(lst):
|
|
946
|
+
score[idx] = score.get(idx, 0.0) + 1.0 / (k + rank + 1)
|
|
947
|
+
return sorted(score.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
948
|
+
|
|
949
|
+
MMR_LAMBDA = 0.7 # 照 WeKnora 的取值:相关性为主、多样性为辅
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def mmr_select(items, vectors, k, lam=MMR_LAMBDA):
|
|
953
|
+
"""在**已经排好序**的候选里挑 k 条:又相关、又彼此不像(Maximal Marginal Relevance)。
|
|
954
|
+
|
|
955
|
+
为什么要有它:这条语料里"同领域邻居"很多(实测 top-5 常有三四条在讲同一件事),它们吃掉名额
|
|
956
|
+
却不增加信息。MMR 给每条的边际价值 = λ×相关性 − (1−λ)×与**已选条目**的最大相似度,逐条贪心挑。
|
|
957
|
+
|
|
958
|
+
相关性取重排分(0-100 归一化到 0-1),没有重排分就退回余弦;相似度用向量点积(存的就是归一化的)。
|
|
959
|
+
条目必须带 `_row`(在 meta/向量里的行号)—— `search()` 在重排前挂上、返回前摘掉。
|
|
960
|
+
与"同文本折叠"(`dedup_results`)不是一回事:那个折叠一模一样的内容,这个压的是**近似**的内容。
|
|
961
|
+
"""
|
|
962
|
+
np = require_numpy()
|
|
963
|
+
if k <= 0 or not items:
|
|
964
|
+
return items
|
|
965
|
+
rows, rel = [], []
|
|
966
|
+
for item in items:
|
|
967
|
+
row = item.get('_row')
|
|
968
|
+
if row is None:
|
|
969
|
+
return items # 拿不到行号就不动它,别猜
|
|
970
|
+
rows.append(int(row))
|
|
971
|
+
score = item.get('rerankScore')
|
|
972
|
+
rel.append((float(score) / 100.0) if isinstance(score, (int, float)) else
|
|
973
|
+
float(item.get('score') or 0.0))
|
|
974
|
+
matrix = np.asarray(vectors[rows], dtype=np.float32)
|
|
975
|
+
rel = np.asarray(rel, dtype=np.float32)
|
|
976
|
+
|
|
977
|
+
chosen, remaining = [], list(range(len(items)))
|
|
978
|
+
max_sim = np.zeros(len(items), dtype=np.float32)
|
|
979
|
+
while remaining and len(chosen) < k:
|
|
980
|
+
gain = lam * rel[remaining] - (1 - lam) * max_sim[remaining]
|
|
981
|
+
best = remaining[int(np.argmax(gain))]
|
|
982
|
+
chosen.append(best)
|
|
983
|
+
remaining.remove(best)
|
|
984
|
+
sims = matrix @ matrix[best]
|
|
985
|
+
max_sim = np.maximum(max_sim, sims)
|
|
986
|
+
rest = [i for i in range(len(items)) if i not in set(chosen)]
|
|
987
|
+
return [items[i] for i in chosen + rest]
|
|
988
|
+
|
|
989
|
+
def dedup_results(results):
|
|
990
|
+
"""同一段文本只留排名最高的那条,被折叠的账号数记在 `duplicateCount` 里。
|
|
991
|
+
|
|
992
|
+
为什么在**取出结果时**做:多号转载是这语料的常态(实测 2026-10-16:20 条问句的 top-5 里 7% 是重复文本,
|
|
993
|
+
6/20 条问句的 top-5 出现过重复),重复占的是名额而不是信息。不做成"建索引时去重"是因为那会破坏
|
|
994
|
+
`--from <公众号>` 这类按号查(被折叠的账号仍在 id 里);所以留着索引,折叠留给展示。
|
|
995
|
+
`duplicateCount` 让用户知道"同一篇被几个号发过",而不是以为丢了什么。
|
|
996
|
+
"""
|
|
997
|
+
seen, out = {}, []
|
|
998
|
+
for item in results:
|
|
999
|
+
text = item.get('text') or ''
|
|
1000
|
+
if not text: # 没有正文的记录各算各的:空串之间不是"同一段"
|
|
1001
|
+
out.append({**item, 'duplicateCount': 1})
|
|
1002
|
+
continue
|
|
1003
|
+
h = hash(text)
|
|
1004
|
+
if h in seen:
|
|
1005
|
+
seen[h]['duplicateCount'] = seen[h].get('duplicateCount', 1) + 1
|
|
1006
|
+
continue
|
|
1007
|
+
entry = dict(item)
|
|
1008
|
+
entry['duplicateCount'] = 1
|
|
1009
|
+
seen[h] = entry
|
|
1010
|
+
out.append(entry)
|
|
1011
|
+
return out
|
|
1012
|
+
# 正文里按"行"删掉的公众号套话(只删整行,不动正文句子)
|
|
1013
|
+
_BOILERPLATE_LINE = re.compile(r'^\s*(在小说阅读器读本章|去阅读|在公众号小说中沉浸阅读|点击蓝字|关注我们|一键关注|设为星标|预览时标签不可点|阅读原文|微信扫一扫|关注该公众号|写留言|点个在看|点赞|在看|不喜欢|______+|-{3,})\s*$')
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def readable_body(text):
|
|
1017
|
+
"""把正文整理成"能读"的样子:去加粗/斜体标记与公众号套话行,**保留段落**。
|
|
1018
|
+
|
|
1019
|
+
与重排那份摘录(`cleanse_passage`)不同:那个会把空白压成一行、还截断到 300 字,
|
|
1020
|
+
因为它只是喂给模型的;这一份是给人看的,所以保留换行。原始文件路径在 `sourceFile` 里,
|
|
1021
|
+
想核对"有没有被我改过"就打开那个文件对比。
|
|
1022
|
+
"""
|
|
1023
|
+
out = []
|
|
1024
|
+
for raw_line in (text or '').split('\n'):
|
|
1025
|
+
line = re.sub(r'!\[[^\]]*\]\([^)]*\)', ' ', raw_line) # 图片语法
|
|
1026
|
+
line = re.sub(r'\[([^\]]{0,80})\]\([^)]*\)', r'\1', line) # 链接留文字
|
|
1027
|
+
line = re.sub(r'\*{1,3}', '', line).strip()
|
|
1028
|
+
if not line or _BOILERPLATE_LINE.match(line):
|
|
1029
|
+
continue
|
|
1030
|
+
out.append(line)
|
|
1031
|
+
return '\n'.join(out)
|
|
1032
|
+
|
|
1033
|
+
FULL_TEXT_MIN_CHARS = 300 # `--fetch-missing` 的判据:正文短于这个数就算"没抓到正文"
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _fetch_body(url):
|
|
1037
|
+
"""按 url 取正文 —— **复用日报那套带缓存的抓取**(`biz_daily.fetch_article_cached`)。
|
|
1038
|
+
|
|
1039
|
+
不另写一份的理由与仓库里其它"同一份实现"一样:缓存格式、失败语义(只缓存成功)、
|
|
1040
|
+
以及"入库前先看缓存"这件事必须只有一处。懒导入:不抓正文的路径不该被它拖着。
|
|
1041
|
+
"""
|
|
1042
|
+
try:
|
|
1043
|
+
from biz_daily import fetch_article_cached
|
|
1044
|
+
except Exception as error: # noqa: BLE001
|
|
1045
|
+
print(' [WARN] 抓正文不可用(%s),改用本地那份' % type(error).__name__, file=sys.stderr)
|
|
1046
|
+
return None
|
|
1047
|
+
try:
|
|
1048
|
+
body, _cached = fetch_article_cached(url)
|
|
1049
|
+
except Exception as error: # noqa: BLE001
|
|
1050
|
+
print(' [WARN] 抓正文失败(%s):%s' % (type(error).__name__, error), file=sys.stderr)
|
|
1051
|
+
return None
|
|
1052
|
+
return body
|
|
1053
|
+
|
|
1054
|
+
FULL_TEXT_LIMIT = 4000 # `--with-text` 默认给多少字(够一篇公众号全文)
|
|
1055
|
+
|
|
1056
|
+
_FETCH_CACHE_DIR_NAME = ('.cache', 'fetch')
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _fetch_cache_path(url):
|
|
1060
|
+
return Path(OUTPUT_ROOT) / '.cache' / 'fetch' / (hashlib.md5(url.encode('utf-8')).hexdigest() + '.md')
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def best_article_body(content, limit=None, md_body=None):
|
|
1064
|
+
"""**展示路径**用:文章正文取**更长的那份** —— 日报 md,或它旁边那份正文缓存。
|
|
1065
|
+
|
|
1066
|
+
日报 md 里正文中位只有 259 汉字,而日报**自己抓到的**完整正文缓存里是 2,063 汉字 ——
|
|
1067
|
+
内容早就抓回来了,只写进 md 一小段。`--with-text` 要给人看的那份,所以取全的。
|
|
1068
|
+
⚠️ **建索引不用这个函数**:实测吃完整正文对检索没有增益(DECISIONS D-118),
|
|
1069
|
+
索引只吃 md 那份摘录(`collect_articles` 里 500 字)。
|
|
1070
|
+
返回 `(body, url)`;url 从 frontmatter 取,取不到就是 None(那时只能用 md 那份)。
|
|
1071
|
+
`md_body` 由调用方给时就用它(例如调用方已按自己的规则切出正文 —— 调用方的切法优先)。
|
|
1072
|
+
"""
|
|
1073
|
+
body = (md_body if md_body is not None
|
|
1074
|
+
else extract_article_body(content, limit or FULL_TEXT_LIMIT))
|
|
1075
|
+
url = None
|
|
1076
|
+
match = re.search(r'^url:\s*"?([^"\n]+)"?', content, re.M)
|
|
1077
|
+
if match:
|
|
1078
|
+
url = match.group(1).strip()
|
|
1079
|
+
cached = _fetch_cache_path(url)
|
|
1080
|
+
if cached.exists():
|
|
1081
|
+
try:
|
|
1082
|
+
full = cached.read_text(encoding='utf-8', errors='replace').strip()
|
|
1083
|
+
if len(full) > len(body):
|
|
1084
|
+
body = full
|
|
1085
|
+
except OSError:
|
|
1086
|
+
pass # 读不了缓存就当没有,别让这一篇失败
|
|
1087
|
+
return (body[:limit] if limit else body), url
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def load_record_text(item, limit=FULL_TEXT_LIMIT):
|
|
1091
|
+
"""把命中的记录还原成本地那份**正文** —— 索引里存的只是"用来匹配的摘录"。
|
|
1092
|
+
|
|
1093
|
+
这是 2026-10-16 用户提出来的:返回标题+分数没有意义,要能给出**某篇公众号的具体内容**。
|
|
1094
|
+
两条来源,取更长的那份:
|
|
1095
|
+
· 日报 md:`output/biz-daily/<id 里那段相对路径>`,正文用同一套 `extract_article_body` 取;
|
|
1096
|
+
· 正文缓存:`output/.cache/fetch/<md5(url)>.md` —— 日报/回填抓来的**完整正文**(比 md 更全)。
|
|
1097
|
+
聊天记录没有这两层:索引里那段**本来就是消息全文**。
|
|
1098
|
+
|
|
1099
|
+
返回 `(text, source_file, url)`;读不到时 text 为 None(**不许**静默当成"这篇没有正文")。
|
|
1100
|
+
"""
|
|
1101
|
+
rid = str(item.get('id') or '')
|
|
1102
|
+
if not rid.startswith('article:'):
|
|
1103
|
+
return (item.get('text') or ''), None, None
|
|
1104
|
+
|
|
1105
|
+
rel = rid[len('article:'):].replace('\\', '/')
|
|
1106
|
+
path = Path(OUTPUT_ROOT) / 'biz-daily' / rel
|
|
1107
|
+
if not path.exists():
|
|
1108
|
+
return None, str(path), None
|
|
1109
|
+
try:
|
|
1110
|
+
content = path.read_text(encoding='utf-8', errors='replace')
|
|
1111
|
+
except OSError:
|
|
1112
|
+
return None, str(path), None
|
|
1113
|
+
|
|
1114
|
+
body, url = best_article_body(content, limit)
|
|
1115
|
+
return readable_body(body), str(path), url
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def attach_full_text(results, limit=FULL_TEXT_LIMIT, fetch_missing=0):
|
|
1119
|
+
"""给每条结果挂上 `fullText` / `sourceFile` / `url`(读不到的挂 None,并数出来)。
|
|
1120
|
+
|
|
1121
|
+
`fetch_missing>0` 时,**本地正文短于 `FULL_TEXT_MIN_CHARS` 的那些**会按 `url` 去抓(最多抓这么多条):
|
|
1122
|
+
抓得到的写回 `fullText` 并标 `fetchedFromUrl`,同一份缓存下次直接命中(不重复打扰上游)。
|
|
1123
|
+
默认 0 = 不抓 —— 检索不该默认产生对上游的请求。返回 `(results, missing, fetched)`。
|
|
1124
|
+
"""
|
|
1125
|
+
missing = 0
|
|
1126
|
+
fetched = 0
|
|
1127
|
+
for item in results:
|
|
1128
|
+
text, path, url = load_record_text(item, limit)
|
|
1129
|
+
if text is None:
|
|
1130
|
+
missing += 1
|
|
1131
|
+
if fetch_missing and fetched < fetch_missing and url and len(text or '') < FULL_TEXT_MIN_CHARS:
|
|
1132
|
+
body = _fetch_body(url)
|
|
1133
|
+
if body and body.strip() and len(body) > len(text or ''):
|
|
1134
|
+
text = readable_body(body)[:limit]
|
|
1135
|
+
fetched += 1
|
|
1136
|
+
item['fetchedFromUrl'] = True
|
|
1137
|
+
item['fullText'] = text
|
|
1138
|
+
if path:
|
|
1139
|
+
item['sourceFile'] = path.replace('\\', '/')
|
|
1140
|
+
if url:
|
|
1141
|
+
item['url'] = url
|
|
1142
|
+
return results, missing, fetched
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
# ===== 精确短语定位(D-121)=====
|
|
1146
|
+
#
|
|
1147
|
+
# **为什么单独有一条腿**:现有两条腿都是"相似"——bigram+BM25 按词项计分、向量按语义近似。
|
|
1148
|
+
# 于是"哪个号在哪天**原样**写过这句话"这类问句(专有名词、代码名、政策原文、引文核对)
|
|
1149
|
+
# 它们答不了,也不该硬答:把"查不到原样"混进相似度排序里,用户看不出区别。
|
|
1150
|
+
#
|
|
1151
|
+
# 实现用 SQLite **FTS5 + trigram**,标准库自带的,不需要新依赖。四个实测结论决定了这个形状
|
|
1152
|
+
# (2026-10-17 本机 sqlite 3.45.1,8 次探针):
|
|
1153
|
+
# 1. 默认分词 `unicode61` 对中文**完全不可用**(整句一个 token:`沙箱逃逸` 命中 0)⇒ 必须 trigram;
|
|
1154
|
+
# 2. trigram 是**连续子串**匹配,不是词项 AND(`光伏补贴政策` 只命中原样连续的,
|
|
1155
|
+
# 而 `补贴政策` 命中"的补贴政策"/"补贴政策解读")⇒ 正好是"原样定位"要的语义,
|
|
1156
|
+
# 也正是它**不能**替我们那条关键词腿的原因(那条要的是"分开出现也能排上分");
|
|
1157
|
+
# 3. trigram 有 **3 字下限**:`光伏`(2 字)恒空 ⇒ 短查询必须**明说**,绝不能回报"0 命中";
|
|
1158
|
+
# 4. 查询里的标点会被当**语法**(`PM2.5` → `syntax error near "."`、`GPT-4` → `no such column: 4`)
|
|
1159
|
+
# ⇒ 整串加引号当字面量(内部 `"` 翻倍);加引号后 `PM2.5`/`C++`/`GPT-4` 全部正常,
|
|
1160
|
+
# 且 ASCII 大小写不敏感(`"pm2.5"` 能命中 `PM2.5`)。全角/半角不互认。
|
|
1161
|
+
#
|
|
1162
|
+
# **语料只从 `meta.json` 来**(语义索引的记录清单):再写一套记录枚举,两个索引的语料迟早分叉,
|
|
1163
|
+
# 而"两个索引覆盖的不是同一批"这种错不会报,只会让结果看起来少。
|
|
1164
|
+
PHRASE_INDEX_DIR = Path(OUTPUT_ROOT) / '.phrase_index'
|
|
1165
|
+
PHRASE_DB = PHRASE_INDEX_DIR / 'phrase.sqlite'
|
|
1166
|
+
PHRASE_MIN_CHARS = 3
|
|
1167
|
+
PHRASE_TEXT_LIMIT = FULL_TEXT_LIMIT
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def escape_phrase(query):
|
|
1171
|
+
"""把整串当**字面量**(而不是 FTS 语法):外层加引号,内部 `"` 翻倍。"""
|
|
1172
|
+
return '"' + str(query or '').replace('"', '""') + '"'
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def phrase_chars(query):
|
|
1176
|
+
""""有效字符数":去掉所有空白再数。trigram 的下限按字符算,空格不参与。"""
|
|
1177
|
+
return len(''.join(str(query or '').split()))
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _phrase_label(item):
|
|
1181
|
+
"""「哪个号」:文章取文件名里第一个 `-` 前那段(`<账号>-<标题>.md`),聊天取会话名。
|
|
1182
|
+
|
|
1183
|
+
与 `--from` 的口径保持一致:那条路也是靠 `id`(文件名)里含账号名来找号的
|
|
1184
|
+
(2026-10-15 修正文提取时 `source:` 随 YAML 头离开了嵌入文本,见 `search()` 里的注释)。
|
|
1185
|
+
"""
|
|
1186
|
+
if str(item.get('type')) == 'chat':
|
|
1187
|
+
return str(item.get('talker') or '')
|
|
1188
|
+
rid = str(item.get('id') or '')
|
|
1189
|
+
stem = rid.split(':', 1)[-1].replace('\\', '/').rsplit('/', 1)[-1]
|
|
1190
|
+
if stem.endswith('.md'):
|
|
1191
|
+
stem = stem[:-3]
|
|
1192
|
+
return stem.split('-', 1)[0] if '-' in stem else stem
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def build_phrase_index(line: str = 'all', db_path=None, meta_path=None):
|
|
1196
|
+
"""从 `meta.json` **全量重建**短语索引(不调用任何模型,所以重建便宜到可以每次都全量)。
|
|
1197
|
+
|
|
1198
|
+
返回 `{'records', 'byLine', 'textMissing', 'db', 'bytes'}`;没有语义索引时返回 error ——
|
|
1199
|
+
它是**派生**产物,不自己去找语料。
|
|
1200
|
+
"""
|
|
1201
|
+
import sqlite3
|
|
1202
|
+
meta_file = Path(meta_path or META_FILE)
|
|
1203
|
+
if not meta_file.exists():
|
|
1204
|
+
return {'error': '还没有语义索引,短语索引由它派生',
|
|
1205
|
+
'hint': 'weflow-cli search-index --yes'}
|
|
1206
|
+
meta = json.loads(meta_file.read_text(encoding='utf-8'))
|
|
1207
|
+
types = line_types(line)
|
|
1208
|
+
rows = []
|
|
1209
|
+
text_missing = 0
|
|
1210
|
+
by_line = {}
|
|
1211
|
+
for item in meta:
|
|
1212
|
+
if types is not None and str(item.get('type') or '') not in types:
|
|
1213
|
+
continue
|
|
1214
|
+
text, _source_file, _url = load_record_text(item, PHRASE_TEXT_LIMIT)
|
|
1215
|
+
if not text:
|
|
1216
|
+
# 读不到正文(md 被删/移动):退回索引里那段摘录,并**数出来**告诉用户,
|
|
1217
|
+
# 而不是让这篇悄悄从短语索引里消失。
|
|
1218
|
+
text = item.get('text') or ''
|
|
1219
|
+
text_missing += 1
|
|
1220
|
+
if not text:
|
|
1221
|
+
continue
|
|
1222
|
+
key = str(item.get('type') or '')
|
|
1223
|
+
by_line[key] = by_line.get(key, 0) + 1
|
|
1224
|
+
rows.append((text, str(item.get('id') or ''), key, _phrase_label(item),
|
|
1225
|
+
str(item.get('date') or item.get('time') or ''),
|
|
1226
|
+
str(item.get('title') or '')))
|
|
1227
|
+
path = Path(db_path or PHRASE_DB)
|
|
1228
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1229
|
+
conn = sqlite3.connect(str(path))
|
|
1230
|
+
try:
|
|
1231
|
+
conn.execute('DROP TABLE IF EXISTS hits')
|
|
1232
|
+
conn.execute("CREATE VIRTUAL TABLE hits USING fts5("
|
|
1233
|
+
"body, rid UNINDEXED, line UNINDEXED, label UNINDEXED,"
|
|
1234
|
+
" date UNINDEXED, title UNINDEXED, tokenize='trigram')")
|
|
1235
|
+
conn.executemany('INSERT INTO hits(body, rid, line, label, date, title)'
|
|
1236
|
+
' VALUES (?, ?, ?, ?, ?, ?)', rows)
|
|
1237
|
+
conn.commit()
|
|
1238
|
+
conn.execute('VACUUM')
|
|
1239
|
+
finally:
|
|
1240
|
+
conn.close()
|
|
1241
|
+
return {'records': len(rows), 'byLine': by_line, 'textMissing': text_missing,
|
|
1242
|
+
'line': line, 'db': str(path), 'bytes': path.stat().st_size}
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
def _phrase_where(line, from_source):
|
|
1246
|
+
"""拼过滤条件。**与向量那条路同一口径**:`--from` 按 `id` 子串或标签精确匹配。"""
|
|
1247
|
+
clauses, params = [], []
|
|
1248
|
+
if line_types(line) is not None:
|
|
1249
|
+
clauses.append('line IN (%s)' % ','.join('?' * len(sorted(line_types(line)))))
|
|
1250
|
+
params.extend(sorted(line_types(line)))
|
|
1251
|
+
if from_source:
|
|
1252
|
+
want = from_source.strip().lower()
|
|
1253
|
+
clauses.append('(lower(rid) LIKE ? OR lower(label) = ?)')
|
|
1254
|
+
params.extend(['%' + want + '%', want])
|
|
1255
|
+
return (' AND ' + ' AND '.join(clauses) if clauses else ''), params
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def phrase_search(query, limit: int = 20, line: str = 'all', from_source: str = None,
|
|
1259
|
+
db_path=None, group_limit: int = 20):
|
|
1260
|
+
"""原样定位:返回命中行 + 按(账号/会话 × 日期)的分组。
|
|
1261
|
+
|
|
1262
|
+
**两条不许静默**:查不到索引 → 报错并给建索引的命令;查询短于下限 → 明说"查不了"
|
|
1263
|
+
(而不是 0 命中)。把这两种说成"没有"是这条腿最坏的失效方式。
|
|
1264
|
+
"""
|
|
1265
|
+
import sqlite3
|
|
1266
|
+
path = Path(db_path or PHRASE_DB)
|
|
1267
|
+
result = {'query': query, 'escaped': escape_phrase(query), 'line': line,
|
|
1268
|
+
'from': from_source, 'total': 0, 'hits': [], 'groups': []}
|
|
1269
|
+
if not path.exists():
|
|
1270
|
+
result.update({'error': '短语索引还没建',
|
|
1271
|
+
'hint': 'weflow-cli search-index --phrase(本地、不花钱)'})
|
|
1272
|
+
return result
|
|
1273
|
+
chars = phrase_chars(query)
|
|
1274
|
+
if chars < PHRASE_MIN_CHARS:
|
|
1275
|
+
result.update({'tooShort': True, 'chars': chars, 'minChars': PHRASE_MIN_CHARS,
|
|
1276
|
+
'note': 'trigram 分词有 %d 字下限,更短的串它切不出任何词项 —— '
|
|
1277
|
+
'这里**不报"0 命中"**,因为那是把"查不了"说成"没有"'
|
|
1278
|
+
% PHRASE_MIN_CHARS})
|
|
1279
|
+
return result
|
|
1280
|
+
where, params = _phrase_where(line, from_source)
|
|
1281
|
+
conn = sqlite3.connect(str(path))
|
|
1282
|
+
try:
|
|
1283
|
+
total = conn.execute('SELECT count(*) FROM hits WHERE hits MATCH ?' + where,
|
|
1284
|
+
[result['escaped']] + params).fetchone()[0]
|
|
1285
|
+
result['total'] = total
|
|
1286
|
+
for label, day, n in conn.execute(
|
|
1287
|
+
'SELECT label, substr(date, 1, 10) AS day, count(*) AS n FROM hits '
|
|
1288
|
+
'WHERE hits MATCH ?' + where +
|
|
1289
|
+
' GROUP BY label, day ORDER BY n DESC, day DESC LIMIT ?',
|
|
1290
|
+
[result['escaped']] + params + [group_limit]):
|
|
1291
|
+
result['groups'].append({'label': label, 'date': day, 'count': n})
|
|
1292
|
+
for rid, kind, label, date, title, ctx in conn.execute(
|
|
1293
|
+
'SELECT rid, line, label, date, title, '
|
|
1294
|
+
"snippet(hits, 0, '【', '】', '…', 14) FROM hits "
|
|
1295
|
+
'WHERE hits MATCH ?' + where + ' ORDER BY date DESC LIMIT ?',
|
|
1296
|
+
[result['escaped']] + params + [limit]):
|
|
1297
|
+
result['hits'].append({'id': rid, 'type': kind, 'label': label,
|
|
1298
|
+
'date': date, 'title': title, 'context': ctx})
|
|
1299
|
+
finally:
|
|
1300
|
+
conn.close()
|
|
1301
|
+
return result
|
|
1302
|
+
|
|
1303
|
+
|
|
584
1304
|
def search(query: str, api_key: str, top_k: int = 10, rerank_results: bool = True,
|
|
585
|
-
line: str = 'all'
|
|
1305
|
+
line: str = 'all', hybrid: bool = True, from_source: str = None,
|
|
1306
|
+
rerank_pool: int = RERANK_CANDIDATES,
|
|
1307
|
+
dedup: bool = True, with_text: int = 0,
|
|
1308
|
+
fetch_missing: int = 0, mmr: bool = False):
|
|
586
1309
|
"""Semantic search with keyword fallback.
|
|
587
1310
|
|
|
588
1311
|
`line` 在**取候选之前**就把不属于这条线的记录挡掉(把它们的相似度压成 -inf 再
|
|
@@ -594,46 +1317,109 @@ def search(query: str, api_key: str, top_k: int = 10, rerank_results: bool = Tru
|
|
|
594
1317
|
"静默少搜一半"——后者在这个仓库里是最贵的那类 bug。
|
|
595
1318
|
"""
|
|
596
1319
|
types = line_types(line) # 认不出的线在这里就报错,不静默当 all
|
|
1320
|
+
VECTOR_STATE.update(used=False, fallback=None, counts=None)
|
|
1321
|
+
# `--mmr` 在 fallback 里也要用 `vectors`;只在 try 里赋值会让它**未绑定**(NameError)。
|
|
1322
|
+
vectors = None
|
|
597
1323
|
# Try embedding-based search first
|
|
598
1324
|
if VECTORS_FILE.exists() and META_FILE.exists():
|
|
599
1325
|
try:
|
|
600
1326
|
np = require_numpy()
|
|
601
1327
|
vectors = np.load(VECTORS_FILE)
|
|
602
1328
|
meta = json.loads(META_FILE.read_text(encoding='utf-8'))
|
|
1329
|
+
# meta 与 vectors 的行数必须一致 —— 中途被杀、或两份文件新旧不一,都可能不一样。
|
|
1330
|
+
# 不一致时**按少的那个截断**:宁可少几条结果,也不能让下标越界,因为越界会被
|
|
1331
|
+
# 下面那个 `except: pass` 变成"静默退回关键词",那是最难看出来的一种失效。
|
|
1332
|
+
rows = min(len(meta), int(vectors.shape[0]))
|
|
1333
|
+
if rows < len(meta):
|
|
1334
|
+
meta = meta[:rows]
|
|
1335
|
+
elif rows < int(vectors.shape[0]):
|
|
1336
|
+
vectors = vectors[:rows]
|
|
1337
|
+
# 顺手把每线条数算掉:`main()` 原来会再解析一遍同一份 meta(69 + 56 ms/次)
|
|
1338
|
+
VECTOR_STATE['counts'] = _counts_from_meta(meta)
|
|
603
1339
|
query_embeddings = get_embeddings([query], api_key)
|
|
604
1340
|
if query_embeddings and not all(v == 0 for v in query_embeddings[0]):
|
|
605
1341
|
query_vec = np.array(query_embeddings[0], dtype=np.float32)
|
|
606
1342
|
query_vec = query_vec / np.linalg.norm(query_vec)
|
|
607
1343
|
similarities = np.dot(vectors, query_vec)
|
|
608
|
-
|
|
609
|
-
|
|
1344
|
+
# 关键词那一侧要用**同一个**掩码(`--line wiki` 时聊天不许从这条路上混进来)
|
|
1345
|
+
in_scope_mask = None
|
|
1346
|
+
if types is not None or from_source:
|
|
1347
|
+
in_scope = np.array([True if types is None else str(m.get('type', '')) in types
|
|
1348
|
+
for m in meta], dtype=bool)
|
|
1349
|
+
if from_source:
|
|
1350
|
+
# **按公众号查是属性查询,不该指望向量。** 号名只住在 `id`(文件名
|
|
1351
|
+
# `<公众号>-<标题>.md`)里:2026-10-15 修正文提取时,`source:` 随 YAML 头
|
|
1352
|
+
# 一起离开了嵌入文本。实测(窗口 2026-09-12..10-13):红星新闻 908 篇,
|
|
1353
|
+
# 语义前 10 只回 3 条;这里按 id 精确筛,能筛出全部。
|
|
1354
|
+
want = from_source.strip().lower()
|
|
1355
|
+
by_id = np.array([want in str(m.get('id', '')).lower()
|
|
1356
|
+
or str(m.get('source', '')).lower() == want
|
|
1357
|
+
for m in meta], dtype=bool)
|
|
1358
|
+
in_scope = in_scope & by_id
|
|
610
1359
|
if not in_scope.any():
|
|
611
|
-
#
|
|
1360
|
+
# 一条都没有:**说出来**(是这条线/这个号在索引里没有记录,不是"没命中")
|
|
612
1361
|
return []
|
|
613
1362
|
similarities = np.where(in_scope, similarities, -np.inf)
|
|
1363
|
+
in_scope_mask = in_scope
|
|
614
1364
|
# 先取一个比重排池更大的候选集,重排才有可以换的空间。
|
|
615
|
-
pool_size = max(top_k,
|
|
1365
|
+
pool_size = max(top_k, rerank_pool) if rerank_results else top_k
|
|
616
1366
|
if types is not None:
|
|
617
1367
|
pool_size = min(pool_size, int(in_scope.sum()))
|
|
618
|
-
|
|
1368
|
+
vec_order = [int(i) for i in np.argsort(similarities)[::-1][:RRF_POOL]]
|
|
1369
|
+
vec_order = [i for i in vec_order if np.isfinite(similarities[i])]
|
|
1370
|
+
if hybrid:
|
|
1371
|
+
# 关键词那一侧走**同一个掩码**:`--line wiki` 时聊天记录不许从这条路上混进来。
|
|
1372
|
+
kw_order = [i for i, _s in keyword_rank(query, meta, in_scope_mask,
|
|
1373
|
+
rare_ratio=HYBRID_RARE_RATIO,
|
|
1374
|
+
score_ratio=HYBRID_SCORE_RATIO)]
|
|
1375
|
+
top_indices = [i for i, _s in rrf_fuse([vec_order, kw_order])[:pool_size]]
|
|
1376
|
+
else:
|
|
1377
|
+
top_indices = vec_order[:pool_size]
|
|
619
1378
|
results = []
|
|
620
1379
|
for idx in top_indices:
|
|
621
|
-
if not np.isfinite(similarities[idx]):
|
|
622
|
-
continue # 掩码挡掉的(-inf)不许混进结果
|
|
623
1380
|
item = meta[idx]
|
|
624
|
-
results.append({**item, 'score': float(similarities[idx])})
|
|
1381
|
+
results.append({**item, '_row': int(idx), 'score': float(similarities[idx])})
|
|
625
1382
|
if rerank_results:
|
|
626
|
-
results =
|
|
1383
|
+
results = rerank_dispatch(query, results, create_client(),
|
|
1384
|
+
pool_size=rerank_pool)
|
|
1385
|
+
if dedup:
|
|
1386
|
+
results = dedup_results(results)
|
|
1387
|
+
if with_text:
|
|
1388
|
+
results, _missing, _fetched = attach_full_text(results, with_text,
|
|
1389
|
+
fetch_missing=fetch_missing)
|
|
1390
|
+
if mmr:
|
|
1391
|
+
results = mmr_select(results, vectors, top_k)
|
|
1392
|
+
for _item in results: # `_row` 只是内部用的,别漏进输出
|
|
1393
|
+
_item.pop('_row', None)
|
|
1394
|
+
VECTOR_STATE['used'] = True
|
|
627
1395
|
return results[:top_k]
|
|
628
|
-
except:
|
|
629
|
-
pass
|
|
1396
|
+
except Exception as error:
|
|
1397
|
+
# **显式降级**,原因留下来。原来是裸 `except: pass`:向量路径任何异常都会变成
|
|
1398
|
+
# "静默只搜关键词",而返回里照样写着 `hybrid: true` —— 那是最难看出来的一种失效,
|
|
1399
|
+
# 也让"快慢/好坏"的对比可能落在不同路径上。另外裸 except 会吞掉 KeyboardInterrupt。
|
|
1400
|
+
VECTOR_STATE['fallback'] = '%s: %s' % (type(error).__name__, error)
|
|
1401
|
+
|
|
1402
|
+
if not VECTOR_STATE['used'] and VECTOR_STATE['fallback'] is None:
|
|
1403
|
+
# 没异常也没返回 —— 最常见的原因是 embedding 拿到空/全零(上游失败)。
|
|
1404
|
+
VECTOR_STATE['fallback'] = '未做向量检索(embedding 为空或全零)'
|
|
630
1405
|
|
|
631
1406
|
# Fallback: keyword search
|
|
632
1407
|
results = keyword_search(query, max(top_k, RERANK_CANDIDATES) if rerank_results else top_k,
|
|
633
1408
|
line=line)
|
|
634
1409
|
if rerank_results:
|
|
635
1410
|
# 关键词打分比余弦更粗,重排在这里的收益反而更大。
|
|
636
|
-
|
|
1411
|
+
results = rerank_dispatch(query, results, create_client(),
|
|
1412
|
+
pool_size=rerank_pool)
|
|
1413
|
+
if dedup:
|
|
1414
|
+
results = dedup_results(results)
|
|
1415
|
+
if with_text:
|
|
1416
|
+
results, _missing, _fetched = attach_full_text(results, with_text,
|
|
1417
|
+
fetch_missing=fetch_missing)
|
|
1418
|
+
if mmr and vectors is not None:
|
|
1419
|
+
# 向量没拿到时**不能**做 MMR(它需要矩阵);以前这里会 NameError。
|
|
1420
|
+
results = mmr_select(results, vectors, top_k)
|
|
1421
|
+
for _item in results: # `_row` 只是内部用的,别漏进输出
|
|
1422
|
+
_item.pop('_row', None)
|
|
637
1423
|
return results[:top_k]
|
|
638
1424
|
|
|
639
1425
|
|
|
@@ -651,6 +1437,17 @@ def line_counts() -> dict:
|
|
|
651
1437
|
meta = json.loads(META_FILE.read_text(encoding='utf-8'))
|
|
652
1438
|
except Exception:
|
|
653
1439
|
return counts
|
|
1440
|
+
return _counts_from_meta(meta)
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def _counts_from_meta(meta) -> dict:
|
|
1444
|
+
"""从**已经解析好**的 meta 算每线条数(`line_counts` 与 `search()` 共用这一份)。
|
|
1445
|
+
|
|
1446
|
+
抽出来是让 `search()` 复用自己刚解析的那份:它原来在返回之后又被 `main()` 调
|
|
1447
|
+
`line_counts()` **再解析一遍**同一个 13.5 MB 文件(实测 69 + 56 ms)。两份实现分开
|
|
1448
|
+
写更糟 —— 两处解析同一份文件迟早会漂,而漂的表现是"条数对不上但谁都不报错"。
|
|
1449
|
+
"""
|
|
1450
|
+
counts = {line: 0 for line in LINE_CHOICES}
|
|
654
1451
|
for item in meta:
|
|
655
1452
|
t = str(item.get('type', ''))
|
|
656
1453
|
for line, types in LINE_TYPES.items():
|
|
@@ -699,6 +1496,31 @@ def main():
|
|
|
699
1496
|
p.add_argument('--top-k', type=int, default=10)
|
|
700
1497
|
p.add_argument('--no-rerank', action='store_true',
|
|
701
1498
|
help='不调用决策模型重排,只按向量/关键词相似度返回(回退到引入重排之前)')
|
|
1499
|
+
p.add_argument('--mmr', action='store_true',
|
|
1500
|
+
help='对最终结果做 MMR 去冗余(λ=0.7):压掉近似的同领域邻居,可能牺牲单点召回')
|
|
1501
|
+
p.add_argument('--fetch-missing', nargs='?', type=int, const=3, default=0,
|
|
1502
|
+
metavar='N',
|
|
1503
|
+
help='正文短于 300 字的那些按 url 抓一次(最多 N 条,默认 3);复用日报的缓存')
|
|
1504
|
+
p.add_argument('--with-text', nargs='?', type=int, const=FULL_TEXT_LIMIT,
|
|
1505
|
+
default=0, metavar='N',
|
|
1506
|
+
help='给每条命中挂上本地正文字段 fullText/sourceFile/url(默认不给;'
|
|
1507
|
+
'N 控制每条最多多少字,默认 %d)' % FULL_TEXT_LIMIT)
|
|
1508
|
+
p.add_argument('--no-dedup', action='store_true',
|
|
1509
|
+
help='不折叠同文本的多号转载(默认折叠,并在结果里给 duplicateCount)')
|
|
1510
|
+
p.add_argument('--rerank-pool', type=int, default=RERANK_CANDIDATES,
|
|
1511
|
+
help='重排看多少条候选(默认 %d;池子越大越准、越贵)' % RERANK_CANDIDATES)
|
|
1512
|
+
p.add_argument('--no-hybrid', action='store_true',
|
|
1513
|
+
help='不融合本地 BM25 名次(默认融合:加门控后实测三个台子都只增不减)')
|
|
1514
|
+
p.add_argument('--from', dest='from_source',
|
|
1515
|
+
help='只查某个公众号(按 id/文件名的子串匹配,不走语义)')
|
|
1516
|
+
p.add_argument('--phrase', action='store_true',
|
|
1517
|
+
help='把这串当**原样短语**在本地定位(FTS5/trigram;不走向量、不出境、不花钱):'
|
|
1518
|
+
'回答"哪个号在哪天原样写过它"。查不到原样不等于查不到语义,收窄了问题范围')
|
|
1519
|
+
|
|
1520
|
+
pp = subparsers.add_parser('phrase-build',
|
|
1521
|
+
help='构建/重建精确短语索引(本地,不调用任何模型)')
|
|
1522
|
+
add_line_arg(pp)
|
|
1523
|
+
|
|
702
1524
|
add_line_arg(p)
|
|
703
1525
|
|
|
704
1526
|
args = parser.parse_args()
|
|
@@ -707,34 +1529,87 @@ def main():
|
|
|
707
1529
|
if getattr(args, 'days', 90) < 1 or getattr(args, 'article_days', 30) < 1:
|
|
708
1530
|
json_output({'error': '--days 与 --article-days 都要 ≥ 1(0 天等于索引一个空库)'})
|
|
709
1531
|
return
|
|
710
|
-
|
|
711
|
-
|
|
1532
|
+
# 读不到配置不该让**不需要配置**的子命令崩:`phrase-build` 与 `search --phrase` 只读本地索引,
|
|
1533
|
+
# 它们没有任何理由要求 `~/.weflow-cli/config.json` 存在(CI 上没有那个文件、本地有,于是这条
|
|
1534
|
+
# 会"本地绿、CI 红")。需要 key 的那条路会在下面自己报"缺 key"。
|
|
1535
|
+
try:
|
|
1536
|
+
config = load_config()
|
|
1537
|
+
except Exception:
|
|
1538
|
+
config = {}
|
|
1539
|
+
# `getattr`:不是每个子命令都有 `--api-key`(`phrase-build` 不需要 key —— 它不调模型)。
|
|
1540
|
+
# 无条件读会让"建本地短语索引"直接崩在一个**与它无关**的参数上。
|
|
1541
|
+
api_key = (getattr(args, 'api_key', '') or os.environ.get('DASHSCOPE_API_KEY', '')
|
|
1542
|
+
or get_dashscope_key(config))
|
|
712
1543
|
|
|
713
1544
|
if args.command == 'build':
|
|
714
1545
|
result = build_index(api_key, full=args.full, days=args.days, article_days=args.article_days)
|
|
715
1546
|
elif args.command == 'update':
|
|
716
1547
|
result = build_index(api_key, full=False, days=args.days, article_days=args.article_days)
|
|
1548
|
+
elif args.command == 'phrase-build':
|
|
1549
|
+
result = build_phrase_index(line=args.line)
|
|
1550
|
+
result['success'] = 'error' not in result
|
|
717
1551
|
elif args.command == 'search':
|
|
718
1552
|
query = args.query or os.environ.get('WEFLOW_SEARCH_QUERY', '')
|
|
719
1553
|
if not query:
|
|
720
1554
|
result = {"error": "missing search query"}
|
|
1555
|
+
elif args.phrase:
|
|
1556
|
+
# 原样定位:**本地、无 key、不出境**。所以它不校验 `api_key`,
|
|
1557
|
+
# 也不该被当成"向量搜索的降级版"——两者问的是不同的问题。
|
|
1558
|
+
# 向量检索的那些旗标在这条路上不生效。CLI 会直接拒绝这种组合;这里兜住
|
|
1559
|
+
# **直接调脚本**的人:静默忽略会让人把默认行为当成"调过的结果"。
|
|
1560
|
+
ignored_flags = [name for name, on in (
|
|
1561
|
+
('--mmr', getattr(args, 'mmr', False)),
|
|
1562
|
+
('--no-rerank', getattr(args, 'no_rerank', False)),
|
|
1563
|
+
('--no-hybrid', getattr(args, 'no_hybrid', False)),
|
|
1564
|
+
('--no-dedup', getattr(args, 'no_dedup', False)),
|
|
1565
|
+
('--with-text', getattr(args, 'with_text', 0)),
|
|
1566
|
+
('--fetch-missing', getattr(args, 'fetch_missing', 0)),
|
|
1567
|
+
('--rerank-pool', getattr(args, 'rerank_pool', RERANK_CANDIDATES) != RERANK_CANDIDATES),
|
|
1568
|
+
) if on]
|
|
1569
|
+
if ignored_flags:
|
|
1570
|
+
print('[WARN] --phrase only locates verbatim text; ignored: %s'
|
|
1571
|
+
% ', '.join(ignored_flags), file=sys.stderr)
|
|
1572
|
+
result = phrase_search(query, limit=args.top_k, line=args.line,
|
|
1573
|
+
from_source=args.from_source)
|
|
1574
|
+
result['success'] = 'error' not in result
|
|
721
1575
|
else:
|
|
722
1576
|
# **输出带上是哪条线**(`--line` 默认文章线)。少了这一层,`--line chat`
|
|
723
1577
|
# 空成一片时读的人分不清"没命中"和"这条线根本没进索引"——而两者的下一步
|
|
724
1578
|
# (换个词 / 重建索引)是两回事。
|
|
725
1579
|
rows = search(query, api_key, top_k=args.top_k,
|
|
726
|
-
rerank_results=not args.no_rerank, line=args.line
|
|
727
|
-
|
|
1580
|
+
rerank_results=not args.no_rerank, line=args.line,
|
|
1581
|
+
hybrid=not args.no_hybrid, from_source=args.from_source,
|
|
1582
|
+
rerank_pool=args.rerank_pool,
|
|
1583
|
+
dedup=not args.no_dedup,
|
|
1584
|
+
with_text=args.with_text,
|
|
1585
|
+
fetch_missing=args.fetch_missing,
|
|
1586
|
+
mmr=args.mmr)
|
|
1587
|
+
# `search()` 本来就解析过 meta,顺手把每线条数算在 `VECTOR_STATE['counts']` 里;
|
|
1588
|
+
# 只有它没算(走了关键词 fallback)时才重新解析。省掉一次 13.5 MB 的解析。
|
|
1589
|
+
counts = VECTOR_STATE.get('counts') or line_counts()
|
|
728
1590
|
result = {
|
|
729
1591
|
'success': True,
|
|
730
1592
|
'line': args.line,
|
|
731
1593
|
'lineLabel': line_label(args.line),
|
|
1594
|
+
'hybrid': not args.no_hybrid,
|
|
1595
|
+
# **这次到底走没走向量**:没走时把原因写出来。缺了它,"只搜到关键词结果"
|
|
1596
|
+
# 与"关键词结果就是这样"在返回里长得一模一样。
|
|
1597
|
+
'vector': {'used': VECTOR_STATE['used'], 'fallback': VECTOR_STATE['fallback']},
|
|
1598
|
+
'rerank': dict(RERANK_STATE),
|
|
1599
|
+
'from': args.from_source,
|
|
1600
|
+
'dedup': not args.no_dedup,
|
|
1601
|
+
'withText': args.with_text,
|
|
1602
|
+
'fetchMissing': args.fetch_missing,
|
|
1603
|
+
'mmr': args.mmr,
|
|
732
1604
|
'results': rows,
|
|
733
1605
|
'indexCounts': counts,
|
|
734
1606
|
}
|
|
735
1607
|
if not rows and counts['all'] == 0:
|
|
736
1608
|
result['note'] = ('语义索引还没建(output/.semantic_index 不存在)——'
|
|
737
1609
|
'先跑 weflow-cli search-index')
|
|
1610
|
+
elif not rows and args.from_source:
|
|
1611
|
+
result['note'] = ('索引里没有「%s」的记录 —— 按号筛的是文件名里的号名,'
|
|
1612
|
+
'也可能是建索引那天的窗口没覆盖到它' % args.from_source)
|
|
738
1613
|
elif not rows and args.line != 'all' and counts[args.line] == 0:
|
|
739
1614
|
result['note'] = ('索引里一条「%s」的记录都没有(建索引那天的窗口可能没覆盖到),'
|
|
740
1615
|
'先看 --line all' % line_label(args.line))
|