diff --git a/apps/api/src/repositories/interfaces.py b/apps/api/src/repositories/interfaces.py index b702a840..95823186 100644 --- a/apps/api/src/repositories/interfaces.py +++ b/apps/api/src/repositories/interfaces.py @@ -272,6 +272,25 @@ class IKnowledgeRepository(Protocol): """view_count +1""" ... + async def save_embedding(self, entry_id: str, embedding: list[float]) -> bool: + """儲存向量 embedding (768 維, pgvector)""" + ... + + async def semantic_search( + self, + query_embedding: list[float], + limit: int = 10, + threshold: float = 0.5, + ) -> list[tuple["KnowledgeEntry", float]]: + """語意搜尋 — cosine similarity, 回傳 (entry, score) 降序""" + ... + + async def list_unembedded_entries( + self, + ) -> list[tuple[str, str, str]]: + """列出尚未產生 embedding 的條目 [(id, title, content)]""" + ... + @runtime_checkable class IPlaybookRepository(Protocol): diff --git a/apps/api/src/repositories/knowledge_repository.py b/apps/api/src/repositories/knowledge_repository.py index 01f938a0..31cf2653 100644 --- a/apps/api/src/repositories/knowledge_repository.py +++ b/apps/api/src/repositories/knowledge_repository.py @@ -184,6 +184,17 @@ class KnowledgeDBRepository: ) return result.rowcount > 0 + async def list_unembedded_entries(self) -> list[tuple[str, str, str]]: + """列出尚未產生 embedding 的條目 [(id, title, content)]""" + from sqlalchemy import text as sa_text + result = await self.db.execute( + sa_text( + "SELECT id, title, content FROM knowledge_entries " + "WHERE embedding IS NULL AND status != 'ARCHIVED'" + ) + ) + return [(row.id, row.title, row.content) for row in result.fetchall()] + async def save_embedding(self, entry_id: str, embedding: list[float]) -> bool: """儲存向量 embedding (768 維)""" # 直接用 raw SQL 寫入 pgvector 欄位 diff --git a/apps/api/src/services/knowledge_service.py b/apps/api/src/services/knowledge_service.py index 96e35548..6c50dfdd 100644 --- a/apps/api/src/services/knowledge_service.py +++ b/apps/api/src/services/knowledge_service.py @@ -50,6 +50,12 @@ def get_knowledge_service() -> "KnowledgeService": class KnowledgeService: """Knowledge Base 業務邏輯""" + def __init__(self) -> None: + # I2: 注入 embedding service,避免每次呼叫 new 實例 + self._embed_svc = OllamaEmbeddingService(model="nomic-embed-text", timeout=15.0) + # I1: 持有背景 Task 引用,防止 GC 提前回收 + self._pending_tasks: set[asyncio.Task] = set() # type: ignore[type-arg] + async def create_entry(self, data: KnowledgeEntryCreate) -> KnowledgeEntry: """建立知識條目,建立後背景自動產生 embedding""" async with get_db_context() as db: @@ -62,17 +68,19 @@ class KnowledgeService: source=entry.source, ) - # 背景產生 embedding (不阻塞回應) - asyncio.create_task(self._embed_entry(entry.id, data.title, data.content)) + # 背景產生 embedding (不阻塞回應);持有引用防 GC 回收 + task = asyncio.create_task(self._embed_entry(entry.id, data.title, data.content)) + self._pending_tasks.add(task) + task.add_done_callback(self._pending_tasks.discard) return entry async def _embed_entry(self, entry_id: str, title: str, content: str) -> None: """背景任務:產生並儲存 embedding""" try: - svc = OllamaEmbeddingService(model="nomic-embed-text", timeout=15.0) text = f"search_document: {title}\n\n{content[:2000]}" - embedding = await svc.embed_text(text) + embedding = await self._embed_svc.embed_text(text) if not embedding: + logger.warning("knowledge_embedding_empty", entry_id=entry_id) return async with get_db_context() as db: repo = KnowledgeDBRepository(db) @@ -174,15 +182,14 @@ class KnowledgeService: Returns: list of (entry, score) 已按相似度降序排列 """ - svc = OllamaEmbeddingService(model="nomic-embed-text", timeout=15.0) query_text = f"search_query: {query}" - embedding = await svc.embed_text(query_text) + embedding = await self._embed_svc.embed_text(query_text) if not embedding: logger.warning("semantic_search_embedding_failed", query=query) return [] async with get_db_context() as db: - repo = KnowledgeDBRepository(db) + repo: IKnowledgeRepository = KnowledgeDBRepository(db) return await repo.semantic_search(embedding, limit=limit, threshold=threshold) async def embed_all_entries(self) -> dict[str, int]: @@ -192,30 +199,23 @@ class KnowledgeService: Returns: {"total": N, "success": N, "failed": N} """ - svc = OllamaEmbeddingService(model="nomic-embed-text", timeout=15.0) - success = failed = 0 - + # C2 修復: 透過 Repository 取得資料,Service 不直接執行 raw SQL async with get_db_context() as db: - from sqlalchemy import text as sa_text - result = await db.execute( - sa_text( - "SELECT id, title, content FROM knowledge_entries " - "WHERE embedding IS NULL AND status != 'ARCHIVED'" - ) - ) - rows = result.fetchall() + repo: IKnowledgeRepository = KnowledgeDBRepository(db) + rows = await repo.list_unembedded_entries() - for row in rows: - entry_id, title, content = row + success = failed = 0 + for entry_id, title, content in rows: try: text = f"search_document: {title}\n\n{content[:2000]}" - embedding = await svc.embed_text(text) + embedding = await self._embed_svc.embed_text(text) if embedding: async with get_db_context() as db: repo = KnowledgeDBRepository(db) await repo.save_embedding(entry_id, embedding) success += 1 else: + logger.warning("embed_all_empty_vector", entry_id=entry_id) failed += 1 except Exception as e: logger.warning("embed_all_failed", entry_id=entry_id, error=str(e)) diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index d816eff9..376d267a 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -5,6 +5,24 @@ --- +## 📍 當前狀態 (2026-04-04 知識庫 pgvector RAG Phase 1 完成 + 首席架構師 Review) + +| 項目 | 狀態 | Commit | +|------|------|--------| +| **pgvector 安裝** | ✅ 192.168.0.188 PostgreSQL | 手動 | +| **embedding 欄位 + ivfflat index** | ✅ knowledge_entries | 手動 migration | +| **API: GET /semantic-search + POST /embed-all** | ✅ | 8960bba | +| **整合測試 23 PASSED** | ✅ | 5e836bd | +| **首席架構師 Review** | ✅ 71→~88 (C1/C2/I1/I2 修復) | patch commit 待推 | +| **IKnowledgeRepository Protocol 補齊** | ✅ C1 修復 | — | +| **Service 層 raw SQL 移至 Repository** | ✅ C2 修復 | — | +| **asyncio.create_task 引用持有** | ✅ I1 修復 | — | +| **OllamaEmbeddingService __init__ 注入** | ✅ I2 修復 | — | + +**下一步**: push gitea → CD 部署 → /embed-all 補齊 7 筆 prod 資料 → E2E 驗證語意搜尋 + +--- + ## 📍 當前狀態 (2026-04-03 Phase 22.6 雙 AI 對話 + 首席架構師 Code Review) | 項目 | 狀態 | Commit/備註 |