AI_Menu_Search / core /vectorstore.py
Juhaha
feat: 쿼리 인덱스 ChromaDB→Qdrant 통일 + 도메인/상품 라우팅 보강
fab35a9
Raw
History Blame Contribute Delete
6.23 kB
"""
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("컬렉션 초기화 완료")