feat: rag 서비스 Event Hub 연동 및 연관 회의록 API 추가

This commit is contained in:
djeon
2025-10-29 15:29:40 +09:00
parent 5859b1c498
commit ad7975efbd
20 changed files with 2855 additions and 22 deletions
Binary file not shown.
+138 -1
View File
@@ -22,7 +22,9 @@ from ..models.document import (
)
from ..models.minutes import (
MinutesSearchRequest,
MinutesSearchResult
MinutesSearchResult,
RelatedMinutesRequest,
RelatedMinutesResponse
)
from ..db.postgres_vector import PostgresVectorDB
from ..db.azure_search import AzureAISearchDB
@@ -31,6 +33,7 @@ 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(
@@ -62,6 +65,7 @@ _doc_db = None
_rag_minutes_db = None
_embedding_gen = None
_claude_service = None
_redis_cache = None
def get_config():
@@ -139,6 +143,22 @@ def get_claude_service():
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
# ============================================================================
@@ -501,6 +521,123 @@ async def get_minutes_stats(rag_minutes_db: RagMinutesDB = Depends(get_rag_minut
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/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)
+20 -10
View File
@@ -92,6 +92,8 @@ class RagMinutesDB:
if field in minutes_dict and minutes_dict[field]:
if isinstance(minutes_dict[field], datetime):
minutes_dict[field] = minutes_dict[field].isoformat()
minutes_dict.pop("embedding")
return RagMinutes(**minutes_dict)
@@ -189,7 +191,8 @@ class RagMinutesDB:
self,
query_embedding: List[float],
top_k: int = 5,
similarity_threshold: float = 0.7
similarity_threshold: float = 0.7,
exclude_minutes_id: Optional[str] = None
) -> List[Dict[str, Any]]:
"""
벡터 유사도 검색
@@ -198,27 +201,34 @@ class RagMinutesDB:
query_embedding: 쿼리 임베딩 벡터
top_k: 반환할 최대 결과 수
similarity_threshold: 최소 유사도 임계값
exclude_minutes_id: 제외할 회의록 ID (연관 회의록 검색 시 자기 자신 제외)
Returns:
검색 결과 리스트
"""
with self.get_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
# 제외 조건 추가
exclude_condition = ""
params = [query_embedding, query_embedding, similarity_threshold, query_embedding, top_k]
if exclude_minutes_id:
exclude_condition = "AND minutes_id != %s"
# 파라미터 순서: 처음 4개는 embedding 검색용, 5번째는 exclude용, 6번째는 limit용
params = [query_embedding, query_embedding, similarity_threshold, exclude_minutes_id, query_embedding, top_k]
query = f"""
SELECT *,
1 - (embedding <=> %s::vector) as similarity_score
FROM rag_minutes
WHERE embedding IS NOT NULL
AND 1 - (embedding <=> %s::vector) >= %s
{exclude_condition}
ORDER BY embedding <=> %s::vector
LIMIT %s
""", (
query_embedding,
query_embedding,
similarity_threshold,
query_embedding,
top_k
))
"""
cur.execute(query, params)
results = []
for row in cur.fetchall():
@@ -229,7 +239,7 @@ class RagMinutesDB:
"similarity_score": float(similarity_score)
})
logger.info(f"벡터 검색 완료: {len(results)}개 결과")
logger.info(f"벡터 검색 완료: {len(results)}개 결과 (exclude: {exclude_minutes_id})")
return results
def search_by_keyword(
Binary file not shown.
+38
View File
@@ -106,3 +106,41 @@ class MinutesSearchResult(BaseModel):
"similarity_score": 0.92
}
}
class RelatedMinutesRequest(BaseModel):
"""연관 회의록 조회 요청"""
minute_id: str = Field(..., description="기준 회의록 ID")
meeting_title: str = Field(..., description="회의 제목")
summary: str = Field(..., description="회의록 요약")
top_k: int = Field(5, ge=1, le=20, description="반환할 최대 결과 수")
similarity_threshold: float = Field(0.7, ge=0.0, le=1.0, description="최소 유사도 임계값")
class Config:
json_schema_extra = {
"example": {
"minute_id": "MIN-2025-001",
"meeting_title": "2025 Q1 마케팅 전략 회의",
"summary": "2025년 1분기 마케팅 전략 수립을 위한 회의",
"top_k": 5,
"similarity_threshold": 0.7
}
}
class RelatedMinutesResponse(BaseModel):
"""연관 회의록 조회 응답"""
minutes: RagMinutes
similarity_score: float = Field(..., ge=0.0, le=1.0, description="유사도 점수")
class Config:
json_schema_extra = {
"example": {
"minutes": {
"meeting_id": "MTG-2025-002",
"title": "2024 Q4 마케팅 성과 분석",
"minutes_id": "MIN-2024-050"
},
"similarity_score": 0.85
}
}
+129 -8
View File
@@ -1,6 +1,6 @@
"""
Azure Event Hub Consumer 서비스
회의록 확정 이벤트를 consume하여 RAG 저장소에 저장
회의록 확정 이벤트 및 세그먼트 생성 이벤트를 consume
"""
import asyncio
import json
@@ -13,7 +13,9 @@ from azure.eventhub.extensions.checkpointstoreblobaio import BlobCheckpointStore
from ..models.minutes import RagMinutes, MinutesSection
from ..db.rag_minutes_db import RagMinutesDB
from ..db.postgres_vector import PostgresVectorDB
from ..utils.embedding import EmbeddingGenerator
from ..utils.text_processor import extract_nouns_as_query
logger = logging.getLogger(__name__)
@@ -29,7 +31,9 @@ class EventHubConsumer:
storage_connection_string: str,
storage_container_name: str,
rag_minutes_db: RagMinutesDB,
embedding_gen: EmbeddingGenerator
embedding_gen: EmbeddingGenerator,
term_db: Optional[PostgresVectorDB] = None,
config: Optional[Dict[str, Any]] = None
):
"""
초기화
@@ -42,6 +46,8 @@ class EventHubConsumer:
storage_container_name: Checkpoint 저장 컨테이너 이름
rag_minutes_db: RAG Minutes 데이터베이스
embedding_gen: Embedding 생성기
term_db: 용어집 데이터베이스 (선택)
config: 설정 딕셔너리 (선택)
"""
self.connection_string = connection_string
self.eventhub_name = eventhub_name
@@ -50,6 +56,8 @@ class EventHubConsumer:
self.storage_container_name = storage_container_name
self.rag_minutes_db = rag_minutes_db
self.embedding_gen = embedding_gen
self.term_db = term_db
self.config = config or {}
self.client: Optional[EventHubConsumerClient] = None
self.is_running = False
@@ -106,13 +114,18 @@ class EventHubConsumer:
event_body = event.body_as_str()
event_data = json.loads(event_body)
logger.info(f"이벤트 수신: {event_data.get('eventType', 'unknown')}")
logger.info(f"이벤트 수신: {event_data.get('data', 'unknown')}")
event_type = event_data.get('eventType', 'unknown')
logger.info(f"이벤트 수신: {event_type}")
# 회의록 확정 이벤트 처리
if event_data.get("eventType") == "MINUTES_FINALIZED":
# 이벤트 타입별 처리
if event_type == "MINUTES_FINALIZED":
# 회의록 확정 이벤트
await self._process_minutes_event(event_data)
elif event_type == "SegmentCreated":
# 세그먼트 생성 이벤트 - 용어검색 실행
await self._process_segment_event(event_data)
# Checkpoint 업데이트
await partition_context.update_checkpoint(event)
@@ -131,6 +144,110 @@ class EventHubConsumer:
"""
logger.error(f"Event Hub 에러 (Partition {partition_context.partition_id}): {str(error)}")
async def _process_segment_event(self, event_data: Dict[str, Any]):
"""
세그먼트 생성 이벤트 처리 - 용어검색 실행
Args:
event_data: 이벤트 데이터
"""
try:
# 용어집 DB가 없으면 스킵
if not self.term_db:
logger.debug("용어집 DB가 설정되지 않아 용어검색을 스킵합니다")
return
# 세그먼트 데이터 추출
segment_id = event_data.get("segmentId")
text = event_data.get("text", "")
meeting_id = event_data.get("meetingId")
if not text:
logger.warning(f"세그먼트 {segment_id}에 텍스트가 없습니다")
return
logger.info(f"세그먼트 용어검색 시작: {segment_id} (회의: {meeting_id})")
logger.info(f"텍스트: {text[:100]}...")
# 1. 명사 추출하여 검색 쿼리 생성
search_query = extract_nouns_as_query(text)
logger.info(f"검색 쿼리 변환: '{text[:30]}...''{search_query}'")
# 2. 용어검색 설정
config = self.config.get("term_glossary", {})
search_config = config.get("search", {})
top_k = search_config.get("top_k", 5)
confidence_threshold = search_config.get("confidence_threshold", 0.7)
keyword_weight = search_config.get("keyword_weight", 0.4)
vector_weight = search_config.get("vector_weight", 0.6)
# 3. 키워드 검색
keyword_results = self.term_db.search_by_keyword(
query=search_query,
top_k=top_k,
confidence_threshold=confidence_threshold
)
# 4. 벡터 검색
query_embedding = self.embedding_gen.generate_embedding(search_query)
vector_results = self.term_db.search_by_vector(
query_embedding=query_embedding,
top_k=top_k,
confidence_threshold=confidence_threshold
)
# 5. 하이브리드 검색 결과 통합 (RRF)
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)
else:
# 이미 있는 경우 점수 합산
for r in results:
if r["term"].term_id == term_id:
r["relevance_score"] += result["relevance_score"] * vector_weight
break
# 점수 기준 재정렬
results.sort(key=lambda x: x["relevance_score"], reverse=True)
results = results[:top_k]
# 6. 검색 결과 로깅
if results:
logger.info(f"세그먼트 {segment_id} 용어검색 완료: {len(results)}개 발견")
for idx, result in enumerate(results, 1):
term = result["term"]
score = result["relevance_score"]
logger.info(
f" [{idx}] {term.term_name} "
f"(카테고리: {term.category}, 점수: {score:.3f})"
)
else:
logger.info(f"세그먼트 {segment_id}에서 매칭되는 용어를 찾지 못했습니다")
# 7. 선택적: 검색 결과를 별도 테이블에 저장하거나 Event Hub로 발행
# TODO: 필요시 검색 결과를 저장하거나 downstream 서비스로 전달
except Exception as e:
logger.error(f"세그먼트 이벤트 처리 실패: {str(e)}", exc_info=True)
def _convert_datetime_array_to_string(self, value: Union[str, List, None]) -> Optional[str]:
"""
Java LocalDateTime 배열을 ISO 8601 문자열로 변환
@@ -302,7 +419,8 @@ class EventHubConsumer:
async def start_consumer(
config: Dict[str, Any],
rag_minutes_db: RagMinutesDB,
embedding_gen: EmbeddingGenerator
embedding_gen: EmbeddingGenerator,
term_db: Optional[PostgresVectorDB] = None
):
"""
Event Hub Consumer 시작 (비동기)
@@ -311,6 +429,7 @@ async def start_consumer(
config: 설정 딕셔너리
rag_minutes_db: RAG Minutes 데이터베이스
embedding_gen: Embedding 생성기
term_db: 용어집 데이터베이스 (선택)
"""
eventhub_config = config["eventhub"]
@@ -321,7 +440,9 @@ async def start_consumer(
storage_connection_string=eventhub_config["storage"]["connection_string"],
storage_container_name=eventhub_config["storage"]["container_name"],
rag_minutes_db=rag_minutes_db,
embedding_gen=embedding_gen
embedding_gen=embedding_gen,
term_db=term_db,
config=config
)
try:
+206
View File
@@ -0,0 +1,206 @@
"""
Redis 캐싱 유틸리티
"""
import redis
import json
import logging
from typing import Optional, Any
from functools import wraps
logger = logging.getLogger(__name__)
class RedisCache:
"""Redis 캐싱 클래스"""
def __init__(
self,
host: str = "localhost",
port: int = 6379,
db: int = 0,
password: Optional[str] = None,
decode_responses: bool = True
):
"""
초기화
Args:
host: Redis 호스트
port: Redis 포트
db: Redis DB 번호
password: Redis 비밀번호
decode_responses: 응답 디코딩 여부
"""
try:
self.client = redis.Redis(
host=host,
port=port,
db=db,
password=password,
decode_responses=decode_responses
)
# 연결 테스트
self.client.ping()
logger.info(f"Redis 연결 성공: {host}:{port}")
except Exception as e:
logger.warning(f"Redis 연결 실패: {str(e)} - 캐싱 비활성화")
self.client = None
def get(self, key: str) -> Optional[Any]:
"""
캐시에서 값 조회
Args:
key: 캐시 키
Returns:
캐시된 값 또는 None
"""
if not self.client:
return None
try:
value = self.client.get(key)
if value:
logger.debug(f"캐시 HIT: {key}")
return json.loads(value)
logger.debug(f"캐시 MISS: {key}")
return None
except Exception as e:
logger.error(f"캐시 조회 실패 ({key}): {str(e)}")
return None
def set(self, key: str, value: Any, ttl: int = 3600) -> bool:
"""
캐시에 값 저장
Args:
key: 캐시 키
value: 저장할 값
ttl: 만료 시간 (초)
Returns:
성공 여부
"""
if not self.client:
return False
try:
serialized = json.dumps(value, ensure_ascii=False)
self.client.setex(key, ttl, serialized)
logger.debug(f"캐시 저장: {key} (TTL: {ttl}s)")
return True
except Exception as e:
logger.error(f"캐시 저장 실패 ({key}): {str(e)}")
return False
def delete(self, key: str) -> bool:
"""
캐시 삭제
Args:
key: 캐시 키
Returns:
성공 여부
"""
if not self.client:
return False
try:
self.client.delete(key)
logger.debug(f"캐시 삭제: {key}")
return True
except Exception as e:
logger.error(f"캐시 삭제 실패 ({key}): {str(e)}")
return False
def delete_pattern(self, pattern: str) -> int:
"""
패턴 매칭으로 여러 캐시 삭제
Args:
pattern: 캐시 키 패턴 (예: "minutes:*")
Returns:
삭제된 키 개수
"""
if not self.client:
return 0
try:
keys = self.client.keys(pattern)
if keys:
count = self.client.delete(*keys)
logger.info(f"캐시 일괄 삭제: {count}개 키 ({pattern})")
return count
return 0
except Exception as e:
logger.error(f"캐시 패턴 삭제 실패 ({pattern}): {str(e)}")
return 0
def exists(self, key: str) -> bool:
"""
캐시 존재 여부 확인
Args:
key: 캐시 키
Returns:
존재 여부
"""
if not self.client:
return False
try:
return self.client.exists(key) > 0
except Exception as e:
logger.error(f"캐시 존재 확인 실패 ({key}): {str(e)}")
return False
def cached(prefix: str, ttl: int = 3600, key_builder=None):
"""
함수 결과 캐싱 데코레이터
Args:
prefix: 캐시 키 prefix
ttl: 만료 시간 (초)
key_builder: 캐시 키 생성 함수 (선택사항)
Example:
@cached(prefix="minutes:", ttl=1800)
def get_minutes_by_id(minutes_id: str):
...
"""
def decorator(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
# Redis 캐시 인스턴스 확인
cache = getattr(self, '_cache', None)
if not cache or not cache.client:
return func(self, *args, **kwargs)
# 캐시 키 생성
if key_builder:
cache_key = key_builder(*args, **kwargs)
else:
# 기본: 첫 번째 인자를 키로 사용
cache_key = f"{prefix}{args[0] if args else ''}"
# 캐시 조회
cached_value = cache.get(cache_key)
if cached_value is not None:
return cached_value
# 함수 실행
result = func(self, *args, **kwargs)
# 결과 캐싱
if result is not None:
cache.set(cache_key, result, ttl)
return result
return wrapper
return decorator