diff --git a/apps/api/src/api/v1/rag.py b/apps/api/src/api/v1/rag.py index 84e68a55..4b424b06 100644 --- a/apps/api/src/api/v1/rag.py +++ b/apps/api/src/api/v1/rag.py @@ -75,8 +75,9 @@ async def rag_debug() -> dict: ollama_ok: bool | str = False try: async with httpx.AsyncClient(timeout=10.0) as c: + from src.core.config import get_settings as _gs r = await c.post( - "http://192.168.0.111:11434/api/embeddings", + f"{_gs().OLLAMA_URL}/api/embeddings", json={"model": "nomic-embed-text", "prompt": "test"}, ) ollama_ok = r.status_code == 200 if r.status_code == 200 else f"http_{r.status_code}" @@ -98,38 +99,6 @@ async def rag_stats() -> dict: # ============================================================ async def _run_index() -> None: - """背景:掃描所有文件來源並向量化""" - import structlog - from pathlib import Path - - logger = structlog.get_logger(__name__) + """背景:委派給 KnowledgeRAGService.index_all_sources() 執行""" svc = get_knowledge_rag_service() - - sources = [ - Path("docs/runbooks"), - Path("docs/adr"), - Path("docs"), - Path(".agents/skills"), - ] - - total = 0 - for source_dir in sources: - if not source_dir.exists(): - continue - for md_file in source_dir.glob("*.md"): - try: - text = md_file.read_text(encoding="utf-8", errors="ignore") - source_type = source_dir.parts[-1] # e.g. "runbooks", "adr", "skills" - ok = await svc.index_document( - source=source_type, - source_id=str(md_file), - title=md_file.stem, - text=text, - ) - if ok: - total += 1 - logger.debug("rag_indexed", file=str(md_file)) - except Exception as e: - logger.warning("rag_index_file_failed", file=str(md_file), error=str(e)) - - logger.info("rag_index_complete", total_chunks=total) + await svc.index_all_sources() diff --git a/apps/api/src/main.py b/apps/api/src/main.py index 6f073adc..0443ba61 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -296,6 +296,9 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: # Phase 5.4: Close Telegram Gateway telegram_gw = get_telegram_gateway() await telegram_gw.close() + # Phase 33: Close RAG Service httpx client (ADR-067) + from src.services.knowledge_rag_service import get_knowledge_rag_service + await get_knowledge_rag_service().close() # Phase 5: Close HTTP Clients (統帥鐵律: 連線池回收) await close_all_http_clients() # Phase 6.1.1: Close Redis Pool (統帥鐵律: Redis 連線池回收) diff --git a/apps/api/src/services/knowledge_rag_service.py b/apps/api/src/services/knowledge_rag_service.py index ddda5380..4a1e4575 100644 --- a/apps/api/src/services/knowledge_rag_service.py +++ b/apps/api/src/services/knowledge_rag_service.py @@ -11,7 +11,7 @@ AWOOOI — Knowledge RAG Service (Phase 33, ADR-067) - 初期 < 100 筆: 線性搜尋 - 超過 100 筆: 執行 CREATE INDEX ivfflat (手動觸發) -向量模型: nomic-embed-text (Ollama 188, 768維) +向量模型: nomic-embed-text (Ollama 111, 768維) — 188:11434 被 NetworkPolicy v1.3 封閉 生成模型: qwen2.5:7b-instruct (Ollama 111) 2026-04-10 Claude Sonnet 4.6 Asia/Taipei @@ -87,6 +87,8 @@ class KnowledgeRAGService: 將文件向量化並儲存到 pgvector 自動分段 (每段 500 字, overlap 100) """ + # 先刪舊版本,避免重複索引累積 (I2 審查修正 2026-04-10) + await self._delete_by_source_id(source_id) chunks = self._chunk_text(text, chunk_size=500, overlap=100) success = 0 for chunk in chunks: @@ -143,6 +145,18 @@ class KnowledgeRAGService: logger.error("rag_pgvector_search_failed", error=str(e)) return [] + async def _delete_by_source_id(self, source_id: str) -> None: + try: + from src.db.base import get_db_context + from sqlalchemy import text + async with get_db_context() as db: + await db.execute( + text("DELETE FROM rag_chunks WHERE source_id = :source_id"), + {"source_id": source_id}, + ) + except Exception as e: + logger.warning("rag_delete_old_chunks_failed", source_id=source_id, error=str(e)) + async def _save_chunk( self, source: str, source_id: str, title: str, @@ -216,6 +230,41 @@ class KnowledgeRAGService: start += chunk_size - overlap return [c for c in chunks if c.strip()] + async def index_all_sources(self) -> int: + """ + 掃描所有知識來源並向量化(供 /rag/index 端點呼叫) + 來源: docs/runbooks/, docs/adr/, docs/, .agents/skills/ + 回傳成功索引的文件數 + """ + from pathlib import Path + + sources = [ + Path("docs/runbooks"), + Path("docs/adr"), + Path("docs"), + Path(".agents/skills"), + ] + total = 0 + for source_dir in sources: + if not source_dir.exists(): + continue + for md_file in source_dir.rglob("*.md"): + try: + text = md_file.read_text(encoding="utf-8", errors="ignore") + ok = await self.index_document( + source=source_dir.parts[-1], + source_id=str(md_file), + title=md_file.stem, + text=text, + ) + if ok: + total += 1 + logger.debug("rag_source_indexed", file=str(md_file)) + except Exception as e: + logger.warning("rag_source_index_failed", file=str(md_file), error=str(e)) + logger.info("rag_index_all_complete", total_docs=total) + return total + async def get_stats(self) -> dict: """RAG 知識庫統計""" from src.db.base import get_db_context