weflow-cli 1.11.1 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- body = '\n'.join(lines[body_start:body_start + 50]) if body_start else content[:1000]
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
- embeddings = get_embeddings(texts, api_key)
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
- # Load existing vectors
475
+ # 旧索引先读进来:增量时它是底,中途落盘与收尾都要以它开头。
398
476
  if VECTORS_FILE.exists() and not full:
399
- vectors = np.load(VECTORS_FILE)
400
- meta = json.loads(META_FILE.read_text(encoding='utf-8'))
477
+ base_vectors = np.load(VECTORS_FILE)
478
+ base_meta = json.loads(META_FILE.read_text(encoding='utf-8'))
401
479
  else:
402
- vectors = np.empty((0, EMBEDDING_DIM), dtype=np.float32)
403
- meta = []
404
-
405
- # Append new vectors
406
- new_vectors = np.array(embeddings, dtype=np.float32)
407
- vectors = np.vstack([vectors, new_vectors]) if vectors.size else new_vectors
408
- meta.extend(new_items)
409
-
410
- # Normalize vectors for cosine similarity
411
- norms = np.linalg.norm(vectors, axis=1, keepdims=True)
412
- norms[norms == 0] = 1 # Avoid division by zero
413
- vectors = vectors / norms
414
-
415
- # Save
416
- np.save(VECTORS_FILE, vectors)
417
- META_FILE.write_text(json.dumps(meta, ensure_ascii=False), encoding='utf-8')
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(meta),
422
- "new": len(new_items),
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
- RERANK_CANDIDATES = 20
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
- if types is not None:
609
- in_scope = np.array([str(m.get('type', '')) in types for m in meta], dtype=bool)
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, RERANK_CANDIDATES) if rerank_results else 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
- top_indices = np.argsort(similarities)[::-1][:pool_size]
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 = rerank(query, results, create_client())
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
- results = rerank(query, results, create_client())
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))