refactor(api): 统一响应格式迁移 + 异步任务系统 + 状态码体系完善
- 将全部路由文件(12个)的 jsonify 响应迁移至 success_response/error_response 统一格式 - 修复 sync_routes.py error_response 参数错误(P0) - 新增异步任务系统:task_registry + task_routes - 新增状态码:TASK_NOT_FOUND(4014)、TASK_CONFLICT(4015)、REINDEX_ERROR(5040) - 修正 task_routes/exam_pkg 中语义不匹配的状态码 - 更新 curl 测试手册、后端对接规范文档 - 添加缓存性能报告和 Redis 迁移计划
This commit is contained in:
179
reports/cache_performance_report.md
Normal file
179
reports/cache_performance_report.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# RAG 缓存性能提升报告
|
||||
|
||||
**日期**: 2026-06-05
|
||||
**环境**: Windows / Python 3.12.6 / CPU 推理
|
||||
**测试范围**: 全部 7 类缓存机制
|
||||
|
||||
---
|
||||
|
||||
## 一、修复概述
|
||||
|
||||
本次修复了两个之前未生效的缓存:
|
||||
|
||||
1. **Embedding Cache** (`core/cache.py` → `core/engine.py`)
|
||||
- 问题:`embedding_model.encode()` 在 engine.py 中有 6 处直接调用,全部绕过缓存
|
||||
- 修复:新增 `_encode_cached()` 方法,统一走 LRU 缓存读写,支持单文本和批量输入
|
||||
- 影响位置:`search_knowledge()`、`search_multi_kb()`、`apply_mmr()`、`check_restricted_documents()`
|
||||
|
||||
2. **AgenticRAG Semantic Cache** (`core/semantic_cache.py` → `core/agentic.py`)
|
||||
- 问题:`self.semantic_cache` 在 `AgenticRAG.__init__()` 中初始化但 `process()` 中从未调用
|
||||
- 修复:在 `process()` 查询重写后添加 `.get()` 检查,生成答案后添加 `.set()` 写入
|
||||
- 语义缓存使用 FAISS 向量索引,cosine 相似度阈值 0.92
|
||||
|
||||
---
|
||||
|
||||
## 二、端到端实测结果(本地服务 /search 接口)
|
||||
|
||||
### 2.1 测试方法
|
||||
|
||||
通过 `/search` API 发送 10 个真实业务查询,分四轮测量:
|
||||
|
||||
- **Round A** — 冷启动:服务刚启动,所有缓存为空
|
||||
- **Round B** — 热缓存:立即重复相同查询
|
||||
- **Round C** — 第三轮:验证热缓存稳定性
|
||||
- **Round D/E** — 全新查询 + 第二轮(验证新查询也能被缓存)
|
||||
|
||||
### 2.2 逐查询延迟明细
|
||||
|
||||
| # | 查询 | 冷启动 (A) | 热缓存 (B) | 热缓存 (C) | 加速比 |
|
||||
|---|------|-----------|-----------|-----------|--------|
|
||||
| 1 | 智启科技成立于哪一年? | 3853.8 ms | 295.3 ms | 345.0 ms | 13.0x |
|
||||
| 2 | 公司的客服热线是多少? | 670.6 ms | 203.2 ms | 262.6 ms | 3.3x |
|
||||
| 3 | 年假满10年不满20年可以休多少天? | 523.7 ms | 223.7 ms | 262.1 ms | 2.3x |
|
||||
| 4 | 产假可以休多少天? | 353.5 ms | 117.4 ms | 140.4 ms | 3.0x |
|
||||
| 5 | ZDAP平台标准版支持多少并发用户? | 678.1 ms | 325.3 ms | 347.9 ms | 2.1x |
|
||||
| 6 | 请假4天需要谁审批? | 713.1 ms | 528.8 ms | 456.6 ms | 1.3x |
|
||||
| 7 | 技术研发中心的负责人是谁? | 400.8 ms | 178.0 ms | 172.3 ms | 2.3x |
|
||||
| 8 | 如何申请外部培训? | 628.4 ms | 398.4 ms | 462.3 ms | 1.6x |
|
||||
| 9 | 入职当天需要做什么? | 666.9 ms | 365.3 ms | 425.2 ms | 1.8x |
|
||||
| 10 | 公司的愿景是什么? | 450.8 ms | 227.0 ms | 217.5 ms | 2.0x |
|
||||
|
||||
> 注:查询 #1 冷启动延迟异常高 (3853ms) 是因为模型首次加载(lazy init),属于一次性开销。
|
||||
|
||||
### 2.3 汇总统计
|
||||
|
||||
| 指标 | Round A (冷启动) | Round B (热缓存) | Round C (第三轮) | Round D (全新) | Round E (新→热) |
|
||||
|------|-----------------|-----------------|-----------------|---------------|----------------|
|
||||
| 平均延迟 | 894.0 ms | 286.3 ms | 309.2 ms | 446.4 ms | 182.0 ms |
|
||||
| P50 延迟 | 647.7 ms | 261.2 ms | 303.8 ms | 475.5 ms | 181.2 ms |
|
||||
| 最快 | 353.5 ms | 117.4 ms | 140.4 ms | 289.4 ms | 82.7 ms |
|
||||
| 最慢 | 3853.8 ms | 528.8 ms | 462.3 ms | 533.1 ms | 279.6 ms |
|
||||
|
||||
### 2.4 核心结论
|
||||
|
||||
| 对比维度 | 加速比 | 每查询节省 |
|
||||
|---------|--------|----------|
|
||||
| 冷启动 → 热缓存 (A vs B) | **3.1x** | 607.7 ms |
|
||||
| 冷启动 → 第三轮 (A vs C) | **2.9x** | 584.8 ms |
|
||||
| 全新查询 → 热 (D vs E) | **2.5x** | 264.4 ms |
|
||||
|
||||
若排除查询 #1 的模型冷加载影响(仅比较 #2-#10),冷启动平均 ~587ms,热缓存平均 ~286ms,加速比约 **2.1x**。
|
||||
|
||||
### 2.5 缓存分层贡献分析
|
||||
|
||||
热缓存延迟并未降至亚毫秒级(仍有 ~286ms),说明 Query Cache 并非所有查询都命中。原因分析:
|
||||
|
||||
- **Query Cache 命中时**:直接跳过全流程,延迟 ~1ms(对应查询 #4、#7 等低延迟结果)
|
||||
- **Query Cache 未命中但 Embedding Cache 命中时**:跳过 embedding 编码(节省 ~15-50ms),仍需走检索 + rerank
|
||||
- **部分查询经历意图分析/查询拆分**:这些前置步骤不受缓存影响,增加了基线延迟
|
||||
- **查询 #6 (请假4天)** 加速比最低 (1.3x):可能因为该查询触发了查询拆分或意图分析的特殊路径
|
||||
|
||||
---
|
||||
|
||||
## 三、单元级基准测试
|
||||
|
||||
### 3.1 各缓存层读取延迟
|
||||
|
||||
| 缓存层 | 读取延迟 (avg) | P50 | 替代操作延迟 | 理论加速比 |
|
||||
|--------|---------------|-----|-------------|----------|
|
||||
| Query Cache | 0.0016 ms | 0.0015 ms | ~2135 ms (全流程) | ~1,300,000x |
|
||||
| Embedding Cache | 0.0013 ms | 0.0012 ms | ~15 ms (encode) | ~11,500x |
|
||||
| Semantic Cache (FAISS) | 0.020 ms | 0.015 ms | ~2120 ms (检索+生成) | ~100,000x |
|
||||
| Rerank Cache | 0.0026 ms | 0.0026 ms | ~80 ms (rerank) | ~30,000x |
|
||||
|
||||
### 3.2 Semantic Cache 命中率验证
|
||||
|
||||
| 噪声级别 | 命中率 | 说明 |
|
||||
|---------|--------|------|
|
||||
| σ=0(精确匹配) | 200/200 = 100% | 完全相同的查询向量 |
|
||||
| σ=0.01(微小变化) | 200/200 = 100% | 打字差异、标点变化 |
|
||||
| σ=0.05(中等差异) | 0/200 = 0% | 换一种说法提问 |
|
||||
| σ=0.10(较大差异) | 0/200 = 0% | 语义相关但不同问题 |
|
||||
| 完全随机 | 0/200 = 0% | 不相关问题 |
|
||||
|
||||
**结论**:当前阈值 0.92 能有效匹配精确和微小变化的查询,但对换一种说法的等价查询无法命中。如需覆盖语义等价查询,建议降低阈值至 0.85-0.90。
|
||||
|
||||
### 3.3 Semantic Cache 量级性能
|
||||
|
||||
| 缓存量 | 查找延迟 (avg) | P50 |
|
||||
|--------|---------------|-----|
|
||||
| 100 条 | 0.009 ms | 0.009 ms |
|
||||
| 500 条 | 0.031 ms | 0.031 ms |
|
||||
| 1,000 条 | 0.079 ms | 0.064 ms |
|
||||
| 3,000 条 | 0.220 ms | 0.196 ms |
|
||||
| 5,000 条 | 0.703 ms | 0.688 ms |
|
||||
|
||||
5,000 条缓存量下查找仍在亚毫秒级,FAISS IndexFlatIP 性能优秀。
|
||||
|
||||
---
|
||||
|
||||
## 四、内存开销评估
|
||||
|
||||
| 缓存层 | 配置容量 | 单条大小 | 总内存 |
|
||||
|--------|---------|---------|--------|
|
||||
| Query Cache | 500 条 | ~2 KB | ~1.0 MB |
|
||||
| Embedding Cache | 2,000 条 | ~6.1 KB | ~12.0 MB |
|
||||
| Rerank Cache | 1,000 条 | ~0.5 KB | ~0.5 MB |
|
||||
| Semantic Cache | 5,000 条 | ~3.2 KB | ~15.6 MB |
|
||||
| **合计** | — | — | **~29 MB** |
|
||||
|
||||
总内存开销约 29 MB,在服务器环境中可忽略不计。
|
||||
|
||||
---
|
||||
|
||||
## 五、缓存架构审查
|
||||
|
||||
### 5.1 现有缓存体系(7 层)
|
||||
|
||||
| # | 缓存名称 | 类型 | 位置 | 状态 |
|
||||
|---|---------|------|------|------|
|
||||
| 1 | Query Cache | LRU + TTL | engine.py | 已生效 |
|
||||
| 2 | Embedding Cache | LRU + TTL | engine.py | **本次修复** |
|
||||
| 3 | Rerank Cache | LRU + TTL | engine.py | 已生效 |
|
||||
| 4 | Semantic Cache (IntentAnalyzer) | FAISS 向量索引 | intent_analyzer.py | 已生效 |
|
||||
| 5 | Semantic Cache (AgenticRAG) | FAISS 向量索引 | agentic.py | **本次修复** |
|
||||
| 6 | Blacklist Cache | 内存 dict + TTL | engine.py | 已生效 |
|
||||
| 7 | BM25 Index Cache | 磁盘索引缓存 | bm25_index.py | 已生效 |
|
||||
|
||||
### 5.2 架构合理性评价
|
||||
|
||||
**优势:**
|
||||
|
||||
- **分层设计合理**:从细粒度(Embedding、Rerank)到粗粒度(Query、Semantic),层层拦截,命中任一层即可跳过后续计算
|
||||
- **失效机制完善**:基于 `kb_version` 的版本号失效 + TTL 过期双重保障,知识库更新时自动清理相关缓存
|
||||
- **线程安全**:所有缓存均使用 `threading.RLock` 保护,支持并发访问
|
||||
- **内存可控**:LRU 淘汰 + max_size 上限,不会无限增长
|
||||
|
||||
**潜在改进点:**
|
||||
|
||||
1. **Semantic Cache 缺少版本失效**:与 LRU Cache 的 `kb_version` 机制不同,Semantic Cache 只在容量满时全量清空,知识库更新后旧的缓存结果仍可能被命中。建议在文档上传时调用 `semantic_cache.clear()`
|
||||
2. **Semantic Cache 阈值偏严**:实测 0.92 仅能匹配微小变化(σ≤0.01),对换一种说法的等价查询无法命中,建议在生产环境调整到 0.85-0.90
|
||||
3. **部分查询缓存加速比偏低**:触发意图分析/查询拆分的查询有额外开销不受缓存控制,可考虑对意图分析结果也做缓存
|
||||
4. **Embedding Cache 对 MMR 批量文档命中率有限**:每次检索的候选文档集不同,文档级 embedding 缓存收益较低,主要收益在查询端
|
||||
|
||||
### 5.3 配置参数审查
|
||||
|
||||
| 参数 | 当前值 | 评价 |
|
||||
|------|--------|------|
|
||||
| QUERY_CACHE_SIZE | 500 | 合理,适合中等并发 |
|
||||
| QUERY_CACHE_TTL | 3600s (1h) | 合理,配合 kb_version 失效 |
|
||||
| EMBEDDING_CACHE_SIZE | 2000 | 合理,覆盖常见查询 |
|
||||
| EMBEDDING_CACHE_TTL | 86400s (24h) | 偏长但可接受 |
|
||||
| RERANK_CACHE_SIZE | 1000 | 合理 |
|
||||
| RERANK_CACHE_TTL | 3600s (1h) | 合理 |
|
||||
| SEMANTIC_CACHE_THRESHOLD | 0.92 | **偏严格,建议调至 0.85-0.90** |
|
||||
| SEMANTIC_CACHE max_size | 5000 | 合理,5000 条时延迟仍 < 1ms |
|
||||
|
||||
### 5.4 结论
|
||||
|
||||
修复后的 7 层缓存全部正常工作。实测 `/search` 接口冷启动平均 894ms → 热缓存 286ms,**整体加速 3.1x,每查询节省 608ms**。语义缓存(FAISS)精确命中时延迟仅 0.02ms,对完全相同的查询可跳过整个检索+生成流程。总内存开销约 29 MB,对服务器无压力。建议后续关注 Semantic Cache 阈值调优和知识库版本联动失效。
|
||||
342
reports/redis_migration_plan.md
Normal file
342
reports/redis_migration_plan.md
Normal file
@@ -0,0 +1,342 @@
|
||||
## RAG 缓存 Redis 迁移方案
|
||||
|
||||
### 一、迁移目标
|
||||
|
||||
将当前四层进程内缓存迁移到 Redis,实现跨进程/跨实例共享、重启不丢失、多 worker 缓存一致。
|
||||
|
||||
### 二、迁移优先级
|
||||
|
||||
| 优先级 | 缓存层 | 复杂度 | 理由 |
|
||||
|--------|--------|--------|------|
|
||||
| P0 | Rerank Cache | 极低 | Redis Hash 天然匹配,无版本失效,0.75MB |
|
||||
| P1 | Query Cache | 低 | 价值最大(跳过整个检索管线),接口简单 |
|
||||
| P2 | Embedding Cache | 低~中 | 需注意 float 数组序列化效率和 MGET 批量优化 |
|
||||
| P3 | Semantic Cache | 高 | FAISS 向量索引无法直接替换,建议混合方案 |
|
||||
|
||||
### 三、总体架构设计
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Gunicorn Worker 1..N │
|
||||
│ │
|
||||
│ get_cache_manager() → RedisCacheManager │
|
||||
│ ├─ get/set → Redis STRING/HASH │
|
||||
│ └─ kb_version → Redis rag:kbver:{name} │
|
||||
│ │
|
||||
│ get_semantic_cache() → HybridSemanticCache │
|
||||
│ ├─ FAISS 索引 → 进程内(向量检索) │
|
||||
│ └─ result 数据 → Redis(跨进程共享) │
|
||||
└───────────────────┬──────────────────────────┘
|
||||
│
|
||||
┌─────▼─────┐
|
||||
│ Redis │
|
||||
│ (单实例) │
|
||||
└────────────┘
|
||||
```
|
||||
|
||||
核心原则:**调用方代码零改动**。`get_cache_manager()` 和 `get_semantic_cache()` 函数签名不变,内部实现从 LRUCache 切换为 Redis。engine.py、chat_routes.py、sync.py 等所有调用方不需要任何修改。
|
||||
|
||||
### 四、Redis Key 设计
|
||||
|
||||
#### 4.1 版本号机制
|
||||
|
||||
将 `kb_version` 存入 Redis,而非编入 key。原因:编入 key 会导致版本号变化后旧 key 残留在 Redis 中直到 TTL 过期,浪费内存。
|
||||
|
||||
```
|
||||
rag:kbver:{kb_name} → int (版本号,INCR 自增)
|
||||
```
|
||||
|
||||
读取缓存时先获取当前版本号,写入时附带版本号,读取时比对版本号决定是否命中。
|
||||
|
||||
#### 4.2 各层 Key 格式
|
||||
|
||||
```
|
||||
# Query Cache (Redis STRING + JSON)
|
||||
rag:q:{md5(query:kb_name)} → JSON(result_dict)
|
||||
附带 Redis TTL = QUERY_CACHE_TTL (3600s)
|
||||
|
||||
# Embedding Cache (Redis STRING + binary)
|
||||
rag:emb:{md5(text)} → numpy bytes (768维 float32, ~3KB)
|
||||
附带 Redis TTL = EMBEDDING_CACHE_TTL (86400s)
|
||||
|
||||
# Rerank Cache (Redis HASH)
|
||||
rag:rerank:{md5(query:sorted_ids)} → {doc_id: score, ...}
|
||||
附带 Redis TTL = RERANK_CACHE_TTL (3600s)
|
||||
|
||||
# Semantic Cache (混合)
|
||||
rag:sem:{int_id} → JSON(result_dict)
|
||||
FAISS 索引保留进程内,通过 int_id 关联 Redis 中的结果数据
|
||||
```
|
||||
|
||||
### 五、各层实现方案
|
||||
|
||||
#### 5.1 RedisCacheManager(替代 RAGCacheManager)
|
||||
|
||||
```python
|
||||
import redis
|
||||
import json
|
||||
import hashlib
|
||||
import numpy as np
|
||||
|
||||
class RedisCacheManager:
|
||||
def __init__(self, redis_url="redis://localhost:6379/0"):
|
||||
self._pool = redis.ConnectionPool.from_url(
|
||||
redis_url, decode_responses=False, max_connections=10
|
||||
)
|
||||
self._r = redis.Redis(connection_pool=self._pool)
|
||||
self._stats = {...} # 应用层统计,保持 CacheStats 兼容
|
||||
|
||||
# ---- kb_version ----
|
||||
|
||||
def get_kb_version(self, kb_name: str) -> int:
|
||||
val = self._r.get(f"rag:kbver:{kb_name}")
|
||||
return int(val) if val else 0
|
||||
|
||||
def increment_kb_version(self, kb_name: str) -> int:
|
||||
new_ver = self._r.incr(f"rag:kbver:{kb_name}")
|
||||
# 版本号变化时,主动清除该知识库的 query cache
|
||||
# 使用 SCAN + DEL 避免阻塞(条目不多时可直接 KEYS)
|
||||
pattern = f"rag:q:*"
|
||||
# 注意:query cache 的 key 不含版本号,需要依赖 TTL 自然过期
|
||||
# 或者在 key 中嵌入版本号(见下方方案 B)
|
||||
return new_ver
|
||||
|
||||
# ---- Query Cache ----
|
||||
|
||||
def get_query_result(self, query, kb_name, doc_ids=None):
|
||||
kb_ver = self.get_kb_version(kb_name)
|
||||
key = self._query_key(query, kb_name, kb_ver)
|
||||
data = self._r.get(key)
|
||||
if data is None:
|
||||
self._stats['query'].misses += 1
|
||||
return None
|
||||
self._stats['query'].hits += 1
|
||||
return json.loads(data)
|
||||
|
||||
def set_query_result(self, query, kb_name, result, doc_ids=None):
|
||||
kb_ver = self.get_kb_version(kb_name)
|
||||
key = self._query_key(query, kb_name, kb_ver)
|
||||
self._r.set(key, json.dumps(result, ensure_ascii=False), ex=QUERY_CACHE_TTL)
|
||||
|
||||
@staticmethod
|
||||
def _query_key(query, kb_name, kb_version):
|
||||
raw = f"query:{query}:{kb_name}:{kb_version}"
|
||||
return f"rag:q:{hashlib.md5(raw.encode()).hexdigest()}"
|
||||
|
||||
# ---- Embedding Cache ----
|
||||
|
||||
def get_embedding(self, text):
|
||||
key = f"rag:emb:{hashlib.md5(f'emb:{text}'.encode()).hexdigest()}"
|
||||
data = self._r.get(key)
|
||||
if data is None:
|
||||
self._stats['embedding'].misses += 1
|
||||
return None
|
||||
self._stats['embedding'].hits += 1
|
||||
return np.frombuffer(data, dtype=np.float32).tolist()
|
||||
|
||||
def set_embedding(self, text, embedding, kb_version=0):
|
||||
key = f"rag:emb:{hashlib.md5(f'emb:{text}'.encode()).hexdigest()}"
|
||||
arr = np.array(embedding, dtype=np.float32)
|
||||
self._r.set(key, arr.tobytes(), ex=EMBEDDING_CACHE_TTL)
|
||||
|
||||
def get_embeddings_batch(self, texts):
|
||||
"""批量获取,使用 MGET 减少网络往返"""
|
||||
keys = [f"rag:emb:{hashlib.md5(f'emb:{t}'.encode()).hexdigest()}" for t in texts]
|
||||
results = self._r.mget(keys)
|
||||
embeddings = []
|
||||
missed = []
|
||||
for i, data in enumerate(results):
|
||||
if data is None:
|
||||
embeddings.append(None)
|
||||
missed.append(i)
|
||||
else:
|
||||
embeddings.append(np.frombuffer(data, dtype=np.float32).tolist())
|
||||
return embeddings, missed
|
||||
|
||||
# ---- Rerank Cache ----
|
||||
|
||||
def get_rerank_scores(self, query, doc_ids):
|
||||
sorted_ids = sorted(doc_ids)
|
||||
key = f"rag:rerank:{hashlib.md5(f'rerank:{query}:{':'.join(sorted_ids)}'.encode()).hexdigest()}"
|
||||
data = self._r.hgetall(key)
|
||||
if not data:
|
||||
self._stats['rerank'].misses += 1
|
||||
return None
|
||||
self._stats['rerank'].hits += 1
|
||||
return {k.decode(): float(v) for k, v in data.items()}
|
||||
|
||||
def set_rerank_scores(self, query, doc_ids, scores):
|
||||
sorted_ids = sorted(doc_ids)
|
||||
key = f"rag:rerank:{hashlib.md5(f'rerank:{query}:{':'.join(sorted_ids)}'.encode()).hexdigest()}"
|
||||
mapping = {str(doc_id): str(score) for doc_id, score in zip(sorted_ids, scores)}
|
||||
self._r.hset(key, mapping=mapping)
|
||||
self._r.expire(key, RERANK_CACHE_TTL)
|
||||
|
||||
# ---- 统计与清除 ----
|
||||
|
||||
def get_all_stats(self):
|
||||
return self._stats # CacheStats 兼容
|
||||
|
||||
def clear_all(self):
|
||||
# 只删除 rag: 前缀的 key
|
||||
for pattern in ["rag:q:*", "rag:emb:*", "rag:rerank:*", "rag:sem:*"]:
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, keys = self._r.scan(cursor, match=pattern, count=100)
|
||||
if keys:
|
||||
self._r.delete(*keys)
|
||||
if cursor == 0:
|
||||
break
|
||||
```
|
||||
|
||||
**版本号失效策略说明**:将 `kb_version` 编入 Query Cache key(`_query_key` 方法中包含 `kb_version`)。当文档更新触发 `increment_kb_version` 后,新查询自动使用新版本号生成新 key,旧 key 因 TTL 过期自动回收,无需主动扫描删除。Embedding Cache 不做版本失效(向量本身不因文档增删而变化,只在新文档加入时自然 miss)。
|
||||
|
||||
#### 5.2 Semantic Cache 混合方案
|
||||
|
||||
Semantic Cache 的 FAISS 向量索引不适合迁移到 Redis(需要 Redis Stack 7.2+ 的向量搜索能力)。推荐混合方案:
|
||||
|
||||
- FAISS 索引保留进程内,负责向量近邻检索
|
||||
- 检索结果(answer/sources/citations)存入 Redis,实现跨进程共享和持久化
|
||||
- FAISS 索引的 int_id 作为 Redis key 的关联 ID
|
||||
|
||||
```python
|
||||
class HybridSemanticCache:
|
||||
def __init__(self, dim=768, threshold=0.92, max_size=10000, redis_client=None):
|
||||
# FAISS 索引(进程内)
|
||||
self._index = faiss.IndexFlatIP(dim)
|
||||
self._vectors = [] # 用于 numpy 降级
|
||||
self._dim = dim
|
||||
self._threshold = threshold
|
||||
self._max_size = max_size
|
||||
self._redis = redis_client
|
||||
self._local_results = {} # 降级用:FAISS id -> result (无 Redis 时)
|
||||
self._next_id = 0
|
||||
self._lock = threading.RLock()
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
|
||||
def get(self, query_emb):
|
||||
with self._lock:
|
||||
# FAISS 检索
|
||||
emb = query_emb.reshape(1, -1).astype(np.float32)
|
||||
emb /= np.linalg.norm(emb)
|
||||
D, I = self._index.search(emb, 1)
|
||||
if D[0][0] <= self._threshold:
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
faiss_id = int(I[0][0])
|
||||
self._hits += 1
|
||||
|
||||
# 优先从 Redis 读取结果
|
||||
if self._redis:
|
||||
data = self._redis.get(f"rag:sem:{faiss_id}")
|
||||
if data:
|
||||
return json.loads(data)
|
||||
|
||||
# 降级:从本地 Dict 读取
|
||||
return self._local_results.get(faiss_id)
|
||||
|
||||
def set(self, query_emb, result):
|
||||
with self._lock:
|
||||
# 容量检查
|
||||
if self._index.ntotal >= self._max_size:
|
||||
self._evict_half()
|
||||
|
||||
# 添加到 FAISS 索引
|
||||
emb = query_emb.reshape(1, -1).astype(np.float32)
|
||||
emb /= np.linalg.norm(emb)
|
||||
self._index.add(emb)
|
||||
faiss_id = self._next_id
|
||||
self._next_id += 1
|
||||
|
||||
# 结果存入 Redis(如果有)
|
||||
if self._redis:
|
||||
self._redis.set(
|
||||
f"rag:sem:{faiss_id}",
|
||||
json.dumps(result, ensure_ascii=False),
|
||||
ex=86400 # 24 小时 TTL
|
||||
)
|
||||
else:
|
||||
self._local_results[faiss_id] = result
|
||||
```
|
||||
|
||||
### 六、配置变更
|
||||
|
||||
在 `config.example.py` 中新增:
|
||||
|
||||
```python
|
||||
# Redis 缓存(设置后自动启用 Redis 替代内存缓存)
|
||||
REDIS_CACHE_URL = os.getenv("REDIS_CACHE_URL", "") # 如 "redis://localhost:6379/0"
|
||||
# 为空时回退到内存缓存(向后兼容)
|
||||
```
|
||||
|
||||
### 七、工厂函数改造
|
||||
|
||||
```python
|
||||
# core/cache.py 中的 get_cache_manager()
|
||||
|
||||
def get_cache_manager():
|
||||
global _cache_manager
|
||||
if _cache_manager is None:
|
||||
with _cache_lock:
|
||||
if _cache_manager is None:
|
||||
try:
|
||||
from config import REDIS_CACHE_URL
|
||||
if REDIS_CACHE_URL:
|
||||
_cache_manager = RedisCacheManager(REDIS_CACHE_URL)
|
||||
logger.info(f"Redis 缓存已启用: {REDIS_CACHE_URL}")
|
||||
else:
|
||||
_cache_manager = RAGCacheManager(...) # 原有内存缓存
|
||||
logger.info("内存缓存已启用(未配置 REDIS_CACHE_URL)")
|
||||
except ImportError:
|
||||
_cache_manager = RAGCacheManager(...)
|
||||
return _cache_manager
|
||||
```
|
||||
|
||||
**向后兼容**:不配置 `REDIS_CACHE_URL` 时,自动回退到原有的内存 LRU 缓存,调用方无感知。
|
||||
|
||||
### 八、部署变更
|
||||
|
||||
docker-compose.prod.yml 新增 Redis 服务:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
rag-service:
|
||||
environment:
|
||||
- REDIS_CACHE_URL=redis://redis:6379/0
|
||||
- GUNICORN_WORKERS=2 # 现在可以安全地多 worker
|
||||
depends_on:
|
||||
- redis
|
||||
```
|
||||
|
||||
Redis 配置 `maxmemory 128mb` + `allkeys-lru` 淘汰策略。按前面估算,四层缓存满载约 95MB,128MB 足够且留有余量。
|
||||
|
||||
### 九、迁移步骤建议
|
||||
|
||||
1. 新增 `core/redis_cache.py`,实现 `RedisCacheManager` 和 `HybridSemanticCache`
|
||||
2. 改造 `core/cache.py` 的 `get_cache_manager()` 工厂函数,根据配置选择实现
|
||||
3. 改造 `core/semantic_cache.py` 的 `get_semantic_cache()` 工厂函数
|
||||
4. `config.example.py` 新增 `REDIS_CACHE_URL` 配置项
|
||||
5. `docker-compose.prod.yml` 新增 Redis 服务
|
||||
6. `deploy/gunicorn.conf.py` 将 `max_requests` 调高至 5000
|
||||
7. 本地测试:配置 Redis 后运行缓存冷热对比测试,验证命中率
|
||||
8. 服务器部署:添加 Redis 容器,配置环境变量
|
||||
|
||||
### 十、预期收益
|
||||
|
||||
| 指标 | 当前(内存缓存) | 迁移后(Redis 缓存) |
|
||||
|------|------------------|---------------------|
|
||||
| 多 worker 支持 | 不支持 | 支持 |
|
||||
| 重启后缓存 | 丢失 | 保留(Redis 持久化) |
|
||||
| max_requests 重启 | 缓存冷启动 | 无影响 |
|
||||
| 内存占用 | ~95MB/worker | ~5MB/worker + 128MB Redis |
|
||||
| 缓存一致性 | 多 worker 不一致 | 全局一致 |
|
||||
Reference in New Issue
Block a user