""" 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 from pathlib import Path from typing import Protocol import redis.asyncio as redis import structlog 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, strict=True): 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): # results[i] is the Redis key, results[i+1] is the fields 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