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,179 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ adaptive.py — Unified Search v2 自适应学习引擎
4
+
5
+ 增强(v2):
6
+ - success × latency × cost 三维评分
7
+ - 7天滑动窗口
8
+ - SQLite 持久化(跨进程复用)
9
+ - 预算模式感知(高 cost 引擎在 budget 模式下降权)
10
+
11
+ 评分公式:
12
+ score = success_rate × latency_factor × cost_factor
13
+ latency_factor = min(1.0, 2000 / avg_latency_ms) # 2s 内满分
14
+ cost_factor = free=1.0, low=0.85, paid=0.6
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import sqlite3
22
+ import threading
23
+ import time
24
+ from pathlib import Path
25
+ from typing import Optional
26
+
27
+ # ── 路径 ──────────────────────────────────────────────────────────────────────
28
+
29
+ DB_DIR = Path.home() / ".cache" / "unified-search"
30
+ DB_PATH = DB_DIR / "adaptive.db"
31
+ WINDOW_DAYS = 7
32
+
33
+ # ── 成本分级因子 ─────────────────────────────────────────────────────────────
34
+
35
+ COST_FACTORS = {"free": 1.0, "low": 0.85, "paid": 0.6}
36
+
37
+
38
+ class AdaptiveLearner:
39
+ """自适应学习引擎:追踪引擎表现并输出推荐分数。"""
40
+
41
+ def __init__(self):
42
+ self._lock = threading.Lock()
43
+ DB_DIR.mkdir(parents=True, exist_ok=True)
44
+ self._init_db()
45
+
46
+ def _connect(self) -> sqlite3.Connection:
47
+ conn = sqlite3.connect(str(DB_PATH), timeout=10)
48
+ conn.execute("PRAGMA journal_mode=WAL")
49
+ return conn
50
+
51
+ def _init_db(self):
52
+ with self._connect() as conn:
53
+ conn.execute("""
54
+ CREATE TABLE IF NOT EXISTS engine_perf (
55
+ engine TEXT NOT NULL,
56
+ success INTEGER NOT NULL,
57
+ latency_ms REAL NOT NULL,
58
+ cost REAL NOT NULL DEFAULT 0.0,
59
+ created_at REAL NOT NULL
60
+ )
61
+ """)
62
+ # 检查列是否存在(迁移)
63
+ cols = [r[1] for r in conn.execute("PRAGMA table_info(engine_perf)")]
64
+ if "created_at" not in cols:
65
+ conn.execute("ALTER TABLE engine_perf ADD COLUMN created_at REAL DEFAULT 0")
66
+ if "cost" not in cols:
67
+ conn.execute("ALTER TABLE engine_perf ADD COLUMN cost REAL DEFAULT 0.0")
68
+ # 检查索引
69
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_perf_engine_time ON engine_perf(engine, created_at)")
70
+ # 清理过期数据
71
+ cutoff = time.time() - WINDOW_DAYS * 86400
72
+ conn.execute("DELETE FROM engine_perf WHERE created_at < ?", (cutoff,))
73
+
74
+ def record(self, engine: str, success: bool, latency_ms: float, cost: float = 0.0):
75
+ """记录一次引擎调用结果。"""
76
+ with self._lock:
77
+ with self._connect() as conn:
78
+ conn.execute(
79
+ "INSERT INTO engine_perf (engine, success, latency_ms, cost, created_at) VALUES (?, ?, ?, ?, ?)",
80
+ (engine, 1 if success else 0, latency_ms, cost, time.time()),
81
+ )
82
+ conn.commit()
83
+
84
+ def get_score(self, engine: str) -> float:
85
+ """获取引擎的综合推荐分数(0.0 ~ 1.0)。"""
86
+ with self._lock:
87
+ cutoff = time.time() - WINDOW_DAYS * 86400
88
+ with self._connect() as conn:
89
+ row = conn.execute(
90
+ "SELECT COUNT(*), AVG(success), AVG(latency_ms), AVG(cost) FROM engine_perf WHERE engine = ? AND created_at > ?",
91
+ (engine, cutoff),
92
+ ).fetchone()
93
+ total, avg_success, avg_latency, avg_cost = row
94
+ if not total or total == 0:
95
+ return 0.5 # 无数据时中性分
96
+
97
+ success_rate = avg_success or 0.5
98
+ latency_factor = min(1.0, 2000.0 / max(avg_latency or 2000, 1))
99
+ # 成本因子:平均成本越低越好
100
+ cost_factor = max(0.3, 1.0 - (avg_cost or 0.0) * 10)
101
+
102
+ return round(success_rate * latency_factor * cost_factor, 4)
103
+
104
+ def get_ranking(self) -> list[tuple[str, float]]:
105
+ """获取所有引擎的推荐排名(降序)。"""
106
+ with self._lock:
107
+ cutoff = time.time() - WINDOW_DAYS * 86400
108
+ with self._connect() as conn:
109
+ rows = conn.execute(
110
+ "SELECT engine, COUNT(*), AVG(success), AVG(latency_ms), AVG(cost) "
111
+ "FROM engine_perf WHERE created_at > ? GROUP BY engine",
112
+ (cutoff,),
113
+ ).fetchall()
114
+
115
+ results = []
116
+ for engine, total, avg_success, avg_latency, avg_cost in rows:
117
+ if not total:
118
+ continue
119
+ success_rate = avg_success or 0.5
120
+ latency_factor = min(1.0, 2000.0 / max(avg_latency or 2000, 1))
121
+ cost_factor = max(0.3, 1.0 - (avg_cost or 0.0) * 10)
122
+ score = round(success_rate * latency_factor * cost_factor, 4)
123
+ results.append((engine, score))
124
+
125
+ results.sort(key=lambda x: -x[1])
126
+ return results
127
+
128
+ def get_stats(self) -> dict:
129
+ """获取所有引擎的统计信息。"""
130
+ ranking = self.get_ranking()
131
+ cutoff = time.time() - WINDOW_DAYS * 86400
132
+ with self._connect() as conn:
133
+ rows = conn.execute(
134
+ "SELECT engine, COUNT(*), AVG(success), AVG(latency_ms), AVG(cost) "
135
+ "FROM engine_perf WHERE created_at > ? GROUP BY engine",
136
+ (cutoff,),
137
+ ).fetchall()
138
+
139
+ stats = {}
140
+ for engine, total, avg_success, avg_latency, avg_cost in rows:
141
+ stats[engine] = {
142
+ "calls": total,
143
+ "success_rate": round(avg_success or 0, 3),
144
+ "avg_latency_ms": round(avg_latency or 0, 1),
145
+ "avg_cost": round(avg_cost or 0, 6),
146
+ "score": self.get_score(engine),
147
+ }
148
+ return stats
149
+
150
+ def should_use(self, engine: str, threshold: float = 0.3) -> bool:
151
+ """判断引擎是否值得使用(分数高于阈值)。"""
152
+ return self.get_score(engine) >= threshold
153
+
154
+
155
+ # ── 模块级单例 ─────────────────────────────────────────────────────────────────
156
+
157
+ _learner: Optional[AdaptiveLearner] = None
158
+
159
+
160
+ def get_learner() -> AdaptiveLearner:
161
+ global _learner
162
+ if _learner is None:
163
+ _learner = AdaptiveLearner()
164
+ return _learner
165
+
166
+
167
+ # ── CLI ────────────────────────────────────────────────────────────────────────
168
+
169
+ if __name__ == "__main__":
170
+ import sys
171
+ learner = get_learner()
172
+ if len(sys.argv) > 1 and sys.argv[1] == "stats":
173
+ print(json.dumps(learner.get_stats(), ensure_ascii=False, indent=2))
174
+ elif len(sys.argv) > 1 and sys.argv[1] == "rank":
175
+ ranking = learner.get_ranking()
176
+ for engine, score in ranking:
177
+ print(f"{engine:<15} {score:.4f}")
178
+ else:
179
+ print("用法: python3 adaptive.py stats|rank")
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ benchmark.py — 性能测试
4
+
5
+ 对比 v2.0 各关键路径延迟:
6
+ - TF-IDF 路由
7
+ - 双层缓存(L1 / L2)
8
+ - 引擎执行
9
+ - RRF 融合
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ import time
16
+ import os
17
+
18
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
19
+ sys.path.insert(0, SCRIPT_DIR)
20
+
21
+ from tfidf_router import get_router, semantic_route
22
+ from cache import SearchCache
23
+ from route import route_query
24
+
25
+
26
+ def bench_tfidf(n: int = 100) -> dict:
27
+ """测试 TF-IDF 路由延迟。"""
28
+ router = get_router()
29
+ queries = ["英伟达财报", "Python异步编程", "基金推荐", "latest AI research",
30
+ "北京旅游攻略", "股票行情", "transformer paper", "美联储加息"]
31
+ t0 = time.perf_counter()
32
+ for _ in range(n):
33
+ for q in queries:
34
+ router.route(q, top_k=3)
35
+ elapsed = (time.perf_counter() - t0) * 1000
36
+ total = n * len(queries)
37
+ return {
38
+ "total_calls": total,
39
+ "total_ms": round(elapsed, 2),
40
+ "avg_ms": round(elapsed / total, 4),
41
+ "qps": round(total / (elapsed / 1000), 0),
42
+ }
43
+
44
+
45
+ def bench_cache(n: int = 100) -> dict:
46
+ """测试双层缓存读写延迟。"""
47
+ cache = SearchCache()
48
+ # 预热
49
+ for i in range(10):
50
+ cache.set(f"query_{i}", "anysearch", 5,
51
+ {"results": [{"title": f"result_{i}", "url": f"http://x/{i}"}]},
52
+ domain="tech")
53
+
54
+ # GET 测试
55
+ t0 = time.perf_counter()
56
+ hits = 0
57
+ for _ in range(n):
58
+ for i in range(10):
59
+ hit = cache.get(f"query_{i}", "anysearch", 5, domain="tech")
60
+ if hit:
61
+ hits += 1
62
+ elapsed = (time.perf_counter() - t0) * 1000
63
+ total = n * 10
64
+ return {
65
+ "total_calls": total,
66
+ "hits": hits,
67
+ "hit_rate": round(hits / total, 3),
68
+ "total_ms": round(elapsed, 2),
69
+ "avg_ms": round(elapsed / total, 4),
70
+ }
71
+
72
+
73
+ def bench_route(n: int = 50) -> dict:
74
+ """测试端到端路由决策延迟。"""
75
+ queries = ["英伟达最新财报", "Python 异步编程最佳实践", "2026年AI芯片行业竞争格局",
76
+ "基金定投策略", "笔记本电脑推荐", "latest transformer paper"]
77
+ t0 = time.perf_counter()
78
+ for _ in range(n):
79
+ for q in queries:
80
+ route_query(q)
81
+ elapsed = (time.perf_counter() - t0) * 1000
82
+ total = n * len(queries)
83
+ return {
84
+ "total_calls": total,
85
+ "total_ms": round(elapsed, 2),
86
+ "avg_ms": round(elapsed / total, 4),
87
+ }
88
+
89
+
90
+ def main():
91
+ print("=" * 60)
92
+ print("Unified Search v2.0 — 性能基准测试")
93
+ print("=" * 60)
94
+
95
+ print("\n[1] TF-IDF 语义路由")
96
+ r1 = bench_tfidf()
97
+ print(f" 调用次数: {r1['total_calls']}")
98
+ print(f" 总耗时: {r1['total_ms']}ms")
99
+ print(f" 单次延迟: {r1['avg_ms']}ms")
100
+ print(f" 吞吐: {r1['qps']} qps")
101
+
102
+ print("\n[2] 双层缓存(L1/L2 读取)")
103
+ r2 = bench_cache()
104
+ print(f" 调用次数: {r2['total_calls']}")
105
+ print(f" 命中次数: {r2['hits']}")
106
+ print(f" 命中率: {r2['hit_rate']}")
107
+ print(f" 总耗时: {r2['total_ms']}ms")
108
+ print(f" 单次延迟: {r2['avg_ms']}ms")
109
+
110
+ print("\n[3] 端到端路由决策")
111
+ r3 = bench_route()
112
+ print(f" 调用次数: {r3['total_calls']}")
113
+ print(f" 总耗时: {r3['total_ms']}ms")
114
+ print(f" 单次延迟: {r3['avg_ms']}ms")
115
+
116
+ print("\n" + "=" * 60)
117
+ # 目标验证
118
+ print("目标验证:")
119
+ print(f" TF-IDF < 5ms: {'✓' if r1['avg_ms'] < 5 else '✗'} ({r1['avg_ms']}ms)")
120
+ print(f" 缓存命中 < 0.1ms: {'✓' if r2['avg_ms'] < 0.1 else '✗'} ({r2['avg_ms']}ms)")
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ cache.py — Unified Search v2 双层缓存引擎
4
+
5
+ 功能:
6
+ L1: 内存 LRU 热缓存(100 条),避免同进程重复查询
7
+ L2: SQLite 持久化缓存(TTL 可配置),跨进程复用
8
+ 分级 TTL:financial / news / realtime / general / research / evergreen
9
+ 大值 gzip 压缩(> 1KB)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import gzip
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import sqlite3
19
+ import threading
20
+ import time
21
+ from collections import OrderedDict
22
+ from typing import Optional
23
+
24
+ try:
25
+ from config import get_cache_config
26
+ except ImportError:
27
+ import sys
28
+ from pathlib import Path
29
+ sys.path.insert(0, str(Path(__file__).parent))
30
+ from config import get_cache_config
31
+
32
+
33
+ # ── 常量 ──────────────────────────────────────────────────────────────────────
34
+
35
+ DEFAULT_DB_PATH = "~/.cache/unified-search/cache.db"
36
+ DEFAULT_TTL = 3600
37
+ MAX_MEMORY_ITEMS = 100
38
+ MAX_DB_SIZE_MB = 100
39
+ COMPRESSION_THRESHOLD = 1024
40
+ COMPRESSION_LEVEL = 6
41
+
42
+ # 分级 TTL(秒)
43
+ CACHE_TIERS = {
44
+ "financial": 300, # 5 分钟
45
+ "news": 600, # 10 分钟
46
+ "realtime": 900, # 15 分钟
47
+ "general": 3600, # 1 小时
48
+ "research": 7200, # 2 小时
49
+ "evergreen": 86400, # 24 小时
50
+ }
51
+
52
+ # query domain → TTL tier 映射
53
+ DOMAIN_TIER_MAP = {
54
+ "stock_query": "financial",
55
+ "fund_query": "financial",
56
+ "financial_news": "news",
57
+ "zhihu_content": "general",
58
+ "tech_deep": "research",
59
+ "news_realtime": "realtime",
60
+ "general_search": "general",
61
+ "stock": "financial",
62
+ "fund": "financial",
63
+ "news": "realtime",
64
+ "tech": "research",
65
+ "general": "general",
66
+ "auto": "general",
67
+ }
68
+
69
+
70
+ # ── LRU 内存缓存 ───────────────────────────────────────────────────────────────
71
+
72
+ class LRUCache:
73
+ """基于 OrderedDict 的简单 LRU。"""
74
+
75
+ def __init__(self, max_size: int = MAX_MEMORY_ITEMS):
76
+ self._max_size = max_size
77
+ self._store: OrderedDict[str, dict] = OrderedDict()
78
+ self._hits = 0
79
+ self._misses = 0
80
+ self._lock = threading.RLock()
81
+
82
+ def get(self, key: str) -> Optional[dict]:
83
+ with self._lock:
84
+ if key in self._store:
85
+ self._hits += 1
86
+ self._store.move_to_end(key)
87
+ return self._store[key]
88
+ self._misses += 1
89
+ return None
90
+
91
+ def set(self, key: str, value: dict):
92
+ with self._lock:
93
+ if key in self._store:
94
+ self._store.move_to_end(key)
95
+ self._store[key] = value
96
+ else:
97
+ if len(self._store) >= self._max_size:
98
+ self._store.popitem(last=False)
99
+ self._store[key] = value
100
+
101
+ def clear(self):
102
+ with self._lock:
103
+ self._store.clear()
104
+ self._hits = 0
105
+ self._misses = 0
106
+
107
+ @property
108
+ def stats(self) -> dict:
109
+ with self._lock:
110
+ return {
111
+ "hits": self._hits,
112
+ "misses": self._misses,
113
+ "size": len(self._store),
114
+ "hit_rate": round(self._hits / max(self._hits + self._misses, 1), 3),
115
+ }
116
+
117
+
118
+ # ── SQLite 持久化缓存 ──────────────────────────────────────────────────────────
119
+
120
+ class SQLiteCache:
121
+ """SQLite 持久化缓存,支持 TTL 过期、大小限制、gzip 压缩。"""
122
+
123
+ SCHEMA_VERSION = 2
124
+
125
+ def __init__(self, db_path: str = DEFAULT_DB_PATH, ttl: int = DEFAULT_TTL):
126
+ self._db_path = os.path.expanduser(db_path)
127
+ self._ttl = ttl
128
+ self._lock = threading.RLock()
129
+ self._hits = 0
130
+ self._misses = 0
131
+ os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
132
+ self._init_db()
133
+
134
+ def _connect(self) -> sqlite3.Connection:
135
+ conn = sqlite3.connect(self._db_path, timeout=10)
136
+ conn.execute("PRAGMA journal_mode=WAL")
137
+ conn.execute("PRAGMA synchronous=NORMAL")
138
+ return conn
139
+
140
+ def _init_db(self):
141
+ with self._connect() as conn:
142
+ conn.executescript("""
143
+ CREATE TABLE IF NOT EXISTS search_cache (
144
+ key TEXT PRIMARY KEY,
145
+ query TEXT NOT NULL,
146
+ engine TEXT NOT NULL,
147
+ max_results INTEGER NOT NULL,
148
+ domain TEXT DEFAULT 'general',
149
+ value_blob BLOB NOT NULL,
150
+ compressed INTEGER DEFAULT 0,
151
+ ttl INTEGER DEFAULT 3600,
152
+ created_at REAL NOT NULL,
153
+ accessed_at REAL NOT NULL
154
+ );
155
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
156
+ """)
157
+ # 迁移:添加可能缺失的列
158
+ cols = [r[1] for r in conn.execute("PRAGMA table_info(search_cache)")]
159
+ if "domain" not in cols:
160
+ conn.execute("ALTER TABLE search_cache ADD COLUMN domain TEXT DEFAULT 'general'")
161
+ if "ttl" not in cols:
162
+ conn.execute("ALTER TABLE search_cache ADD COLUMN ttl INTEGER DEFAULT 3600")
163
+ conn.executescript("""
164
+ CREATE INDEX IF NOT EXISTS idx_search_cache_expires ON search_cache(created_at);
165
+ CREATE INDEX IF NOT EXISTS idx_search_cache_domain ON search_cache(domain);
166
+ """)
167
+ conn.execute("INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)",
168
+ ("schema_version", str(self.SCHEMA_VERSION)))
169
+
170
+ def _is_expired(self, created_at: float, ttl: int | None = None) -> bool:
171
+ return (time.time() - created_at) > (ttl if ttl is not None else self._ttl)
172
+
173
+ @staticmethod
174
+ def _serialize(value: dict) -> tuple[bytes, int]:
175
+ raw = json.dumps(value, ensure_ascii=False).encode("utf-8")
176
+ if len(raw) > COMPRESSION_THRESHOLD:
177
+ return gzip.compress(raw, COMPRESSION_LEVEL), 1
178
+ return raw, 0
179
+
180
+ @staticmethod
181
+ def _deserialize(blob: bytes, compressed: int) -> dict:
182
+ raw = gzip.decompress(blob) if compressed else blob
183
+ return json.loads(raw.decode("utf-8"))
184
+
185
+ def get(self, key: str) -> Optional[dict]:
186
+ with self._lock:
187
+ with self._connect() as conn:
188
+ row = conn.execute(
189
+ "SELECT value_blob, compressed, created_at, ttl FROM search_cache WHERE key = ?",
190
+ (key,),
191
+ ).fetchone()
192
+ if row is None:
193
+ self._misses += 1
194
+ return None
195
+ value_blob, compressed, created_at, ttl = row
196
+ if self._is_expired(created_at, ttl):
197
+ conn.execute("DELETE FROM search_cache WHERE key = ?", (key,))
198
+ conn.commit()
199
+ self._misses += 1
200
+ return None
201
+ conn.execute("UPDATE search_cache SET accessed_at = ? WHERE key = ?",
202
+ (time.time(), key))
203
+ conn.commit()
204
+ self._hits += 1
205
+ return self._deserialize(value_blob, compressed)
206
+
207
+ def set(self, key: str, query: str, engine: str, max_results: int,
208
+ value: dict, domain: str = "general", ttl: int | None = None):
209
+ with self._lock:
210
+ blob, compressed = self._serialize(value)
211
+ now = time.time()
212
+ effective_ttl = ttl if ttl is not None else self._ttl
213
+ with self._connect() as conn:
214
+ conn.execute(
215
+ """INSERT OR REPLACE INTO search_cache
216
+ (key, query, engine, max_results, domain, value_blob, compressed, ttl, created_at, accessed_at)
217
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
218
+ (key, query, engine, max_results, domain, blob, compressed, effective_ttl, now, now),
219
+ )
220
+ conn.commit()
221
+ self._evict_if_needed()
222
+
223
+ def _evict_if_needed(self):
224
+ """超过 100MB 时删除最旧的记录"""
225
+ if self.size_mb <= MAX_DB_SIZE_MB:
226
+ return
227
+ with self._connect() as conn:
228
+ target_mb = MAX_DB_SIZE_MB * 0.8
229
+ while self.size_mb > target_mb:
230
+ row = conn.execute("SELECT key FROM search_cache ORDER BY accessed_at ASC LIMIT 1").fetchone()
231
+ if not row:
232
+ break
233
+ conn.execute("DELETE FROM search_cache WHERE key = ?", (row[0],))
234
+ conn.commit()
235
+
236
+ def clear(self, older_than_hours: int = 24):
237
+ with self._lock:
238
+ cutoff = time.time() - older_than_hours * 3600
239
+ with self._connect() as conn:
240
+ conn.execute("DELETE FROM search_cache WHERE created_at < ?", (cutoff,))
241
+ conn.commit()
242
+
243
+ @property
244
+ def stats(self) -> dict:
245
+ with self._lock:
246
+ with self._connect() as conn:
247
+ row = conn.execute("SELECT COUNT(*), SUM(LENGTH(value_blob)) FROM search_cache").fetchone()
248
+ return {
249
+ "hits": self._hits,
250
+ "misses": self._misses,
251
+ "hit_rate": round(self._hits / max(self._hits + self._misses, 1), 3),
252
+ "size_mb": round((row[1] or 0) / 1024 / 1024, 2),
253
+ "entries": row[0] or 0,
254
+ }
255
+
256
+ @property
257
+ def size_mb(self) -> float:
258
+ with self._connect() as conn:
259
+ row = conn.execute("SELECT SUM(LENGTH(value_blob)) FROM search_cache").fetchone()
260
+ return (row[0] or 0) / 1024 / 1024
261
+
262
+
263
+ # ── 双层缓存入口 ───────────────────────────────────────────────────────────────
264
+
265
+ class SearchCache:
266
+ """
267
+ 双层缓存引擎:L1 LRU + L2 SQLite
268
+ 缓存键 = SHA256(query + "|" + engine + "|" + str(max_results))[:32]
269
+ """
270
+
271
+ def __init__(self, db_path: str = DEFAULT_DB_PATH, ttl: int = DEFAULT_TTL):
272
+ cfg = get_cache_config()
273
+ self._db_path = os.path.expanduser(cfg.get("db_path", db_path))
274
+ self._ttl = cfg.get("ttl", ttl)
275
+ self._l1 = LRUCache(max_size=MAX_MEMORY_ITEMS)
276
+ self._l2 = SQLiteCache(db_path=self._db_path, ttl=self._ttl)
277
+
278
+ @staticmethod
279
+ def _key(query: str, engine: str, max_results: int) -> str:
280
+ raw = f"{query}|{engine}|{max_results}"
281
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
282
+
283
+ @staticmethod
284
+ def resolve_ttl(domain: str = "general") -> int:
285
+ """根据 domain 返回 TTL(秒)。"""
286
+ tier = DOMAIN_TIER_MAP.get(domain, "general")
287
+ return CACHE_TIERS.get(tier, DEFAULT_TTL)
288
+
289
+ def get(self, query: str, engine: str, max_results: int,
290
+ domain: str = "general") -> Optional[dict]:
291
+ """先查 L1,未命中再查 L2。"""
292
+ key = self._key(query, engine, max_results)
293
+ hit = self._l1.get(key)
294
+ if hit is not None:
295
+ ttl = hit.get("_ttl", self.resolve_ttl(domain))
296
+ if time.time() - hit.get("_ts", 0) < ttl:
297
+ hit["_cache_level"] = "L1"
298
+ return hit
299
+ self._l1._store.pop(key, None)
300
+
301
+ hit = self._l2.get(key)
302
+ if hit is not None:
303
+ self._l1.set(key, hit)
304
+ hit["_cache_level"] = "L2"
305
+ return hit
306
+ return None
307
+
308
+ def set(self, query: str, engine: str, max_results: int, results: dict,
309
+ domain: str = "general", ttl: int | None = None):
310
+ """写入双层缓存。"""
311
+ key = self._key(query, engine, max_results)
312
+ effective_ttl = ttl if ttl is not None else self.resolve_ttl(domain)
313
+ value = {**results, "_domain": domain, "_ttl": effective_ttl, "_ts": time.time()}
314
+ self._l1.set(key, value)
315
+ self._l2.set(key, query, engine, max_results, value, domain=domain, ttl=effective_ttl)
316
+
317
+ def clear(self, older_than_hours: int = 24):
318
+ self._l2.clear(older_than_hours=older_than_hours)
319
+
320
+ @property
321
+ def stats(self) -> dict:
322
+ l1 = self._l1.stats
323
+ l2 = self._l2.stats
324
+ total_hits = l1["hits"] + l2["hits"]
325
+ return {
326
+ "hits": total_hits,
327
+ "misses": l1["misses"],
328
+ "hit_rate": round(total_hits / max(total_hits + l1["misses"], 1), 3),
329
+ "size_mb": l2["size_mb"],
330
+ "entries": l2["entries"],
331
+ "l1": l1,
332
+ "l2": l2,
333
+ }
334
+
335
+
336
+ # ── CLI 入口 ──────────────────────────────────────────────────────────────────
337
+
338
+ def _cli():
339
+ import argparse
340
+ parser = argparse.ArgumentParser(description="Unified Search v2 缓存管理")
341
+ sub = parser.add_subparsers(dest="cmd")
342
+ p_get = sub.add_parser("get")
343
+ p_get.add_argument("query")
344
+ p_get.add_argument("engine", nargs="?", default="auto")
345
+ p_get.add_argument("max_results", nargs="?", type=int, default=5)
346
+ p_get.add_argument("--domain", default="general")
347
+ p_set = sub.add_parser("set")
348
+ p_set.add_argument("query")
349
+ p_set.add_argument("engine")
350
+ p_set.add_argument("max_results", type=int)
351
+ p_set.add_argument("value_json")
352
+ p_set.add_argument("--domain", default="general")
353
+ p_clear = sub.add_parser("clear")
354
+ p_clear.add_argument("--older-than", type=int, default=24)
355
+ sub.add_parser("stats")
356
+ args = parser.parse_args()
357
+ cache = SearchCache()
358
+ if args.cmd == "get":
359
+ hit = cache.get(args.query, args.engine, args.max_results, domain=args.domain)
360
+ print(json.dumps({"hit": hit is not None, "data": hit}, ensure_ascii=False))
361
+ elif args.cmd == "set":
362
+ cache.set(args.query, args.engine, args.max_results, json.loads(args.value_json), domain=args.domain)
363
+ print('{"ok": true}')
364
+ elif args.cmd == "clear":
365
+ cache.clear(older_than_hours=args.older_than)
366
+ print('{"ok": true}')
367
+ elif args.cmd == "stats":
368
+ print(json.dumps(cache.stats, ensure_ascii=False, indent=2))
369
+ else:
370
+ parser.print_help()
371
+
372
+
373
+ if __name__ == "__main__":
374
+ _cli()