Files
exAichatbot_agent/exAiChatBot-chatbot2.0-agent/scripts/vector_store.py
T
Macbook 4b86b2a660 Agent 2.0 exdev 서버 배포 스택
- server-dev start/stop/deploy 및 Gitea push 자동 배포
- local-dev 로컬 개발 환경

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:57:30 +09:00

1132 lines
41 KiB
Python

"""
vector_store.py
────────────────────────────────────────────
벡터 DB 추상화 레이어: FAISS / Qdrant 통합 인터페이스
"""
import os
import pickle
import re
import uuid
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Dict, Any, Optional, Tuple
import numpy as np
import faiss
# ────────────────────────────────────────────
# 환경 변수 설정
# ────────────────────────────────────────────
VECTOR_STORE = os.getenv("VECTOR_STORE", "faiss").lower()
QDRANT_HOST = os.getenv("QDRANT_HOST", "localhost")
QDRANT_PORT = int(os.getenv("QDRANT_PORT", "6333"))
QDRANT_COLLECTION = os.getenv("QDRANT_COLLECTION", "qa_vectors")
HYBRID_SEARCH_ENABLED = os.getenv("HYBRID_SEARCH_ENABLED", "false").lower() == "true"
SPARSE_SEARCH_ENABLED = os.getenv("SPARSE_SEARCH_ENABLED", "true").lower() == "true"
SPARSE_TOP_K = int(os.getenv("SPARSE_TOP_K", "30"))
HYBRID_MERGE_TOP_K = int(os.getenv("HYBRID_MERGE_TOP_K", "40"))
# 결정적 point ID 생성을 위한 고정 네임스페이스 (변경 금지: 바꾸면 기존 ID와 불일치)
POINT_ID_NAMESPACE = uuid.UUID("b1e7c0de-0000-4a00-8000-71636861746b")
def make_point_id(meta: Dict[str, Any]) -> str:
"""
FAQ 메타데이터로부터 결정적(deterministic) point ID(UUID) 생성.
- 질문(q) 텍스트(trim) 기준 → 동일 질문은 항상 같은 ID
- reingest·업로드·어드민 추가 시 upsert / 중복 방지
"""
q = str(meta.get("q") or meta.get("question") or "").strip()
if not q:
key = f"rand:{uuid.uuid4()}"
else:
key = f"q:{q}"
return str(uuid.uuid5(POINT_ID_NAMESPACE, key))
def source_created_at_key(value: Any) -> Tuple[int, int, int]:
"""source_created_at 정렬 키 — 큰 값일수록 최신. 빈/파싱불가 → (0,0,0)."""
s = str(value or "").strip()
if not s:
return (0, 0, 0)
m = re.match(r"^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})", s)
if m:
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
m = re.match(r"^(\d{4})(\d{2})(\d{2})$", s)
if m:
return (int(m.group(1)), int(m.group(2)), int(m.group(3)))
if re.match(r"^\d{4}$", s):
return (int(s), 0, 0)
m = re.search(r"\b(20\d{2})\b", s)
if m:
return (int(m.group(1)), 0, 0)
return (0, 0, 0)
def source_created_at_cmp(incoming: Any, existing: Any) -> int:
"""incoming vs existing — 양수면 incoming이 더 최신."""
a, b = source_created_at_key(incoming), source_created_at_key(existing)
if a < b:
return -1
if a > b:
return 1
return 0
def _now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
# ────────────────────────────────────────────
# 추상 인터페이스
# ────────────────────────────────────────────
class VectorStore(ABC):
"""벡터 DB 추상 인터페이스"""
@abstractmethod
def add_vectors(
self,
vectors: np.ndarray,
metadatas: List[Dict[str, Any]],
skip_if_older: bool = True,
) -> None:
"""벡터 추가"""
pass
@abstractmethod
def search(
self,
query_vector: np.ndarray,
top_k: int = 30,
threshold: Optional[float] = None,
filter_dict: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""
벡터 검색
Returns:
[{"meta": {...}, "score": float}, ...]
"""
pass
@abstractmethod
def save(self, directory: str) -> None:
"""인덱스 저장"""
pass
@abstractmethod
def load(self, directory: str) -> None:
"""인덱스 로드"""
pass
@abstractmethod
def count(self) -> int:
"""벡터 개수"""
pass
# ── 어드민(조회/삭제)용 선택 기능 ──
# 기본은 미지원. 증분 운영이 가능한 스토어(Qdrant)에서만 오버라이드한다.
def get_by_id(self, point_id: str) -> Optional[Dict[str, Any]]:
raise NotImplementedError("get_by_id는 Qdrant 스토어에서만 지원합니다")
def list_points(
self,
limit: int = 50,
offset: Optional[str] = None,
filter_dict: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
raise NotImplementedError("list_points는 Qdrant 스토어에서만 지원합니다")
def delete_by_id(self, point_id: str) -> None:
raise NotImplementedError("delete_by_id는 Qdrant 스토어에서만 지원합니다")
def delete_by_ids(self, point_ids: List[str]) -> None:
raise NotImplementedError("delete_by_ids는 Qdrant 스토어에서만 지원합니다")
def keyword_search(
self,
keyword: str,
fields: Tuple[str, ...] = ("q", "a"),
filter_dict: Optional[Dict[str, Any]] = None,
limit: int = 50,
skip: int = 0,
batch: int = 256,
max_scan: int = 500000,
count_total: bool = True,
) -> Tuple[List[Dict[str, Any]], int, bool]:
raise NotImplementedError("keyword_search는 Qdrant 스토어에서만 지원합니다")
def distinct_payload_values(self, field: str) -> Dict[str, int]:
raise NotImplementedError("distinct_payload_values는 Qdrant 스토어에서만 지원합니다")
# ────────────────────────────────────────────
# FAISS 구현
# ────────────────────────────────────────────
class FAISSStore(VectorStore):
"""FAISS 기반 벡터 스토어 (단순/고속)"""
def __init__(self):
self.index: Optional[faiss.IndexFlatIP] = None
self.metadatas: List[Dict[str, Any]] = []
self.dimension: Optional[int] = None
print("[FAISSStore] 초기화 완료")
def add_vectors(
self,
vectors: np.ndarray,
metadatas: List[Dict[str, Any]],
skip_if_older: bool = True,
) -> None:
"""벡터 추가 (정규화된 벡터 전제)"""
if vectors.shape[0] != len(metadatas):
raise ValueError(f"벡터 수({vectors.shape[0]})와 메타데이터 수({len(metadatas)}) 불일치")
vectors = vectors.astype("float32")
# 인덱스 초기화
if self.index is None:
self.dimension = vectors.shape[1]
self.index = faiss.IndexFlatIP(self.dimension)
print(f"[FAISSStore] 새 인덱스 생성: dim={self.dimension}")
# 정규화 확인 (IndexFlatIP는 정규화된 벡터 필요)
norms = np.linalg.norm(vectors, axis=1)
if not np.allclose(norms, 1.0, atol=1e-5):
print(f"[FAISSStore] ⚠️ 정규화되지 않은 벡터 감지 (norm 범위: [{norms.min():.4f}, {norms.max():.4f}])")
faiss.normalize_L2(vectors)
print("[FAISSStore] 자동 정규화 완료")
# 추가
self.index.add(vectors)
self.metadatas.extend(metadatas)
print(f"[FAISSStore] {len(metadatas)}개 벡터 추가 (총 {self.count()}개)")
def search(
self,
query_vector: np.ndarray,
top_k: int = 30,
threshold: Optional[float] = None,
filter_dict: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""벡터 검색"""
if self.index is None or self.count() == 0:
return []
# 쿼리 벡터 정규화
query_vector = query_vector.astype("float32")
if query_vector.ndim == 1:
query_vector = query_vector[np.newaxis, :]
norm = np.linalg.norm(query_vector)
if not np.isclose(norm, 1.0, atol=1e-5):
faiss.normalize_L2(query_vector)
# 검색
D, I = self.index.search(query_vector, k=min(top_k, self.count()))
# 결과 조합
results = []
for score, idx in zip(D[0], I[0]):
if idx == -1: # padding
continue
if threshold is not None and score < threshold:
continue
results.append({
"meta": self.metadatas[idx],
"score": float(score)
})
# 메타데이터 필터링 (FAISS는 사후 필터링)
if filter_dict:
results = [
r for r in results
if all(r["meta"].get(k) == v for k, v in filter_dict.items())
]
return results
def save(self, directory: str) -> None:
"""인덱스 및 메타데이터 저장"""
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
index_path = directory / "qa.index"
meta_path = directory / "qa_meta.pkl"
if self.index is None:
raise ValueError("저장할 인덱스가 없습니다")
faiss.write_index(self.index, str(index_path))
with open(meta_path, "wb") as f:
pickle.dump(self.metadatas, f)
print(f"[FAISSStore] 저장 완료: {index_path} ({self.count()}개 벡터)")
def load(self, directory: str) -> None:
"""인덱스 및 메타데이터 로드"""
directory = Path(directory)
index_path = directory / "qa.index"
meta_path = directory / "qa_meta.pkl"
if not index_path.exists():
raise FileNotFoundError(f"인덱스 파일 없음: {index_path}")
if not meta_path.exists():
raise FileNotFoundError(f"메타데이터 파일 없음: {meta_path}")
self.index = faiss.read_index(str(index_path))
with open(meta_path, "rb") as f:
self.metadatas = pickle.load(f)
self.dimension = self.index.d
print(f"[FAISSStore] 로드 완료: {index_path} ({self.count()}개 벡터, dim={self.dimension})")
def count(self) -> int:
"""벡터 개수"""
return self.index.ntotal if self.index else 0
# ────────────────────────────────────────────
# Qdrant 구현 (확장용)
# ────────────────────────────────────────────
class QdrantStore(VectorStore):
"""Qdrant 기반 벡터 스토어 (확장 가능)"""
def __init__(
self,
host: str = QDRANT_HOST,
port: int = QDRANT_PORT,
collection_name: str = QDRANT_COLLECTION
):
try:
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from qdrant_client.models import (
SparseIndexParams,
SparseVector,
SparseVectorParams,
)
except ImportError:
raise ImportError(
"Qdrant를 사용하려면 qdrant-client 설치 필요: pip install qdrant-client"
)
self.client = QdrantClient(host=host, port=port)
self.collection_name = collection_name
self.dimension: Optional[int] = None
self._PointStruct = PointStruct
self._Distance = Distance
self._VectorParams = VectorParams
self._SparseVector = SparseVector
self._SparseVectorParams = SparseVectorParams
self._SparseIndexParams = SparseIndexParams
self.hybrid_enabled = HYBRID_SEARCH_ENABLED or collection_name.endswith("_v2")
self.sparse_search_enabled = SPARSE_SEARCH_ENABLED
print(
f"[QdrantStore] 초기화: {host}:{port}, collection={collection_name}, "
f"hybrid={self.hybrid_enabled}"
)
def _ensure_collection(self, dimension: int) -> None:
"""컬렉션 생성 (존재하지 않을 경우)"""
collections = self.client.get_collections().collections
exists = any(c.name == self.collection_name for c in collections)
if not exists:
if self.hybrid_enabled:
self.client.create_collection(
collection_name=self.collection_name,
vectors_config={
"dense": self._VectorParams(
size=dimension,
distance=self._Distance.COSINE,
)
},
sparse_vectors_config={
"sparse": self._SparseVectorParams(
index=self._SparseIndexParams(on_disk=False)
)
},
)
print(
f"[QdrantStore] hybrid 컬렉션 생성: "
f"{self.collection_name} (dense_dim={dimension}, sparse=sparse)"
)
else:
self.client.create_collection(
collection_name=self.collection_name,
vectors_config=self._VectorParams(
size=dimension,
distance=self._Distance.COSINE # 정규화된 벡터 + 내적 = 코사인
)
)
print(f"[QdrantStore] 컬렉션 생성: {self.collection_name} (dim={dimension})")
else:
print(f"[QdrantStore] 기존 컬렉션 사용: {self.collection_name}")
def find_by_exact_q(self, question: str) -> Optional[Dict[str, Any]]:
"""payload.q 정확일치로 단건 조회 (레거시 source_id 기반 ID 마이그레이션용)."""
q = str(question or "").strip()
if not q:
return None
try:
from qdrant_client.models import Filter, FieldCondition, MatchValue
flt = Filter(must=[FieldCondition(key="q", match=MatchValue(value=q))])
points, _ = self.client.scroll(
collection_name=self.collection_name,
limit=1,
with_payload=True,
with_vectors=False,
scroll_filter=flt,
)
except Exception as e:
print(f"[QdrantStore] find_by_exact_q 실패: {e}")
return None
if not points:
return None
p = points[0]
return {"id": p.id, "meta": p.payload}
def upsert_vectors(
self,
vectors: np.ndarray,
metadatas: List[Dict[str, Any]],
skip_if_older: bool = True,
) -> Dict[str, Any]:
"""
벡터 upsert. 동일 q는 q 기반 ID로 덮어쓰기.
skip_if_older=True 이면 기존 source_created_at이 더 최신이면 스킵.
Returns:
inserted, updated, skipped, last_id, last_action, last_skip_reason
"""
if vectors.shape[0] != len(metadatas):
raise ValueError(f"벡터 수({vectors.shape[0]})와 메타데이터 수({len(metadatas)}) 불일치")
vectors = vectors.astype("float32")
self.dimension = vectors.shape[1]
self._ensure_collection(self.dimension)
point_ids = [make_point_id(m) for m in metadatas]
existing_by_id: Dict[Any, Any] = {}
try:
records = self.client.retrieve(
collection_name=self.collection_name,
ids=point_ids,
with_payload=True,
with_vectors=False,
)
existing_by_id = {r.id: r for r in records}
except Exception as e:
print(f"[QdrantStore] batch retrieve 실패: {e}")
points = []
legacy_delete_ids: List[str] = []
inserted = updated = skipped = 0
last_id: Optional[str] = None
last_action: Optional[str] = None
last_skip_reason: Optional[str] = None
for vec, meta, point_id in zip(vectors, metadatas, point_ids):
incoming = dict(meta)
q = str(incoming.get("q") or incoming.get("question") or "").strip()
if q:
incoming["q"] = q
incoming.pop("question", None)
record = existing_by_id.get(point_id)
existing_item: Optional[Dict[str, Any]] = None
if record is not None:
existing_item = {"id": record.id, "meta": record.payload or {}}
elif q:
existing_item = self.find_by_exact_q(q)
existing_meta = (existing_item or {}).get("meta") or {}
legacy_id = existing_item.get("id") if existing_item else None
if skip_if_older and existing_meta:
incoming_date = incoming.get("source_created_at")
existing_date = existing_meta.get("source_created_at")
if source_created_at_cmp(incoming_date, existing_date) < 0:
skipped += 1
last_id = str(legacy_id or point_id)
last_action = "skipped"
last_skip_reason = "older_source_created_at"
continue
now = _now_iso()
if existing_meta:
merged = dict(existing_meta)
merged.update(incoming)
merged["indexed_at"] = existing_meta.get("indexed_at") or now
merged["updated_at"] = now
payload = merged
updated += 1
last_action = "updated"
else:
payload = dict(incoming)
payload.setdefault("indexed_at", now)
payload.setdefault("updated_at", now)
inserted += 1
last_action = "inserted"
payload["point_id"] = point_id
if self.hybrid_enabled:
from sparse_encoder import encode_document
sparse = encode_document(payload)
vector = {
"dense": vec.tolist(),
"sparse": self._SparseVector(
indices=sparse["indices"],
values=sparse["values"],
),
}
else:
vector = vec.tolist()
points.append(
self._PointStruct(
id=point_id,
vector=vector,
payload=payload,
)
)
last_id = point_id
if legacy_id is not None and str(legacy_id) != str(point_id):
legacy_delete_ids.append(str(legacy_id))
if points:
self.client.upsert(collection_name=self.collection_name, points=points)
if legacy_delete_ids:
self.delete_by_ids(list(dict.fromkeys(legacy_delete_ids)))
if inserted or updated:
print(
f"[QdrantStore] upsert 완료: +{inserted} 갱신 {updated} 스킵 {skipped} "
f"(총 {self.count()}개)"
)
elif skipped:
print(f"[QdrantStore] {skipped}건 스킵 (기존 source_created_at이 더 최신)")
return {
"inserted": inserted,
"updated": updated,
"skipped": skipped,
"last_id": last_id,
"last_action": last_action,
"last_skip_reason": last_skip_reason,
}
def add_vectors(
self,
vectors: np.ndarray,
metadatas: List[Dict[str, Any]],
skip_if_older: bool = True,
) -> None:
"""벡터 upsert (Qdrant)."""
self.upsert_vectors(vectors, metadatas, skip_if_older=skip_if_older)
def search(
self,
query_vector: np.ndarray,
top_k: int = 30,
threshold: Optional[float] = None,
filter_dict: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""벡터 검색"""
query_vector = query_vector.astype("float32")
if query_vector.ndim == 2:
query_vector = query_vector[0]
# Qdrant 필터 구성
query_filter = None
if filter_dict:
from qdrant_client.models import Filter, FieldCondition, MatchValue
conditions = [
FieldCondition(key=k, match=MatchValue(value=v))
for k, v in filter_dict.items()
]
query_filter = Filter(must=conditions)
query_kwargs = {"using": "dense"} if self.hybrid_enabled else {}
# 검색 (qdrant-client 1.10+ 권장 API: query_points)
response = self.client.query_points(
collection_name=self.collection_name,
query=query_vector.tolist(),
limit=top_k,
query_filter=query_filter,
score_threshold=threshold,
with_payload=True,
**query_kwargs,
)
# 결과 변환 (point id 포함 → 어드민에서 삭제 등에 사용)
return [
{
"id": r.id,
"meta": r.payload,
"score": r.score,
"source": "dense",
"dense_score": r.score,
}
for r in response.points
]
def sparse_search(
self,
query_text: str,
top_k: int = SPARSE_TOP_K,
filter_dict: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""Qdrant sparse vector 검색 (hybrid collection 전용)."""
if not self.hybrid_enabled or not self.sparse_search_enabled:
return []
from sparse_encoder import encode_query
sparse = encode_query(query_text)
if not sparse["indices"]:
return []
query_filter = None
if filter_dict:
from qdrant_client.models import Filter, FieldCondition, MatchValue
conditions = [
FieldCondition(key=k, match=MatchValue(value=v))
for k, v in filter_dict.items()
]
query_filter = Filter(must=conditions)
response = self.client.query_points(
collection_name=self.collection_name,
query=self._SparseVector(
indices=sparse["indices"],
values=sparse["values"],
),
using="sparse",
limit=top_k,
query_filter=query_filter,
with_payload=True,
)
return [
{
"id": r.id,
"meta": r.payload,
"score": r.score,
"source": "sparse",
"sparse_score": r.score,
}
for r in response.points
]
def hybrid_search(
self,
query_vector: np.ndarray,
query_text: str,
top_k: int = 30,
threshold: Optional[float] = None,
filter_dict: Optional[Dict[str, Any]] = None,
sparse_top_k: int = SPARSE_TOP_K,
merge_top_k: int = HYBRID_MERGE_TOP_K,
) -> List[Dict[str, Any]]:
"""Dense + sparse 후보를 병합해 reranker 후보로 반환."""
dense_results = self.search(
query_vector,
top_k=top_k,
threshold=threshold,
filter_dict=filter_dict,
)
sparse_results = self.sparse_search(
query_text,
top_k=sparse_top_k,
filter_dict=filter_dict,
)
merged: Dict[str, Dict[str, Any]] = {}
for result in dense_results + sparse_results:
key = str(result.get("id") or make_point_id(result.get("meta") or {}))
existing = merged.get(key)
if not existing:
item = dict(result)
meta = dict(item.get("meta") or {})
item["meta"] = meta
item["sources"] = {item.get("source")}
merged[key] = item
continue
existing["sources"].add(result.get("source"))
if result.get("dense_score") is not None:
existing["dense_score"] = result.get("dense_score")
if result.get("sparse_score") is not None:
existing["sparse_score"] = result.get("sparse_score")
existing["score"] = max(
float(existing.get("score") or 0.0),
float(result.get("score") or 0.0),
)
items = []
for item in merged.values():
sources = {s for s in item.pop("sources", set()) if s}
source = "both" if len(sources) > 1 else next(iter(sources), "unknown")
item["source"] = source
item["meta"]["_search_source"] = source
item["meta"]["_dense_score"] = item.get("dense_score")
item["meta"]["_sparse_score"] = item.get("sparse_score")
items.append(item)
def sort_key(item: Dict[str, Any]) -> Tuple[int, float]:
source_bonus = {"both": 2, "sparse": 1, "dense": 0}.get(item.get("source"), 0)
return (source_bonus, float(item.get("score") or 0.0))
items.sort(key=sort_key, reverse=True)
return items[:merge_top_k]
def save(self, directory: str) -> None:
"""Qdrant는 자체 영속성 지원 (별도 저장 불필요)"""
print(f"[QdrantStore] Qdrant는 자동으로 영속화됩니다 (스냅샷 경로: {directory} 무시)")
def load(self, directory: str) -> None:
"""Qdrant는 자체 영속성 지원 (별도 로드 불필요)"""
# 컬렉션 존재 확인만 수행
try:
info = self.client.get_collection(self.collection_name)
vectors = info.config.params.vectors
if isinstance(vectors, dict):
dense = vectors.get("dense") or next(iter(vectors.values()))
self.dimension = getattr(dense, "size", None)
else:
self.dimension = getattr(vectors, "size", None)
print(f"[QdrantStore] 컬렉션 연결: {self.collection_name} ({self.count()}개 벡터)")
except Exception as e:
print(f"[QdrantStore] ⚠️ 컬렉션 없음: {e}")
def count(self) -> int:
"""벡터 개수 (정확 카운트)"""
try:
return self.client.count(
collection_name=self.collection_name, exact=True
).count
except Exception:
return 0
# ── 어드민(조회/삭제)용 기능 ──
def get_by_id(self, point_id: str) -> Optional[Dict[str, Any]]:
"""point ID로 단건 조회"""
try:
records = self.client.retrieve(
collection_name=self.collection_name,
ids=[point_id],
with_payload=True,
with_vectors=False,
)
except Exception as e:
print(f"[QdrantStore] get_by_id 실패: {e}")
return None
if not records:
return None
r = records[0]
return {"id": r.id, "meta": r.payload}
def _build_filter(self, filter_dict: Optional[Dict[str, Any]]):
"""payload 정확일치 필터 생성 (None/빈값은 무시). category=__EMPTY__ → 미분류"""
if not filter_dict:
return None
from qdrant_client.models import Filter, FieldCondition, MatchValue
category = filter_dict.get("category")
if isinstance(category, str):
category = category.strip()
if category in ("__EMPTY__", "미분류"):
should = [FieldCondition(key="category", match=MatchValue(value=""))]
try:
from qdrant_client.models import IsEmptyCondition, IsNullCondition, PayloadField
field = PayloadField(key="category")
should.append(IsEmptyCondition(is_empty=field))
should.append(IsNullCondition(is_null=field))
except ImportError:
try:
from qdrant_client.models import IsEmpty
should.append(IsEmpty(key="category"))
except ImportError:
pass
return Filter(should=should)
conditions = [
FieldCondition(key=k, match=MatchValue(value=v))
for k, v in filter_dict.items()
if k != "category" and v is not None and str(v) != ""
]
if category is not None and str(category).strip() != "":
conditions.append(
FieldCondition(key="category", match=MatchValue(value=str(category)))
)
return Filter(must=conditions) if conditions else None
def ensure_admin_payload_indexes(self) -> None:
"""어드민 목록 필터/order_by용 payload index (없으면 생성, 실패는 무시)."""
if not hasattr(self.client, "create_payload_index"):
return
try:
from qdrant_client.models import PayloadSchemaType
for field, schema in (
("category", PayloadSchemaType.KEYWORD),
("source", PayloadSchemaType.KEYWORD),
("updated_at", PayloadSchemaType.DATETIME),
):
try:
self.client.create_payload_index(
collection_name=self.collection_name,
field_name=field,
field_schema=schema,
)
print(f"[QdrantStore] payload index ensured: {field}")
except Exception as e:
msg = str(e).lower()
if "already exists" in msg or "already exist" in msg:
continue
print(f"[QdrantStore] payload index {field} skip: {e}")
except Exception as e:
print(f"[QdrantStore] ensure_admin_payload_indexes 실패(무시): {e}")
def _scroll_collect(
self,
size: int,
scroll_offset,
filter_dict: Optional[Dict[str, Any]],
) -> Tuple[List[Dict[str, Any]], Optional[Any]]:
"""
scroll로 size개 point 수집.
payload가 크면 Qdrant가 limit보다 적게 반환할 수 있어 배치를 반복한다.
"""
flt = self._build_filter(filter_dict)
collected: List[Dict[str, Any]] = []
offset = scroll_offset
while len(collected) < size:
batch = min(size - len(collected), 64)
try:
points, offset = self.client.scroll(
collection_name=self.collection_name,
limit=batch,
offset=offset,
with_payload=True,
with_vectors=False,
scroll_filter=flt,
)
except Exception as e:
print(f"[QdrantStore] scroll collect 실패: {e}")
break
if not points:
break
for p in points:
collected.append({"id": p.id, "meta": p.payload})
if len(collected) >= size:
break
if offset is None:
break
return collected, offset
def _scroll_skip(
self,
skip: int,
filter_dict: Optional[Dict[str, Any]],
scroll_offset=None,
) -> Tuple[int, Optional[Any]]:
"""payload 없이 scroll하며 skip 건수만큼 건너뛴다."""
flt = self._build_filter(filter_dict)
skipped = 0
offset = scroll_offset
while skipped < skip:
batch = min(256, skip - skipped)
try:
points, offset = self.client.scroll(
collection_name=self.collection_name,
limit=batch,
offset=offset,
with_payload=False,
with_vectors=False,
scroll_filter=flt,
)
except Exception as e:
print(f"[QdrantStore] scroll skip 실패: {e}")
return skipped, offset
if not points:
break
skipped += len(points)
if offset is None:
break
return skipped, offset
def list_points(
self,
limit: int = 50,
offset: Optional[str] = None,
filter_dict: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
"""
point 페이지네이션 조회 (scroll). filter_dict로 category/source 등 정확일치 필터 가능.
Returns:
(항목 리스트, 다음 페이지 offset). offset이 None이면 마지막 페이지.
"""
limit = max(1, min(limit, 200))
items, next_offset = self._scroll_collect(limit, offset, filter_dict)
return items, next_offset
def count_filtered(self, filter_dict: Optional[Dict[str, Any]] = None) -> int:
"""필터 조건에 맞는 벡터 정확 건수"""
try:
flt = self._build_filter(filter_dict)
return self.client.count(
collection_name=self.collection_name,
count_filter=flt,
exact=True,
).count
except Exception as e:
print(f"[QdrantStore] count_filtered 실패: {e}")
return 0
def _scroll_batch(
self,
limit: int,
scroll_offset,
filter_dict: Optional[Dict[str, Any]],
with_payload: bool,
order_by=None,
):
flt = self._build_filter(filter_dict)
kwargs = dict(
collection_name=self.collection_name,
limit=limit,
offset=scroll_offset,
with_payload=with_payload,
with_vectors=False,
scroll_filter=flt,
)
if order_by is not None:
kwargs["order_by"] = order_by
return self.client.scroll(**kwargs)
def _scroll_skip_and_collect(
self,
skip: int,
size: int,
filter_dict: Optional[Dict[str, Any]],
order_by,
) -> List[Any]:
"""scroll로 skip 건수만큼 건너뛴 뒤 size 만큼 point 반환."""
scroll_offset = None
skipped = 0
while skipped < skip:
batch = min(256, skip - skipped)
points, scroll_offset = self._scroll_batch(
batch, scroll_offset, filter_dict, False, order_by
)
if not points:
return []
skipped += len(points)
if scroll_offset is None:
return []
points, _ = self._scroll_batch(
size, scroll_offset, filter_dict, True, order_by
)
return points or []
def list_points_page(
self,
page: int,
size: int,
filter_dict: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Dict[str, Any]], int]:
"""
페이지 번호 기반 목록.
list_points와 동일 scroll 경로 사용 + 페이지 내 updated_at 정렬.
"""
page = max(0, page)
size = max(1, min(size, 200))
total = self.count_filtered(filter_dict)
if total == 0 or page * size >= total:
return [], total
skip = page * size
skipped, offset = self._scroll_skip(skip, filter_dict)
if skip > 0 and skipped < skip:
return [], total
items, _ = self._scroll_collect(size, offset, filter_dict)
if items:
items.sort(
key=lambda x: (x.get("meta") or {}).get("updated_at") or "",
reverse=True,
)
return items, total
def delete_by_id(self, point_id: str) -> None:
"""point ID로 단건 삭제"""
self.delete_by_ids([point_id])
def delete_by_ids(self, point_ids: List[str]) -> None:
"""point ID 목록으로 일괄 삭제"""
if not point_ids:
return
from qdrant_client.models import PointIdsList
self.client.delete(
collection_name=self.collection_name,
points_selector=PointIdsList(points=list(point_ids)),
)
print(f"[QdrantStore] {len(point_ids)}건 삭제")
def keyword_search(
self,
keyword: str,
fields: Tuple[str, ...] = ("q", "a"),
filter_dict: Optional[Dict[str, Any]] = None,
limit: int = 50,
skip: int = 0,
batch: int = 256,
max_scan: int = 500000,
count_total: bool = True,
) -> Tuple[List[Dict[str, Any]], int, bool]:
"""
DB 전체 대상 문자열 포함(부분일치, 대소문자 무시) 검색.
Returns:
(matches, total_matches, exhausted)
- matches: skip 이후 limit 개
- total_matches: 전체 매칭 건수. count_total=False이면 조기 중단 시점까지의 매칭 수
- exhausted: 전체 스캔 완료 여부
"""
kw = (keyword or "").lower().strip()
if not kw:
return [], 0, True
skip = max(0, skip)
flt = self._build_filter(filter_dict)
page_items: List[Dict[str, Any]] = []
offset = None
scanned = 0
total_matches = 0
exhausted = True
while True:
try:
points, offset = self.client.scroll(
collection_name=self.collection_name,
limit=batch,
offset=offset,
with_payload=True,
with_vectors=False,
scroll_filter=flt,
)
except Exception as e:
print(f"[QdrantStore] keyword_search 실패: {e}")
break
if not points:
break
for p in points:
scanned += 1
payload = p.payload or {}
parts = []
for field in fields:
if field == "question":
parts.append(str(payload.get("question") or payload.get("q") or ""))
elif field == "answer":
parts.append(str(payload.get("answer") or payload.get("a") or ""))
else:
parts.append(str(payload.get(field) or ""))
if kw in " ".join(parts).lower():
if total_matches >= skip and len(page_items) < limit:
page_items.append({"id": p.id, "meta": payload})
total_matches += 1
if not count_total and len(page_items) >= limit:
exhausted = False
return page_items, total_matches, exhausted
if offset is None:
break
if scanned >= max_scan:
exhausted = False
break
return page_items, total_matches, exhausted
def distinct_payload_values(
self, field: str, batch: int = 512, max_scan: int = 100000
) -> Dict[str, int]:
"""payload 특정 필드의 distinct 값과 개수 (예: category 목록). 전체 스캔."""
counts: Dict[str, int] = {}
offset = None
scanned = 0
while True:
try:
points, offset = self.client.scroll(
collection_name=self.collection_name,
limit=batch,
offset=offset,
with_payload=True,
with_vectors=False,
)
except Exception as e:
print(f"[QdrantStore] distinct_payload_values 실패: {e}")
break
if not points:
break
for p in points:
scanned += 1
value = (p.payload or {}).get(field)
if value is None or str(value).strip() == "":
if field == "category":
counts["미분류"] = counts.get("미분류", 0) + 1
else:
key = str(value)
counts[key] = counts.get(key, 0) + 1
if offset is None or scanned >= max_scan:
break
return counts
# ────────────────────────────────────────────
# 팩토리 함수
# ────────────────────────────────────────────
def get_vector_store(store_type: Optional[str] = None) -> VectorStore:
"""
환경 변수 기반 벡터 스토어 생성
Args:
store_type: "faiss" or "qdrant" (None이면 환경변수 VECTOR_STORE 사용)
Returns:
VectorStore 인스턴스
"""
store_type = (store_type or VECTOR_STORE).lower()
if store_type == "faiss":
return FAISSStore()
elif store_type == "qdrant":
return QdrantStore()
else:
raise ValueError(f"지원하지 않는 벡터 스토어: {store_type} (faiss 또는 qdrant 사용)")
if __name__ == "__main__":
# 간단한 테스트
print("=== 벡터 스토어 테스트 ===")
print(f"선택된 벡터 스토어: {VECTOR_STORE}")
store = get_vector_store()
print(f"현재 벡터 수: {store.count()}")