argo-search 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,508 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ search.py — Unified Search v2 CLI 主入口 & 执行编排
4
+
5
+ 职责:
6
+ - 解析命令行参数
7
+ - 通过 route.py 做路由决策(含预算模式)
8
+ - 通过 cache.py 做双层缓存
9
+ - 通过 engines.py 执行引擎搜索
10
+ - RRF 融合 + Bocha Reranker 精排
11
+ - 通过 adaptive.py 记录引擎表现
12
+ - 输出统一 JSON / 文本格式
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+ import time
22
+ from concurrent.futures import ThreadPoolExecutor, as_completed
23
+ from enum import Enum
24
+ from typing import Any, Callable, Optional
25
+
26
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
27
+ sys.path.insert(0, SCRIPT_DIR)
28
+
29
+ from cache import SearchCache
30
+ from route import route_query
31
+ from engines import search as engine_search, available_engines
32
+ from config import get_execution_config, get_cost_factor
33
+
34
+
35
+ # ── 进度阶段 ──────────────────────────────────────────────────────────────────
36
+
37
+ class Stage(str, Enum):
38
+ START = "start"
39
+ CACHE_HIT = "cache_hit"
40
+ ROUTING = "routing"
41
+ SEARCHING = "searching"
42
+ MERGING = "merging"
43
+ DONE = "done"
44
+
45
+
46
+ # ── RRF 融合 ───────────────────────────────────────────────────────────────────
47
+
48
+ def rrf_merge(ranked_lists: list[list[dict[str, Any]]], k: int = 60) -> list[dict[str, Any]]:
49
+ """Reciprocal Rank Fusion 合并多引擎结果。"""
50
+ scores: dict[str, float] = {}
51
+ items: dict[str, dict[str, Any]] = {}
52
+
53
+ for results in ranked_lists:
54
+ for i, r in enumerate(results):
55
+ url = r.get("url", "") or f"__title__:{r.get('title', '')}"
56
+ scores[url] = scores.get(url, 0.0) + 1.0 / (k + i + 1)
57
+ if url not in items:
58
+ items[url] = dict(r)
59
+ else:
60
+ if r.get("score", 0) > items[url].get("score", 0):
61
+ items[url].update(r)
62
+ sources = {items[url].get("source", ""), r.get("source", "")}
63
+ items[url]["source"] = "/".join(s for s in sources if s)
64
+
65
+ ranked = sorted(scores.items(), key=lambda x: -x[1])
66
+ return [items[url] for url, _ in ranked]
67
+
68
+
69
+ def deduplicate_by_url(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
70
+ """URL 去重。"""
71
+ seen: set[str] = set()
72
+ out = []
73
+ for r in results:
74
+ key = r.get("url", "") or f"title:{r.get('title', '')}"
75
+ if key not in seen:
76
+ seen.add(key)
77
+ out.append(r)
78
+ return out
79
+
80
+
81
+ # ── Bocha Reranker ──────────────────────────────────────────────────────────────
82
+
83
+ def rerank_results(query: str, results: list[dict[str, Any]],
84
+ top_n: int = 10, timeout: float = 5) -> list[dict[str, Any]]:
85
+ """使用博查语义排序模型对搜索结果二次精排。"""
86
+ if not results or len(results) <= 1:
87
+ return results
88
+
89
+ api_key = os.environ.get("BOCHA_API_KEY", "")
90
+ if not api_key:
91
+ return results
92
+
93
+ documents = []
94
+ for r in results:
95
+ doc_text = f"{r.get('title', '')} {r.get('snippet', '')}".strip()
96
+ documents.append(doc_text or "empty")
97
+
98
+ import urllib.request
99
+ payload = json.dumps({
100
+ "model": "gte-rerank", "query": query,
101
+ "documents": documents[:50],
102
+ "top_n": min(top_n, len(documents)),
103
+ "return_documents": False,
104
+ }).encode("utf-8")
105
+
106
+ req = urllib.request.Request(
107
+ "https://api.bocha.cn/v1/rerank", data=payload,
108
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
109
+ )
110
+ try:
111
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
112
+ data = json.loads(resp.read().decode("utf-8"))
113
+ rerank_results_list = data.get("data", {}).get("results", [])
114
+ if not rerank_results_list:
115
+ return results
116
+ scored = []
117
+ for rr in rerank_results_list:
118
+ idx = rr.get("index", -1)
119
+ score = rr.get("relevance_score", 0)
120
+ if 0 <= idx < len(results):
121
+ item = dict(results[idx])
122
+ orig_score = item.get("score", 0) or 0
123
+ item["score"] = round(score * 0.7 + orig_score * 0.3, 4)
124
+ scored.append(item)
125
+ if scored:
126
+ scored.sort(key=lambda x: x.get("score", 0), reverse=True)
127
+ return scored[:top_n]
128
+ except Exception:
129
+ pass
130
+ return results
131
+
132
+
133
+ # ── 执行层 ─────────────────────────────────────────────────────────────────────
134
+
135
+ def execute_search(query: str, decision: dict[str, Any], max_results: int,
136
+ timeout: int, depth: str, cache: SearchCache, skip_cache: bool,
137
+ mode: str = "auto",
138
+ on_progress: Optional[Callable[[Stage, dict[str, Any]], None]] = None) -> dict[str, Any]:
139
+ """执行搜索:缓存 → 引擎 → 融合 → 精排 → 写缓存。"""
140
+ domain = decision.get("domain") or "general"
141
+ engine_label = decision.get("engine", "auto")
142
+ engines_combo = decision.get("engines_combo", decision.get("engines", [engine_label]))
143
+ engines = engines_combo
144
+ parallel = decision.get("parallel", False) and len(engines) > 1
145
+
146
+ if on_progress:
147
+ on_progress(Stage.START, {"query": query})
148
+
149
+ cache_engine_key = "+".join(sorted(engines)) if len(engines) > 1 else engines[0]
150
+
151
+ if on_progress:
152
+ on_progress(Stage.ROUTING, {"domain": domain, "engine": engine_label, "engines": engines})
153
+
154
+ # 缓存命中
155
+ if not skip_cache:
156
+ hit = cache.get(query, cache_engine_key, max_results, domain=domain)
157
+ if hit:
158
+ if on_progress:
159
+ on_progress(Stage.CACHE_HIT, {"cache_level": hit.get("_cache_level", "L?")})
160
+ tfidf_scores = decision.get("tfidf_scores", [])
161
+ if tfidf_scores and all(s.get("score", 0) == 0 for s in tfidf_scores):
162
+ tfidf_scores = []
163
+ return {
164
+ "query": query, "engine": engine_label, "engines": engines,
165
+ "engines_combo": engines_combo, "cached": True,
166
+ "cache_level": hit.get("_cache_level", "L?"),
167
+ "domain": domain, "elapsed_ms": 0,
168
+ "tfidf_scores": tfidf_scores,
169
+ "results": hit.get("results", []),
170
+ "count": len(hit.get("results", [])),
171
+ "mode": mode,
172
+ }
173
+
174
+ if on_progress:
175
+ on_progress(Stage.SEARCHING, {"engines": engines})
176
+
177
+ t0 = time.time()
178
+ raw_results: dict[str, list[dict[str, Any]]] = {}
179
+
180
+ exec_cfg = get_execution_config()
181
+ retry_count = exec_cfg.get("retry_count", 0)
182
+
183
+ def _exec_engine(eng: str, retries: int = retry_count) -> list[dict[str, Any]]:
184
+ for attempt in range(retries + 1):
185
+ res = engine_search(query, eng, n=max_results, timeout=timeout, depth=depth, mode=mode)
186
+ if res and any("error" not in r for r in res):
187
+ return res
188
+ if depth != "balanced":
189
+ return engine_search(query, eng, n=max_results, timeout=timeout, depth="balanced", mode=mode)
190
+ return res if res else []
191
+
192
+ if parallel:
193
+ with ThreadPoolExecutor(max_workers=min(len(engines), 3)) as ex:
194
+ futures = {ex.submit(_exec_engine, eng): eng for eng in engines}
195
+ try:
196
+ for fut in as_completed(futures, timeout=timeout + 2):
197
+ eng = futures[fut]
198
+ try:
199
+ raw_results[eng] = fut.result()
200
+ except Exception as e:
201
+ raw_results[eng] = [{"error": str(e), "source": eng}]
202
+ except TimeoutError:
203
+ for fut in futures:
204
+ if not fut.done():
205
+ fut.cancel()
206
+ eng = futures[fut]
207
+ raw_results[eng] = [{"error": "timeout", "source": eng}]
208
+ for fut in futures:
209
+ if not fut.done():
210
+ fut.cancel()
211
+ else:
212
+ for eng in engines:
213
+ res = _exec_engine(eng)
214
+ raw_results[eng] = res
215
+ if res and any("error" not in r for r in res):
216
+ break
217
+
218
+ elapsed = int((time.time() - t0) * 1000)
219
+
220
+ # 融合
221
+ valid_lists = [res for res in raw_results.values()
222
+ if res and not any("error" in r for r in res)]
223
+ if len(valid_lists) > 1:
224
+ merged = rrf_merge(valid_lists)[:max_results]
225
+ elif valid_lists:
226
+ merged = deduplicate_by_url(valid_lists[0])[:max_results]
227
+ else:
228
+ merged = []
229
+
230
+ # Reranker 精排
231
+ if merged and len(merged) > 1:
232
+ merged = rerank_results(query, merged, top_n=max_results)
233
+
234
+ # 按 score 排序
235
+ if merged:
236
+ merged.sort(key=lambda r: abs(r.get("score", 0) or 0), reverse=True)
237
+ merged = merged[:max_results]
238
+
239
+ # 内嵌可信度评分(快速版本:authority + freshness,不含 cross_validation)
240
+ if merged:
241
+ try:
242
+ from evidence import score_authority, score_freshness
243
+ for r in merged:
244
+ url = r.get("url", "")
245
+ source = r.get("source", "")
246
+ auth = score_authority(url, source)
247
+ fresh = score_freshness(r)
248
+ r["authority"] = auth["score"]
249
+ r["authority_tier"] = auth["tier"]
250
+ r["freshness"] = fresh["score"]
251
+ except Exception:
252
+ pass # evidence 模块不可用时跳过
253
+
254
+ if on_progress:
255
+ on_progress(Stage.MERGING, {"count": len(merged)})
256
+
257
+ result_payload = {
258
+ "results": merged, "engines_used": list(raw_results.keys()), "domain": domain,
259
+ }
260
+
261
+ # 写缓存
262
+ if not skip_cache:
263
+ effective_ttl = None
264
+ if elapsed > 2000:
265
+ multiplier = min(2 ** (elapsed // 2000), 8)
266
+ base_ttl = cache.resolve_ttl(domain)
267
+ effective_ttl = base_ttl * multiplier
268
+ cache.set(query, cache_engine_key, max_results, result_payload,
269
+ domain=domain, ttl=effective_ttl)
270
+
271
+ # 记录自适应学习数据
272
+ try:
273
+ from adaptive import get_learner
274
+ learner = get_learner()
275
+ for eng, res in raw_results.items():
276
+ success = bool(res and any("error" not in r for r in res))
277
+ latency = elapsed / max(len(raw_results), 1)
278
+ cost = get_cost_factor(eng)
279
+ learner.record(eng, success=success, latency_ms=latency, cost=0.0 if cost >= 0.85 else 0.001)
280
+ except Exception:
281
+ pass
282
+
283
+ if on_progress:
284
+ on_progress(Stage.DONE, {"count": len(merged), "elapsed_ms": elapsed})
285
+
286
+ tfidf_scores = decision.get("tfidf_scores", [])
287
+ if tfidf_scores and all(s.get("score", 0) == 0 for s in tfidf_scores):
288
+ tfidf_scores = []
289
+
290
+ return {
291
+ "query": query, "engine": engine_label, "engines": engines,
292
+ "engines_combo": engines_combo, "cached": False,
293
+ "domain": domain, "elapsed_ms": elapsed,
294
+ "tfidf_scores": tfidf_scores, "results": merged,
295
+ "count": len(merged), "engines_used": list(raw_results.keys()),
296
+ "errors": _collect_errors(raw_results), "mode": mode,
297
+ }
298
+
299
+
300
+ def _collect_errors(raw_results: dict[str, list[dict[str, Any]]]) -> list[str]:
301
+ errors = []
302
+ for eng, res in raw_results.items():
303
+ for r in res:
304
+ if isinstance(r, dict) and "error" in r:
305
+ errors.append(f"{eng}: {r['error']}")
306
+ return errors
307
+
308
+
309
+ # ── 统一入口 ──────────────────────────────────────────────────────────────────
310
+
311
+ def super_search(query: str, engine: str = "auto", n: int = 5, explain: bool = False,
312
+ skip_cache: bool = False, timeout: int = 10,
313
+ depth: str = "fast", mode: str = "auto", local_first: bool = False,
314
+ rewrite: bool = True, cache: Any = None) -> dict[str, Any]:
315
+ """统一搜索便捷入口。
316
+
317
+ Args:
318
+ query: 搜索查询词
319
+ engine: 指定引擎(默认 auto)
320
+ n: 最大结果数
321
+ explain: 是否输出路由解释
322
+ skip_cache: 是否跳过缓存
323
+ timeout: 超时
324
+ depth: 搜索深度
325
+ mode: 预算模式
326
+ local_first: 强制本地优先
327
+ rewrite: 是否自动改写查询(默认 True)
328
+ """
329
+ cache = cache if cache is not None else SearchCache()
330
+ rewrite_result = None
331
+
332
+ # 查询改写:追加领域关键词提升搜索质量
333
+ if rewrite:
334
+ try:
335
+ from query_rewriter import rewrite_query as do_rewrite
336
+ rewrite_result = do_rewrite(query)
337
+ if rewrite_result["rewritten"] and rewrite_result["confidence"] >= 0.7:
338
+ query = rewrite_result["rewritten"]
339
+ except Exception:
340
+ pass # 改写失败不影响搜索
341
+
342
+ if local_first:
343
+ decision = route_query(query, engine_override="local_search", mode=mode)
344
+ else:
345
+ decision = route_query(query, engine_override=engine, mode=mode)
346
+ if explain:
347
+ combo = decision.get('engines_combo', decision.get('engines', []))
348
+ print(
349
+ f"[路由] {decision['reason']} → engine={decision['engine']} "
350
+ f"combo={combo} domain={decision.get('domain')} "
351
+ f"tfidf={decision.get('tfidf_scores', [])} mode={mode}",
352
+ file=sys.stderr,
353
+ )
354
+ result = execute_search(query=query, decision=decision, max_results=n,
355
+ timeout=timeout, depth=depth, cache=cache,
356
+ skip_cache=skip_cache, mode=mode)
357
+ if rewrite_result and rewrite_result["rewritten"]:
358
+ result["rewritten_query"] = {
359
+ "original": rewrite_result["original"],
360
+ "rewritten": rewrite_result["rewritten"],
361
+ "confidence": rewrite_result["confidence"],
362
+ "reason": rewrite_result["reason"],
363
+ }
364
+ return result
365
+
366
+
367
+ # ── 输出格式化 ─────────────────────────────────────────────────────────────────
368
+
369
+ def format_text_output(results: dict[str, Any]) -> str:
370
+ lines = []
371
+ count = results.get("count", 0)
372
+ elapsed = results.get("elapsed_ms", 0)
373
+ engine = results.get("engine", "?")
374
+ cached = results.get("cached", False)
375
+ cache_level = results.get("cache_level", "")
376
+ domain = results.get("domain", "")
377
+ mode = results.get("mode", "auto")
378
+
379
+ header = f"=== {count} results ({elapsed}ms via {engine})"
380
+ if cached:
381
+ header += f" [CACHE {cache_level}]"
382
+ elif domain:
383
+ header += f" [domain:{domain}]"
384
+ if mode != "auto":
385
+ header += f" [mode:{mode}]"
386
+ lines.append(header)
387
+
388
+ for err in results.get("errors", [])[:3]:
389
+ lines.append(f" [ERROR] {err}")
390
+
391
+ for r in results.get("results", []):
392
+ score = r.get("score", 0)
393
+ title = r.get("title", "?")[:80]
394
+ url = r.get("url", "")
395
+ prefix = f"[{score:.2f}]" if score else "[?]"
396
+ lines.append(f" {prefix} {title}")
397
+ if url:
398
+ lines.append(f" {url}")
399
+ snippet = r.get("snippet", "")
400
+ if snippet:
401
+ lines.append(f" {snippet[:120]}")
402
+
403
+ return "\n".join(lines)
404
+
405
+
406
+ # ── CLI 主入口 ─────────────────────────────────────────────────────────────────
407
+
408
+ def main():
409
+ parser = argparse.ArgumentParser(
410
+ description="Unified Search v2 — 统一搜索 CLI",
411
+ formatter_class=argparse.RawDescriptionHelpFormatter,
412
+ epilog="""
413
+ 示例:
414
+ python3 search.py "python async"
415
+ python3 search.py "英伟达财报" --explain --json
416
+ python3 search.py "基金推荐" --mode fast
417
+ python3 search.py "AAPL" --engine anysearch --domain finance --sub_domain finance.us_stock
418
+ """,
419
+ )
420
+ parser.add_argument("query", nargs="?")
421
+ parser.add_argument("--engine", "-e", default="auto")
422
+ parser.add_argument("--max-results", "-n", type=int, default=5)
423
+ parser.add_argument("--depth", "-d", default="fast",
424
+ choices=["fast", "balanced", "deep"])
425
+ parser.add_argument("--no-cache", action="store_true")
426
+ parser.add_argument("--explain", action="store_true")
427
+ parser.add_argument("--json", action="store_true", dest="json_output")
428
+ parser.add_argument("--timeout", "-t", type=int, default=10)
429
+ parser.add_argument("--list-engines", action="store_true")
430
+ parser.add_argument("--mode", default="auto",
431
+ choices=["fast", "auto", "deep", "budget"],
432
+ help="预算模式: fast=免费优先, auto=成本感知, deep=质量优先, budget=配额控制")
433
+ parser.add_argument("--local-first", action="store_true",
434
+ help="强制优先使用 local_search 零成本聚合引擎")
435
+ parser.add_argument("--domain", default="", help="AnySearch 垂直域")
436
+ parser.add_argument("--sub_domain", default="", help="AnySearch 子域")
437
+ parser.add_argument("--progress", action="store_true")
438
+
439
+ args = parser.parse_args()
440
+
441
+ if args.list_engines:
442
+ print(json.dumps(available_engines(), ensure_ascii=False, indent=2))
443
+ return
444
+
445
+ if not args.query:
446
+ parser.error("必须提供搜索关键词")
447
+
448
+ cache = SearchCache()
449
+ if args.local_first:
450
+ decision = route_query(args.query, engine_override="local_search", mode=args.mode)
451
+ else:
452
+ decision = route_query(args.query, engine_override=args.engine, mode=args.mode)
453
+
454
+ # 查询改写
455
+ rewrite_result = None
456
+ try:
457
+ from query_rewriter import rewrite_query as do_rewrite
458
+ rewrite_result = do_rewrite(args.query)
459
+ if rewrite_result["rewritten"] and rewrite_result["confidence"] >= 0.7:
460
+ search_query = rewrite_result["rewritten"]
461
+ else:
462
+ search_query = args.query
463
+ except Exception:
464
+ search_query = args.query
465
+
466
+ if args.explain:
467
+ combo = decision.get('engines_combo', decision.get('engines', []))
468
+ print(
469
+ f"[路由] {decision['reason']} → engine={decision['engine']} "
470
+ f"combo={combo} domain={decision.get('domain')} "
471
+ f"tfidf={decision.get('tfidf_scores', [])} mode={args.mode}",
472
+ file=sys.stderr,
473
+ )
474
+ if rewrite_result and rewrite_result["rewritten"]:
475
+ print(
476
+ f"[改写] {rewrite_result['original']} → {rewrite_result['rewritten']} "
477
+ f"({rewrite_result['confidence']:.0%})",
478
+ file=sys.stderr,
479
+ )
480
+
481
+ on_progress = None
482
+ if args.progress:
483
+ def on_progress(stage: Stage, data: dict[str, Any]):
484
+ print(f"[progress] {stage.value} {data}", file=sys.stderr)
485
+
486
+ results = execute_search(
487
+ query=search_query, decision=decision, max_results=args.max_results,
488
+ timeout=args.timeout, depth=args.depth, cache=cache,
489
+ skip_cache=args.no_cache, mode=args.mode, on_progress=on_progress,
490
+ )
491
+
492
+ if rewrite_result and rewrite_result["rewritten"]:
493
+ results["rewritten_query"] = {
494
+ "original": rewrite_result["original"],
495
+ "rewritten": rewrite_result["rewritten"],
496
+ "confidence": rewrite_result["confidence"],
497
+ "reason": rewrite_result["reason"],
498
+ }
499
+
500
+ if args.json_output:
501
+ public = {k: v for k, v in results.items() if not k.startswith("_")}
502
+ print(json.dumps(public, ensure_ascii=False, indent=2))
503
+ else:
504
+ print(format_text_output(results))
505
+
506
+
507
+ if __name__ == "__main__":
508
+ main()
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ search_types.py — 统一类型系统
4
+
5
+ 定义 SearchResult 数据类和 normalize_result 统一转换,
6
+ 将所有引擎的原始输出统一为结构化格式。
7
+
8
+ 字段规范:
9
+ - title: 结果标题(必填,截断到 200 字符)
10
+ - url: 结果链接(可选)
11
+ - snippet: 摘要/片段(截断到 300 字符)
12
+ - score: 相关性评分(默认 0.5)
13
+ - source: 引擎来源
14
+ - metadata: 元数据字典
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field, asdict
19
+ from typing import Any
20
+
21
+
22
+ @dataclass
23
+ class SearchResult:
24
+ """统一的搜索结果数据类。"""
25
+ title: str
26
+ url: str = ""
27
+ snippet: str = ""
28
+ score: float = 0.5
29
+ source: str = ""
30
+ metadata: dict = field(default_factory=dict)
31
+
32
+ def to_dict(self) -> dict[str, Any]:
33
+ """转为字典,省略空值/默认值字段。"""
34
+ d: dict[str, Any] = {"title": self.title}
35
+ if self.url:
36
+ d["url"] = self.url
37
+ if self.snippet:
38
+ d["snippet"] = self.snippet[:300]
39
+ if self.score != 0.5:
40
+ d["score"] = round(self.score, 3)
41
+ if self.source:
42
+ d["source"] = self.source
43
+ if self.metadata:
44
+ d["metadata"] = self.metadata
45
+ return d
46
+
47
+
48
+ def normalize_result(item: dict, engine: str, default_score: float = 0.5) -> SearchResult:
49
+ """将任意引擎输出统一为 SearchResult。
50
+
51
+ 处理字段名混乱(score/relevance_score/snippet/content/summary 混用)。
52
+ """
53
+ snippet = (
54
+ item.get("snippet")
55
+ or item.get("content")
56
+ or item.get("summary")
57
+ or item.get("description", "")
58
+ )
59
+ raw_score = item.get("score", item.get("relevance_score", default_score))
60
+ try:
61
+ score = float(raw_score) if isinstance(raw_score, (int, float, str)) else default_score
62
+ except (ValueError, TypeError):
63
+ score = default_score
64
+
65
+ return SearchResult(
66
+ title=item.get("title", "")[:200],
67
+ url=item.get("url", ""),
68
+ snippet=snippet[:300] if snippet else "",
69
+ score=score,
70
+ source=item.get("source", engine),
71
+ metadata=item.get("metadata", {}),
72
+ )