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.
- package/LICENSE +21 -0
- package/README.md +410 -0
- package/backends/domain_profiles.json +364 -0
- package/backends/engine_registry.yaml +505 -0
- package/backends/quota_profiles.json +395 -0
- package/bin/argo.js +54 -0
- package/config.yaml +554 -0
- package/package.json +43 -0
- package/scripts/adaptive.py +179 -0
- package/scripts/benchmark.py +124 -0
- package/scripts/cache.py +374 -0
- package/scripts/clarify.py +689 -0
- package/scripts/config.py +262 -0
- package/scripts/crawl.py +73 -0
- package/scripts/engines.py +386 -0
- package/scripts/evidence.py +381 -0
- package/scripts/extract.py +69 -0
- package/scripts/fetch.py +118 -0
- package/scripts/health_check.py +437 -0
- package/scripts/health_probe.py +218 -0
- package/scripts/mcp_diag.py +81 -0
- package/scripts/mcp_server.py +488 -0
- package/scripts/query_rewriter.py +278 -0
- package/scripts/quota.py +196 -0
- package/scripts/research.py +499 -0
- package/scripts/route.py +341 -0
- package/scripts/search.py +508 -0
- package/scripts/search_types.py +72 -0
- package/scripts/tfidf_router.py +312 -0
- package/sub-skills/local-search/SKILL.md +104 -0
- package/sub-skills/local-search/config.yaml +328 -0
- package/sub-skills/local-search/engine_registry.py +298 -0
- package/sub-skills/local-search/health_check.py +347 -0
- package/sub-skills/local-search/local_search_adapter.py +56 -0
- package/sub-skills/local-search/parse_maps.yaml +184 -0
- package/sub-skills/local-search/search_v3.py +558 -0
- package/sub-skills/local-search/smart_router.py +215 -0
|
@@ -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()
|
package/scripts/cache.py
ADDED
|
@@ -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()
|