""" Vector DB 통합 시스템 FastAPI 애플리케이션 """ from fastapi import FastAPI, HTTPException, Depends from fastapi.middleware.cors import CORSMiddleware from typing import List, Dict, Any import logging from pathlib import Path from ..models.term import ( Term, TermSearchRequest, TermSearchResult, TermExplainRequest, TermExplanation, TermStats ) from ..models.document import ( DocumentSearchRequest, DocumentSearchResult, DocumentStats ) from ..models.minutes import ( MinutesSearchRequest, MinutesSearchResult, RelatedMinutesRequest, RelatedMinutesResponse ) from ..db.postgres_vector import PostgresVectorDB from ..db.azure_search import AzureAISearchDB from ..db.rag_minutes_db import RagMinutesDB from ..services.claude_service import ClaudeService from ..utils.config import load_config, get_database_url from ..utils.embedding import EmbeddingGenerator from ..utils.text_processor import extract_nouns_as_query from ..utils.redis_cache import RedisCache # 로깅 설정 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) # FastAPI 앱 생성 app = FastAPI( title="Vector DB 통합 시스템", description="회의록 작성 시스템을 위한 Vector DB 기반 용어집 및 관련자료 검색 API", version="1.0.0" ) # CORS 설정 app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # 전역 변수 (의존성 주입용) _config = None _term_db = None _doc_db = None _rag_minutes_db = None _embedding_gen = None _claude_service = None _redis_cache = None def get_config(): """설정 로드""" global _config if _config is None: config_path = Path(__file__).parent.parent.parent / "config.yaml" _config = load_config(str(config_path)) return _config def get_term_db(): """용어집 DB 연결""" global _term_db if _term_db is None: config = get_config() db_url = get_database_url(config) _term_db = PostgresVectorDB(db_url) return _term_db def get_doc_db(): """관련자료 DB 연결""" global _doc_db if _doc_db is None: config = get_config() azure_search = config["azure_search"] _doc_db = AzureAISearchDB( endpoint=azure_search["endpoint"], api_key=azure_search["api_key"], index_name=azure_search["index_name"], api_version=azure_search["api_version"] ) return _doc_db def get_rag_minutes_db(): """RAG 회의록 DB 연결""" global _rag_minutes_db if _rag_minutes_db is None: config = get_config() db_url = get_database_url(config) _rag_minutes_db = RagMinutesDB(db_url) return _rag_minutes_db def get_embedding_gen(): """임베딩 생성기""" global _embedding_gen if _embedding_gen is None: config = get_config() azure_openai = config["azure_openai"] _embedding_gen = EmbeddingGenerator( api_key=azure_openai["api_key"], endpoint=azure_openai["endpoint"], model=azure_openai["embedding_model"], dimension=azure_openai["embedding_dimension"], api_version=azure_openai["api_version"] ) return _embedding_gen def get_claude_service(): """Claude 서비스""" global _claude_service if _claude_service is None: config = get_config() claude = config["claude"] _claude_service = ClaudeService( api_key=claude["api_key"], model=claude["model"], max_tokens=claude["max_tokens"], temperature=claude["temperature"] ) return _claude_service def get_redis_cache(): """Redis 캐시""" global _redis_cache if _redis_cache is None: config = get_config() redis_config = config["redis"] _redis_cache = RedisCache( host=redis_config["host"], port=redis_config["port"], db=redis_config["db"], password=redis_config.get("password"), decode_responses=redis_config.get("decode_responses", True) ) return _redis_cache # ============================================================================ # 용어집 API # ============================================================================ @app.get("/") async def root(): """루트 엔드포인트""" return { "service": "Vector DB 통합 시스템", "version": "1.0.0", "endpoints": { "용어집": "/api/terms/*", "관련자료": "/api/documents/*" } } @app.post("/api/rag/terms/search", response_model=List[TermSearchResult]) async def search_terms( request: TermSearchRequest, term_db: PostgresVectorDB = Depends(get_term_db), embedding_gen: EmbeddingGenerator = Depends(get_embedding_gen) ): """ 용어 검색 (Hybrid: Keyword + Vector) Args: request: 검색 요청 Returns: 검색 결과 리스트 """ try: config = get_config() # 명사 추출하여 검색 쿼리 생성 search_query = extract_nouns_as_query(request.query) logger.info(f"검색 쿼리 변환: '{request.query}' → '{search_query}'") if request.search_type == "keyword": # 키워드 검색 results = term_db.search_by_keyword( query=search_query, top_k=request.top_k, confidence_threshold=request.confidence_threshold ) elif request.search_type == "vector": # 벡터 검색 (임베딩은 원본 쿼리 사용) query_embedding = embedding_gen.generate_embedding(search_query) results = term_db.search_by_vector( query_embedding=query_embedding, top_k=request.top_k, confidence_threshold=request.confidence_threshold ) else: # hybrid # 하이브리드 검색 keyword_results = term_db.search_by_keyword( query=search_query, top_k=request.top_k, confidence_threshold=request.confidence_threshold ) query_embedding = embedding_gen.generate_embedding(search_query) vector_results = term_db.search_by_vector( query_embedding=query_embedding, top_k=request.top_k, confidence_threshold=request.confidence_threshold ) # RRF 통합 keyword_weight = config["term_glossary"]["search"]["keyword_weight"] vector_weight = config["term_glossary"]["search"]["vector_weight"] # 간단한 가중합 results = [] seen_ids = set() for result in keyword_results: term_id = result["term"].term_id if term_id not in seen_ids: result["relevance_score"] *= keyword_weight result["match_type"] = "hybrid" results.append(result) seen_ids.add(term_id) for result in vector_results: term_id = result["term"].term_id if term_id not in seen_ids: result["relevance_score"] *= vector_weight result["match_type"] = "hybrid" results.append(result) seen_ids.add(term_id) # 점수 기준 재정렬 results.sort(key=lambda x: x["relevance_score"], reverse=True) results = results[:request.top_k] # 응답 형식으로 변환 return [ TermSearchResult( term=result["term"], relevance_score=result["relevance_score"], match_type=result["match_type"] ) for result in results ] except Exception as e: logger.error(f"용어 검색 실패: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/rag/terms/{term_id}", response_model=Term) async def get_term( term_id: str, term_db: PostgresVectorDB = Depends(get_term_db) ): """ 용어 상세 조회 Args: term_id: 용어 ID Returns: 용어 객체 """ try: term = term_db.get_term_by_id(term_id) if not term: raise HTTPException(status_code=404, detail="용어를 찾을 수 없습니다") return term except HTTPException: raise except Exception as e: logger.error(f"용어 조회 실패: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/rag/terms/{term_id}/explain", response_model=TermExplanation) async def explain_term( term_id: str, request: TermExplainRequest, term_db: PostgresVectorDB = Depends(get_term_db), claude_service: ClaudeService = Depends(get_claude_service) ): """ 용어 맥락 기반 설명 생성 (Claude AI) Args: term_id: 용어 ID request: 설명 요청 Returns: 용어 설명 """ try: # 용어 조회 term = term_db.get_term_by_id(term_id) if not term: raise HTTPException(status_code=404, detail="용어를 찾을 수 없습니다") # Claude AI 호출 result = claude_service.explain_term( term_name=term.term_name, definition=term.definition, context=term.context, meeting_context=request.meeting_context ) return TermExplanation( term=term, explanation=result["explanation"], context_documents=[], generated_by=result["generated_by"], cached=result["cached"] ) except HTTPException: raise except Exception as e: logger.error(f"용어 설명 생성 실패: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/rag/terms/stats", response_model=TermStats) async def get_term_stats(term_db: PostgresVectorDB = Depends(get_term_db)): """용어 통계 조회""" try: stats = term_db.get_stats() return TermStats( total_terms=stats["total_terms"], by_category=stats["by_category"], by_source_type={}, avg_confidence=stats["avg_confidence"] ) except Exception as e: logger.error(f"통계 조회 실패: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) # ============================================================================ # 관련자료 API # ============================================================================ @app.post("/api/rag/documents/search", response_model=List[DocumentSearchResult]) async def search_documents( request: DocumentSearchRequest, doc_db: AzureAISearchDB = Depends(get_doc_db), embedding_gen: EmbeddingGenerator = Depends(get_embedding_gen) ): """ 관련 문서 검색 (Hybrid Search + Semantic Ranking) Args: request: 검색 요청 Returns: 검색 결과 리스트 """ try: # 쿼리 임베딩 생성 query_embedding = embedding_gen.generate_embedding(request.query) # Hybrid Search 실행 results = doc_db.hybrid_search( query=request.query, query_embedding=query_embedding, top_k=request.top_k, folder=request.folder, document_type=request.document_type, semantic_ranking=request.semantic_ranking ) # 관련도 임계값 필터링 filtered_results = [ r for r in results if r["relevance_score"] >= request.relevance_threshold ] # 응답 형식으로 변환 return [ DocumentSearchResult(**result) for result in filtered_results ] except Exception as e: logger.error(f"문서 검색 실패: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/rag/documents/stats", response_model=DocumentStats) async def get_document_stats(doc_db: AzureAISearchDB = Depends(get_doc_db)): """문서 통계 조회""" try: stats = doc_db.get_stats() return DocumentStats( total_documents=stats["total_documents"], by_type=stats["by_type"], by_domain={}, total_chunks=stats["total_chunks"] ) except Exception as e: logger.error(f"통계 조회 실패: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) # ============================================================================ # RAG 회의록 API # ============================================================================ @app.post("/api/rag/minutes/search", response_model=List[MinutesSearchResult]) async def search_related_minutes( request: MinutesSearchRequest, rag_minutes_db: RagMinutesDB = Depends(get_rag_minutes_db), embedding_gen: EmbeddingGenerator = Depends(get_embedding_gen) ): """ 연관 회의록 검색 (Vector Similarity) Args: request: 검색 요청 Returns: 유사 회의록 리스트 """ try: # 쿼리 임베딩 생성 logger.info(f"회의록 검색 시작: {request.query[:50]}...") query_embedding = embedding_gen.generate_embedding(request.query) # 벡터 유사도 검색 results = rag_minutes_db.search_by_vector( query_embedding=query_embedding, top_k=request.top_k, similarity_threshold=request.similarity_threshold ) # 응답 형식으로 변환 search_results = [ MinutesSearchResult( minutes=result["minutes"], similarity_score=result["similarity_score"] ) for result in results ] logger.info(f"회의록 검색 완료: {len(search_results)}개 결과") return search_results except Exception as e: logger.error(f"회의록 검색 실패: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/rag/minutes/{minutes_id}") async def get_minutes( minutes_id: str, rag_minutes_db: RagMinutesDB = Depends(get_rag_minutes_db) ): """ 회의록 상세 조회 Args: minutes_id: 회의록 ID Returns: 회의록 객체 """ try: minutes = rag_minutes_db.get_minutes_by_id(minutes_id) if not minutes: raise HTTPException(status_code=404, detail="회의록을 찾을 수 없습니다") return minutes except HTTPException: raise except Exception as e: logger.error(f"회의록 조회 실패: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/rag/minutes/stats") async def get_minutes_stats(rag_minutes_db: RagMinutesDB = Depends(get_rag_minutes_db)): """회의록 통계 조회""" try: stats = rag_minutes_db.get_stats() return stats except Exception as e: logger.error(f"통계 조회 실패: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @app.post("/api/rag/minutes/related", response_model=List[RelatedMinutesResponse]) async def get_related_minutes( request: RelatedMinutesRequest, rag_minutes_db: RagMinutesDB = Depends(get_rag_minutes_db), embedding_gen: EmbeddingGenerator = Depends(get_embedding_gen), redis_cache: RedisCache = Depends(get_redis_cache) ): """ 연관 회의록 조회 (Option A: DB 조회 후 벡터 검색 + Redis 캐싱) Args: request: 연관 회의록 조회 요청 - minute_id: 기준 회의록 ID - meeting_title: 회의 제목 (미사용) - summary: 회의록 요약 (미사용) - top_k: 반환할 최대 결과 수 - similarity_threshold: 최소 유사도 임계값 Returns: 연관 회의록 리스트 Process: 1. Redis 캐시에서 연관 회의록 결과 조회 2. 캐시 MISS 시: a. minute_id로 rag_minutes 테이블에서 회의록 조회 (캐싱) b. full_content를 벡터 임베딩으로 변환 c. 벡터 DB에서 유사도 검색 (자기 자신 제외) d. 결과를 Redis에 캐싱 3. 연관 회의록 목록 반환 """ try: config = get_config() cache_config = config.get("rag_minutes", {}).get("cache", {}) cache_prefix = cache_config.get("prefix", "minutes:") minutes_ttl = cache_config.get("ttl", 1800) related_ttl = cache_config.get("related_ttl", 3600) logger.info(f"연관 회의록 조회 시작: minute_id={request.minute_id}") # 1. 캐시 키 생성 related_cache_key = ( f"{cache_prefix}related:{request.minute_id}:" f"{request.top_k}:{request.similarity_threshold}" ) # 2. 캐시 조회 cached_results = redis_cache.get(related_cache_key) if cached_results: logger.info(f"연관 회의록 캐시 HIT: {related_cache_key}") return [ RelatedMinutesResponse(**result) for result in cached_results ] # 3. 캐시 MISS - DB 조회 logger.info(f"연관 회의록 캐시 MISS: {related_cache_key}") # 3-1. 회의록 조회 (캐싱) minutes_cache_key = f"{cache_prefix}{request.minute_id}" base_minutes = redis_cache.get(minutes_cache_key) if base_minutes: logger.info(f"회의록 캐시 HIT: {minutes_cache_key}") # RagMinutes 객체로 변환 from ..models.minutes import RagMinutes base_minutes = RagMinutes(**base_minutes) else: logger.info(f"회의록 캐시 MISS: {minutes_cache_key}") base_minutes = rag_minutes_db.get_minutes_by_id(request.minute_id) if not base_minutes: raise HTTPException( status_code=404, detail=f"회의록을 찾을 수 없습니다: {request.minute_id}" ) # 캐시 저장 redis_cache.set(minutes_cache_key, base_minutes.dict(), minutes_ttl) logger.info(f"기준 회의록 조회 완료: {base_minutes.title}") # 3-2. full_content를 벡터 임베딩으로 변환 query_embedding = embedding_gen.generate_embedding(base_minutes.full_content) logger.info(f"임베딩 생성 완료: {len(query_embedding)}차원") # 3-3. 벡터 유사도 검색 (자기 자신 제외) results = rag_minutes_db.search_by_vector( query_embedding=query_embedding, top_k=request.top_k, similarity_threshold=request.similarity_threshold, exclude_minutes_id=request.minute_id ) # 4. 응답 형식으로 변환 related_minutes = [ RelatedMinutesResponse( minutes=result["minutes"], similarity_score=result["similarity_score"] ) for result in results ] # 5. 결과 캐싱 redis_cache.set( related_cache_key, [r.dict() for r in related_minutes], related_ttl ) logger.info(f"연관 회의록 조회 완료: {len(related_minutes)}개 결과") return related_minutes except HTTPException: raise except Exception as e: logger.error(f"연관 회의록 조회 실패: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)