소형 건설업체(100억 미만)를 위한 AI 기반 토목공사 통합관리 플랫폼 Backend (FastAPI): - SQLAlchemy 모델 13개 (users, projects, wbs, tasks, daily_reports, reports, inspections, quality, weather, permits, rag, settings) - API 라우터 11개 (auth, projects, tasks, daily_reports, reports, inspections, weather, rag, kakao, permits, settings) - Services: Claude AI 래퍼, CPM Gantt 계산, 기상청 API, RAG(pgvector), 카카오 Skill API - Alembic 마이그레이션 (pgvector 포함) - pytest 테스트 (CPM, 날씨 경보) Frontend (Next.js 15): - 11개 페이지 (대시보드, 프로젝트, Gantt, 일보, 검측, 품질, 날씨, 인허가, RAG, 설정) - TanStack Query + Zustand + Tailwind CSS 인프라: - Docker Compose (PostgreSQL pgvector + backend + frontend) - 한국어 README 및 설치 가이드 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
126 lines
4.0 KiB
Python
126 lines
4.0 KiB
Python
"""
|
|
RAG (Retrieval-Augmented Generation) service.
|
|
Embeds questions, retrieves relevant chunks, and generates answers with Claude.
|
|
"""
|
|
import httpx
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, text
|
|
from app.config import settings
|
|
from app.models.rag import RagChunk, RagSource
|
|
from app.services.ai_engine import complete
|
|
from app.services.prompts.rag import SYSTEM_PROMPT, build_prompt
|
|
|
|
|
|
async def embed_text(text_input: str) -> list[float]:
|
|
"""Get embedding vector for text using Voyage AI or OpenAI."""
|
|
if settings.VOYAGE_API_KEY:
|
|
return await _embed_voyage(text_input)
|
|
elif settings.OPENAI_API_KEY:
|
|
return await _embed_openai(text_input)
|
|
else:
|
|
raise ValueError("임베딩 API 키가 설정되지 않았습니다 (VOYAGE_API_KEY 또는 OPENAI_API_KEY)")
|
|
|
|
|
|
async def _embed_voyage(text_input: str) -> list[float]:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
resp = await client.post(
|
|
"https://api.voyageai.com/v1/embeddings",
|
|
headers={"Authorization": f"Bearer {settings.VOYAGE_API_KEY}"},
|
|
json={"model": settings.EMBEDDING_MODEL, "input": text_input},
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()["data"][0]["embedding"]
|
|
|
|
|
|
async def _embed_openai(text_input: str) -> list[float]:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
resp = await client.post(
|
|
"https://api.openai.com/v1/embeddings",
|
|
headers={"Authorization": f"Bearer {settings.OPENAI_API_KEY}"},
|
|
json={"model": "text-embedding-3-small", "input": text_input},
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()["data"][0]["embedding"]
|
|
|
|
|
|
async def retrieve_chunks(
|
|
db: AsyncSession,
|
|
question_embedding: list[float],
|
|
top_k: int = 5,
|
|
source_types: list[str] | None = None,
|
|
) -> list[dict]:
|
|
"""Retrieve most relevant chunks using pgvector cosine similarity."""
|
|
embedding_str = "[" + ",".join(str(x) for x in question_embedding) + "]"
|
|
|
|
# Build query with optional source type filter
|
|
source_filter = ""
|
|
if source_types:
|
|
types_str = ", ".join(f"'{t}'" for t in source_types)
|
|
source_filter = f"AND rs.source_type IN ({types_str})"
|
|
|
|
query = text(f"""
|
|
SELECT
|
|
rc.id,
|
|
rc.content,
|
|
rc.metadata,
|
|
rs.title,
|
|
rs.source_type,
|
|
1 - (rc.embedding <=> '{embedding_str}'::vector) AS relevance_score
|
|
FROM rag_chunks rc
|
|
JOIN rag_sources rs ON rs.id = rc.source_id
|
|
WHERE rc.embedding IS NOT NULL
|
|
{source_filter}
|
|
ORDER BY rc.embedding <=> '{embedding_str}'::vector
|
|
LIMIT {top_k}
|
|
""")
|
|
|
|
result = await db.execute(query)
|
|
rows = result.fetchall()
|
|
|
|
return [
|
|
{
|
|
"id": str(row.id),
|
|
"content": row.content,
|
|
"metadata": row.metadata,
|
|
"title": row.title,
|
|
"source_type": row.source_type,
|
|
"relevance_score": float(row.relevance_score),
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
|
|
async def ask(
|
|
db: AsyncSession,
|
|
question: str,
|
|
top_k: int = 5,
|
|
source_types: list[str] | None = None,
|
|
) -> dict:
|
|
"""Full RAG pipeline: embed -> retrieve -> generate."""
|
|
# 1. Embed the question
|
|
embedding = await embed_text(question)
|
|
|
|
# 2. Retrieve relevant chunks
|
|
chunks = await retrieve_chunks(db, embedding, top_k, source_types)
|
|
|
|
if not chunks:
|
|
return {
|
|
"question": question,
|
|
"answer": "관련 자료를 찾을 수 없습니다. 더 구체적인 질문을 입력하거나, 관련 자료가 업로드되었는지 확인해주세요.",
|
|
"sources": [],
|
|
}
|
|
|
|
# 3. Build prompt and generate answer
|
|
prompt = build_prompt(question, chunks)
|
|
answer = await complete(
|
|
messages=[{"role": "user", "content": prompt}],
|
|
system=SYSTEM_PROMPT,
|
|
temperature=0.5,
|
|
)
|
|
|
|
return {
|
|
"question": question,
|
|
"answer": answer,
|
|
"sources": chunks,
|
|
}
|