feat(api): Phase 13.2 AI Rate Limiter + RAG 基礎設施 (#84)

Rate Limiter (防止 Gemini 用量暴衝):
- ai_rate_limiter.py: RPM/Daily/Token 三層閥值
- openclaw.py: 整合 rate limit 檢查,超限自動降級
- health.py: /health/ai-usage 監控端點

RAG Tool 基礎 (#84 進行中):
- embedding_service.py: Ollama embedding 封裝
- rag_service.py: Redis vector search 服務

閥值設定:
- Gemini: 10 RPM, 500/day, 100K tokens/day
- Claude: 5 RPM, 200/day, 50K tokens/day

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-26 15:52:57 +08:00
parent 30145c7d7e
commit bf32c4b1f2
5 changed files with 1048 additions and 13 deletions

View File

@@ -0,0 +1,468 @@
"""
RAG Service - 維運手冊向量搜尋
==============================
Phase 13.2 #84 - Runbook RAG Tool
功能:
- 文檔分段 (Chunking)
- 向量索引 (Redis Stack FT.CREATE)
- 語義搜尋 (KNN Vector Search)
版本: v1.0
建立日期: 2026-03-26 20:45 (台北時區)
建立者: Claude Code
"""
import hashlib
import struct
import uuid
from pathlib import Path
from typing import Protocol
import redis.asyncio as redis
import structlog
from src.core.config import settings
from src.services.embedding_service import IEmbeddingService, get_embedding_service
logger = structlog.get_logger(__name__)
# =============================================================================
# Configuration
# =============================================================================
RAG_CONFIG = {
"chunk_size": 500, # 每段字數
"chunk_overlap": 50, # 重疊字數
"index_name": "idx:runbooks", # Redis index 名稱
"prefix": "runbook:", # Key prefix
"ttl_days": 30, # 文檔 TTL (天)
}
# 維運手冊來源目錄 (相對於專案根目錄)
RUNBOOK_SOURCES = [
"docs/operations/*.md",
"docs/troubleshooting/*.md",
"docs/adr/*.md",
".agents/skills/*.md",
]
# =============================================================================
# Interface
# =============================================================================
class IRAGService(Protocol):
"""RAG 服務介面"""
async def index_documents(self, base_path: Path) -> int:
"""索引文檔,回傳索引數量"""
...
async def search(self, query: str, top_k: int = 5) -> list[dict]:
"""語義搜尋,回傳相關段落"""
...
async def get_index_stats(self) -> dict:
"""取得索引統計"""
...
# =============================================================================
# Implementation
# =============================================================================
class RAGService:
"""
RAG Service 實作
使用 Redis Stack 進行向量索引與搜尋。
支援維運手冊的語義搜尋。
"""
def __init__(
self,
redis_client: redis.Redis | None = None,
embedding_service: IEmbeddingService | None = None,
) -> None:
"""
初始化 RAG Service
Args:
redis_client: Redis 連線 (DI 注入)
embedding_service: Embedding 服務 (DI 注入)
"""
self._redis = redis_client
self._embedding_service = embedding_service
self._index_created = False
async def _get_redis(self) -> redis.Redis:
"""Lazy load Redis client"""
if self._redis is None:
from src.core.redis_client import get_redis
self._redis = get_redis()
return self._redis
async def _get_embedding_service(self) -> IEmbeddingService:
"""Lazy load Embedding service"""
if self._embedding_service is None:
self._embedding_service = get_embedding_service()
return self._embedding_service
# =========================================================================
# Document Processing
# =========================================================================
def _chunk_text(self, text: str, source: str) -> list[dict]:
"""
將文本分段
Args:
text: 原始文本
source: 來源檔案路徑
Returns:
list[dict]: 分段列表,每個包含 content, source, chunk_id
"""
chunk_size = RAG_CONFIG["chunk_size"]
overlap = RAG_CONFIG["chunk_overlap"]
chunks = []
start = 0
chunk_idx = 0
while start < len(text):
end = start + chunk_size
# 嘗試在句號/換行處斷開 (避免截斷句子)
if end < len(text):
# 往後找到最近的句號或換行
for sep in ["\n\n", "", "\n", ". ", ""]:
sep_pos = text.rfind(sep, start + chunk_size // 2, end + 50)
if sep_pos > start:
end = sep_pos + len(sep)
break
chunk_content = text[start:end].strip()
if chunk_content:
chunk_id = self._generate_chunk_id(source, chunk_idx)
chunks.append({
"chunk_id": chunk_id,
"content": chunk_content,
"source": source,
"chunk_index": chunk_idx,
})
chunk_idx += 1
start = end - overlap if end < len(text) else len(text)
return chunks
def _generate_chunk_id(self, source: str, chunk_idx: int) -> str:
"""生成唯一 Chunk ID"""
content = f"{source}:{chunk_idx}"
return hashlib.md5(content.encode()).hexdigest()[:12]
# =========================================================================
# Redis Vector Index
# =========================================================================
async def _ensure_index(self) -> None:
"""
確保向量索引存在
使用 FT.CREATE 建立 HNSW 向量索引
"""
if self._index_created:
return
r = await self._get_redis()
embedding_service = await self._get_embedding_service()
dim = embedding_service.dimension
index_name = RAG_CONFIG["index_name"]
prefix = RAG_CONFIG["prefix"]
try:
# 檢查索引是否存在
await r.execute_command("FT.INFO", index_name)
logger.info("rag_index_exists", index=index_name)
self._index_created = True
return
except redis.ResponseError as e:
if "Unknown index name" not in str(e):
raise
# 建立向量索引
# Schema: content (TEXT), source (TAG), embedding (VECTOR HNSW)
try:
await r.execute_command(
"FT.CREATE", index_name,
"ON", "HASH",
"PREFIX", "1", prefix,
"SCHEMA",
"content", "TEXT", "WEIGHT", "1.0",
"source", "TAG",
"chunk_index", "NUMERIC",
"embedding", "VECTOR", "HNSW", "6",
"TYPE", "FLOAT32",
"DIM", str(dim),
"DISTANCE_METRIC", "COSINE",
)
logger.info(
"rag_index_created",
index=index_name,
dimension=dim,
)
self._index_created = True
except redis.ResponseError as e:
if "Index already exists" in str(e):
self._index_created = True
else:
logger.error("rag_index_create_failed", error=str(e))
raise
async def _store_chunk(
self,
chunk: dict,
embedding: list[float],
) -> None:
"""
儲存分段到 Redis
Args:
chunk: 分段資料
embedding: 向量
"""
r = await self._get_redis()
prefix = RAG_CONFIG["prefix"]
key = f"{prefix}{chunk['chunk_id']}"
# 將 float list 轉換為 bytes (FLOAT32)
embedding_bytes = struct.pack(f"{len(embedding)}f", *embedding)
await r.hset(
key,
mapping={
"content": chunk["content"],
"source": chunk["source"],
"chunk_index": chunk["chunk_index"],
"embedding": embedding_bytes,
},
)
# 設定 TTL
ttl_seconds = RAG_CONFIG["ttl_days"] * 24 * 60 * 60
await r.expire(key, ttl_seconds)
# =========================================================================
# Public API
# =========================================================================
async def index_documents(self, base_path: Path) -> int:
"""
索引維運手冊
Args:
base_path: 專案根目錄
Returns:
int: 索引的分段數量
"""
await self._ensure_index()
embedding_service = await self._get_embedding_service()
total_chunks = 0
all_chunks: list[dict] = []
# 收集所有文檔
for pattern in RUNBOOK_SOURCES:
for file_path in base_path.glob(pattern):
if file_path.is_file():
try:
content = file_path.read_text(encoding="utf-8")
relative_path = str(file_path.relative_to(base_path))
chunks = self._chunk_text(content, relative_path)
all_chunks.extend(chunks)
logger.debug(
"rag_file_chunked",
file=relative_path,
chunks=len(chunks),
)
except Exception as e:
logger.warning(
"rag_file_read_error",
file=str(file_path),
error=str(e),
)
if not all_chunks:
logger.warning("rag_no_documents_found", patterns=RUNBOOK_SOURCES)
return 0
# 批次向量化
logger.info("rag_embedding_start", chunks=len(all_chunks))
texts = [c["content"] for c in all_chunks]
embeddings = await embedding_service.embed_batch(texts, concurrency=3)
# 儲存到 Redis
for chunk, embedding in zip(all_chunks, embeddings):
await self._store_chunk(chunk, embedding)
total_chunks += 1
logger.info(
"rag_index_complete",
total_chunks=total_chunks,
sources=len(RUNBOOK_SOURCES),
)
return total_chunks
async def search(
self,
query: str,
top_k: int = 5,
) -> list[dict]:
"""
語義搜尋維運手冊
Args:
query: 自然語言查詢
top_k: 回傳數量 (預設 5)
Returns:
list[dict]: 相關段落列表
- content: 段落內容
- source: 來源檔案
- score: 相似度分數
"""
await self._ensure_index()
r = await self._get_redis()
embedding_service = await self._get_embedding_service()
index_name = RAG_CONFIG["index_name"]
# 向量化查詢
query_embedding = await embedding_service.embed_text(query)
query_bytes = struct.pack(f"{len(query_embedding)}f", *query_embedding)
# KNN 向量搜尋
# *=>[KNN 5 @embedding $vec AS score]
try:
results = await r.execute_command(
"FT.SEARCH", index_name,
f"*=>[KNN {top_k} @embedding $vec AS score]",
"PARAMS", "2", "vec", query_bytes,
"SORTBY", "score",
"RETURN", "3", "content", "source", "score",
"DIALECT", "2",
)
except redis.ResponseError as e:
logger.error("rag_search_error", error=str(e), query=query[:50])
return []
# 解析結果
# Results format: [total, key1, [field1, value1, ...], key2, ...]
if not results or results[0] == 0:
return []
parsed = []
i = 1
while i < len(results):
key = results[i]
fields = results[i + 1] if i + 1 < len(results) else []
# 將 fields list 轉為 dict
field_dict = {}
for j in range(0, len(fields), 2):
if j + 1 < len(fields):
field_dict[fields[j]] = fields[j + 1]
parsed.append({
"content": field_dict.get("content", ""),
"source": field_dict.get("source", ""),
"score": float(field_dict.get("score", 0)),
})
i += 2
logger.info(
"rag_search_complete",
query=query[:30],
results=len(parsed),
)
return parsed
async def get_index_stats(self) -> dict:
"""
取得索引統計
Returns:
dict: 索引資訊
"""
r = await self._get_redis()
index_name = RAG_CONFIG["index_name"]
try:
info = await r.execute_command("FT.INFO", index_name)
# 將 list 轉為 dict
info_dict = {}
for i in range(0, len(info), 2):
if i + 1 < len(info):
info_dict[info[i]] = info[i + 1]
return {
"index_name": index_name,
"num_docs": info_dict.get("num_docs", 0),
"num_terms": info_dict.get("num_terms", 0),
"indexing": info_dict.get("indexing", 0),
}
except redis.ResponseError:
return {
"index_name": index_name,
"num_docs": 0,
"error": "Index not found",
}
async def clear_index(self) -> bool:
"""
清除索引 (重建用)
Returns:
bool: 是否成功
"""
r = await self._get_redis()
index_name = RAG_CONFIG["index_name"]
try:
await r.execute_command("FT.DROPINDEX", index_name, "DD")
self._index_created = False
logger.info("rag_index_cleared", index=index_name)
return True
except redis.ResponseError:
return False
# =============================================================================
# Singleton Factory
# =============================================================================
_rag_service: RAGService | None = None
def get_rag_service() -> RAGService:
"""
取得 RAG Service 單例
Returns:
RAGService: 共用實例
"""
global _rag_service
if _rag_service is None:
_rag_service = RAGService()
return _rag_service