fix(knowledge): 首席架構師 Review 修復 C1+C2+I1+I2 (71→~88/100)
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 7m16s

C1: IKnowledgeRepository Protocol 補齊 save_embedding + semantic_search +
    list_unembedded_entries,恢復 Interface 先行保護層

C2: embed_all_entries Service 層 raw SQL 移至 Repository.list_unembedded_entries()
    Service 改透過 Protocol 呼叫,符合 leWOOOgo 積木化原則

I1: asyncio.create_task 加入 _pending_tasks set 持有引用,防 GC 回收與
    Shutdown 時 Task 遺失;task done 後自動 discard

I2: OllamaEmbeddingService 從每次 new 改為 KnowledgeService.__init__ 注入,
    單一實例重用

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-04 11:22:38 +08:00
parent 8960bba7fe
commit cddc4cb1fc
4 changed files with 69 additions and 21 deletions

View File

@@ -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):

View File

@@ -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 欄位

View File

@@ -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))

View File

@@ -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/備註 |