""" Qdrant 기반 메뉴 벡터 저장소 (MenuVectorStore). ChromaDB → Qdrant 교체. 공개 인터페이스는 동일하게 유지하여 search_engine / 빌드 스크립트 변경을 최소화한다. Qdrant point ID 규칙: Qdrant는 point ID로 int 또는 UUID만 허용. menu_id("SCR_1300")는 uuid5(NAMESPACE, menu_id)로 결정적 UUID 변환하고, 원본 menu_id는 payload에 저장한다. Distance.COSINE: 정규화된 임베딩(normalize_embeddings=True) 기준 score = 코사인 유사도. ChromaDB의 (1 - distance) 변환 불필요. 연결 대상 (QDRANT_MODE): server (기본): QDRANT_URL HTTP 연결 (docker-compose qdrant 컨테이너 / 사내 서버) local : QDRANT_PATH 폴더 기반 임베디드 실행 (서버 불필요, HF Spaces 단일 컨테이너) """ import uuid from qdrant_client.models import ( Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue, PayloadSchemaType, ) from config import QDRANT_COLLECTION_NAME, EMBEDDING_DIM from core.qdrant_conn import get_qdrant_client _UUID_NS = uuid.UUID("6f9619ff-8b86-d011-b42d-00c04fc964ff") # 고정 네임스페이스 def _point_id(menu_id: str) -> str: """menu_id → 결정적 UUID (동일 menu_id는 항상 동일 point ID → upsert 멱등)""" return str(uuid.uuid5(_UUID_NS, menu_id)) class MenuVectorStore: """ Qdrant 기반 메뉴 벡터 저장소. 재시작해도 컬렉션 영속(볼륨). 컬렉션이 없으면 자동 생성하고 category payload 인덱스를 만들어 카테고리 필터를 pre-filter로 처리한다. """ def __init__(self): # 공유 클라이언트 사용 (local 모드 단일 인스턴스 제약 + 쿼리 인덱스와 공유) self.client = get_qdrant_client() self.collection_name = QDRANT_COLLECTION_NAME self._ensure_collection() def _ensure_collection(self): """컬렉션 없으면 생성 + category 인덱스 부여""" existing = {c.name for c in self.client.get_collections().collections} if self.collection_name not in existing: self.client.create_collection( collection_name=self.collection_name, vectors_config=VectorParams(size=EMBEDDING_DIM, distance=Distance.COSINE), ) # 카테고리 pre-filter용 payload 인덱스 self.client.create_payload_index( collection_name=self.collection_name, field_name="category", field_schema=PayloadSchemaType.KEYWORD, ) print(f"[VectorStore] Qdrant 컬렉션 생성: {self.collection_name} (dim={EMBEDDING_DIM}, cosine)") # ------------------------------------------------------------------------- # 쓰기 # ------------------------------------------------------------------------- def upsert_menus(self, menu_ids: list, embeddings: list, documents: list, metadatas: list): """메뉴 벡터 일괄 업서트 (menu_id·document를 payload에 동봉)""" points = [] for mid, emb, doc, meta in zip(menu_ids, embeddings, documents, metadatas): payload = dict(meta) if meta else {} payload["menu_id"] = mid payload["document"] = doc points.append(PointStruct(id=_point_id(mid), vector=emb, payload=payload)) # 대용량 대비 배치 업서트 BATCH = 256 for i in range(0, len(points), BATCH): self.client.upsert( collection_name=self.collection_name, points=points[i:i + BATCH], ) print(f"[완료] {len(points)}개 메뉴 저장 완료") # ------------------------------------------------------------------------- # 검색 # ------------------------------------------------------------------------- def search(self, query_embedding: list, top_n: int = 5, where: dict = None) -> list: """ 유사도 검색. Args: query_embedding: 쿼리 벡터 (1024dim, 정규화됨) top_n: 반환 개수 where: {"category": "국내주식"} 형태. Qdrant pre-filter로 적용. Returns: [{"menu_id", "similarity", "document", "metadata"}, ...] """ if self.count() == 0: return [] qfilter = None if where and where.get("category"): qfilter = Filter(must=[ FieldCondition(key="category", match=MatchValue(value=where["category"])) ]) # qdrant-client 1.12+ : search() 제거 → query_points() 사용 resp = self.client.query_points( collection_name=self.collection_name, query=query_embedding, query_filter=qfilter, limit=top_n, with_payload=True, ) output = [] for h in resp.points: payload = dict(h.payload or {}) menu_id = payload.pop("menu_id", None) document = payload.pop("document", "") output.append({ "menu_id": menu_id, "similarity": round(float(h.score), 4), # COSINE → 유사도 직접 "document": document, "metadata": payload, # menu_id·document 제외한 나머지 = 기존 metadata }) return output # ------------------------------------------------------------------------- # 유틸 # ------------------------------------------------------------------------- def count(self) -> int: """총 포인트 수 (search_engine에서 dense_top_k 산정에 사용)""" try: return self.client.count(self.collection_name, exact=True).count except Exception: return 0 def get_stats(self) -> dict: return { "total_menus": self.count(), "collection_name": self.collection_name, } def reset(self): """컬렉션 초기화 (재구축 시 사용)""" try: self.client.delete_collection(self.collection_name) except Exception: pass self._ensure_collection() print("컬렉션 초기화 완료")