kcsc-mcp / src /vector_db_client.py
nicefree19's picture
Upload 105 files
0ee3c92 verified
Raw
History Blame Contribute Delete
47.3 kB
"""
๋ฒกํ„ฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ด€๋ฆฌ ๋ชจ๋“ˆ
"""
import os
import json
import logging
import re
import numpy as np
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Dict, List, Any, Optional, Union, Tuple
from datetime import datetime
from tqdm import tqdm
import chromadb
from chromadb.config import Settings
from sentence_transformers import SentenceTransformer
from src import config
from src.utils import (
VectorDBError,
DataProcessingError,
get_latest_data_files,
load_json_data,
determine_optimal_chunk_size,
split_text_into_chunks,
check_index_status,
structured_chunking
)
from src.bm25_client import BM25Client
from src.standard_codes import (
STANDARD_CODE_PATTERN,
canonical_doc_type,
collection_for_doc_type,
collection_names_for_doc_types,
doc_type_matches,
normalize_standard_code,
standard_family_name,
)
from src.ranking_rules import score_ranking_rules
logger = config.setup_logger("vector_db")
STANDARD_CODE_LABEL_PATTERN = re.compile(r"(?:ํ‘œ์ค€์ฝ”๋“œ|๊ธฐ์ค€์ฝ”๋“œ)\W*[:๏ผš]?\s*", re.IGNORECASE)
def _normalize_standard_code(value: Any) -> str:
return normalize_standard_code(value)
def _extract_standard_codes(value: Any) -> List[str]:
codes: List[str] = []
seen = set()
for match in STANDARD_CODE_PATTERN.finditer(str(value or "")):
code = f"{match.group(1).upper()} {match.group(2)} {match.group(3)} {match.group(4)}"
if code not in seen:
codes.append(code)
seen.add(code)
return codes
def _extract_labeled_standard_code(value: Any) -> str:
text = str(value or "")
label_match = STANDARD_CODE_LABEL_PATTERN.search(text)
if not label_match:
return ""
codes = _extract_standard_codes(text[label_match.end(): label_match.end() + 100])
return codes[0] if codes else ""
class KCSCVectorDB:
"""
ChromaDB๋ฅผ ํ™œ์šฉํ•œ ๋ฒกํ„ฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ด€๋ฆฌ
ํ•œ๊ตญ๊ฑด์„ค๊ธฐ์ค€์„ผํ„ฐ(KCSC) ๋ฐ์ดํ„ฐ๋ฅผ ์ž„๋ฒ ๋”ฉํ•˜๊ณ  ๊ฒ€์ƒ‰ํ•˜๋Š” ๊ธฐ๋Šฅ ์ œ๊ณต
"""
_instance = None
_model = None
def __new__(cls, *args, **kwargs):
"""์‹ฑ๊ธ€ํ„ด ํŒจํ„ด ๊ตฌํ˜„"""
if cls._instance is None:
cls._instance = super(KCSCVectorDB, cls).__new__(cls)
return cls._instance
def __init__(self, data_dir=None, db_dir=None, embedding_model=None):
"""
๋ฒกํ„ฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ดˆ๊ธฐํ™”
Args:
data_dir: ๋ฐ์ดํ„ฐ ๋””๋ ‰ํ† ๋ฆฌ ๊ฒฝ๋กœ
db_dir: ๋ฒกํ„ฐ DB ์ €์žฅ ๊ฒฝ๋กœ
embedding_model: ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ ์ด๋ฆ„ ๋˜๋Š” ๊ฒฝ๋กœ
"""
# ์ด๋ฏธ ์ดˆ๊ธฐํ™”๋œ ๊ฒฝ์šฐ ์ค‘๋ณต ์ดˆ๊ธฐํ™” ๋ฐฉ์ง€
if hasattr(self, 'initialized') and self.initialized:
return
self.data_dir = data_dir or config.DATA_DIR
self.db_dir = db_dir or config.VECTOR_DB_DIR
# ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ
os.makedirs(self.data_dir, exist_ok=True)
os.makedirs(self.db_dir, exist_ok=True)
# ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ ์ดˆ๊ธฐํ™”
self._init_embedding_model(embedding_model or config.EMBEDDING_MODEL)
# BM25 ํด๋ผ์ด์–ธํŠธ ์ดˆ๊ธฐํ™”
self.bm25_client = BM25Client()
self.bm25_client.load_index("kcsc_bm25")
# ChromaDB ํด๋ผ์ด์–ธํŠธ ์ดˆ๊ธฐํ™”
logger.info(f"ChromaDB ์ดˆ๊ธฐํ™” ์ค‘... (๊ฒฝ๋กœ: {self.db_dir})")
try:
self.client = chromadb.PersistentClient(
path=self.db_dir,
settings=Settings(
anonymized_telemetry=False,
allow_reset=True
)
)
# ์ปฌ๋ ‰์…˜ ์ดˆ๊ธฐํ™”
self.collections = {}
self._init_collections()
self.initialized = True
logger.info("๋ฒกํ„ฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ดˆ๊ธฐํ™” ์™„๋ฃŒ")
except Exception as e:
logger.error(f"ChromaDB ์ดˆ๊ธฐํ™” ์‹คํŒจ: {str(e)}")
raise VectorDBError(f"๋ฒกํ„ฐ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ดˆ๊ธฐํ™” ์‹คํŒจ: {str(e)}", "init")
@classmethod
def _init_embedding_model(cls, model_name):
"""
์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ ์ดˆ๊ธฐํ™” (ํด๋ž˜์Šค ๋ฉ”์„œ๋“œ๋กœ ์‹ฑ๊ธ€ํ„ด ๊ตฌํ˜„)
Args:
model_name: ์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ ์ด๋ฆ„ ๋˜๋Š” ๊ฒฝ๋กœ
"""
if cls._model is None:
try:
logger.info(f"์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ ๋กœ๋“œ ์ค‘... (๋ชจ๋ธ: {model_name})")
cls._model = SentenceTransformer(model_name)
logger.info("์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ ๋กœ๋“œ ์™„๋ฃŒ")
except Exception as e:
logger.error(f"์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ ๋กœ๋“œ ์‹คํŒจ: {str(e)}")
raise VectorDBError(f"์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ '{model_name}' ๋กœ๋“œ ์‹คํŒจ: {str(e)}", "model_load")
def _init_collections(self):
"""์ปฌ๋ ‰์…˜ ์ดˆ๊ธฐํ™” ๋˜๋Š” ๋กœ๋“œ"""
for doc_type in ["KDS", "KCS"]:
try:
self.collections[doc_type] = self.client.get_or_create_collection(
name=doc_type,
metadata={"description": f"{doc_type} ์„ค๊ณ„๊ธฐ์ค€ ๋ฌธ์„œ"}
)
count = self.collections[doc_type].count()
logger.info(f"{doc_type} ์ปฌ๋ ‰์…˜ ์ดˆ๊ธฐํ™” ์™„๋ฃŒ (ํ•ญ๋ชฉ ์ˆ˜: {count})")
except Exception as e:
logger.error(f"{doc_type} ์ปฌ๋ ‰์…˜ ์ดˆ๊ธฐํ™” ์‹คํŒจ: {str(e)}")
raise VectorDBError(f"{doc_type} ์ปฌ๋ ‰์…˜ ์ดˆ๊ธฐํ™” ์‹คํŒจ: {str(e)}", "collection_init")
def process_document(self, doc: Dict[str, Any], doc_type: str) -> Optional[Dict[str, Any]]:
"""
๋ฌธ์„œ๋ฅผ ์ฒ˜๋ฆฌํ•˜์—ฌ ์ธ๋ฑ์‹ฑ ๊ฐ€๋Šฅํ•œ ํ˜•ํƒœ๋กœ ๋ณ€ํ™˜
Args:
doc: ๋ฌธ์„œ ๋ฐ์ดํ„ฐ
doc_type: ๋ฌธ์„œ ์œ ํ˜•
Returns:
์ฒ˜๋ฆฌ๋œ ๋ฌธ์„œ ๋ฐ์ดํ„ฐ ๋˜๋Š” None (๋‚ด์šฉ์ด ์—†๋Š” ๊ฒฝ์šฐ)
"""
# ๊ธฐ๋ณธ ํ•„๋“œ ์„ค์ •
processed = {
"id": f"{doc_type}-{doc.get('Code', '')}-{doc.get('No', '0')}",
"code": doc.get('Code', ''),
"full_code": doc.get('FullCode', ''),
"name": doc.get('Name', ''),
"title": doc.get('Title', ''),
"content": doc.get('Contents', ''),
"version": doc.get('Version', ''),
"update_date": doc.get('UpdateDate', ''),
"doc_type": doc_type
}
# ๋‚ด์šฉ์ด ์žˆ๋Š” ๊ฒฝ์šฐ์—๋งŒ ๋ฐ˜ํ™˜
if processed["content"]:
return processed
logger.warning(f"๋ฌธ์„œ {processed['id']}์— ๋‚ด์šฉ์ด ์—†์Šต๋‹ˆ๋‹ค.")
return None
def create_document_chunks(
self,
doc: Dict[str, Any],
chunk_size: int = None,
overlap: int = None
) -> List[Dict[str, Any]]:
"""
๋ฌธ์„œ๋ฅผ ์ฒญํฌ๋กœ ๋‚˜๋ˆ„๊ธฐ
Args:
doc: ์ฒ˜๋ฆฌ๋œ ๋ฌธ์„œ ๋ฐ์ดํ„ฐ
chunk_size: ์ฒญํฌ ํฌ๊ธฐ (None์ด๋ฉด ์ž๋™ ๊ฒฐ์ •)
overlap: ์ฒญํฌ ๊ฐ„ ๊ฒน์น˜๋Š” ๋ฌธ์ž ์ˆ˜
Returns:
์ฒญํฌ ๋ชฉ๋ก
"""
content = doc.get("content", "")
if not content:
return [doc]
# ์ฒญํฌ ํฌ๊ธฐ ๊ฒฐ์ •
if chunk_size is None:
chunk_size = determine_optimal_chunk_size(content)
overlap = overlap or config.CHUNK_OVERLAP
# ์ฒญํฌ๋กœ ๋‚˜๋ˆ„๊ธฐ
if len(content) <= chunk_size:
# ์ž‘์€ ๋ฌธ์„œ๋Š” ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ
doc["chunk_id"] = f"{doc['id']}-0"
return [doc]
# ์ฒญํฌ ์ƒ์„ฑ
text_chunks = split_text_into_chunks(content, chunk_size, overlap)
chunks = []
for i, chunk_content in enumerate(text_chunks):
chunk = doc.copy()
chunk["content"] = chunk_content
chunk["chunk_id"] = f"{doc['id']}-{i}//{len(text_chunks)}"
chunks.append(chunk)
return chunks
def index_documents(
self,
doc_type: str,
documents: List[Dict[str, Any]],
chunk_size: int = None,
overlap: int = None,
batch_size: int = 100,
reset: bool = True
) -> Tuple[int, int]:
"""
๋ฌธ์„œ๋ฅผ ์ธ๋ฑ์‹ฑ
Args:
doc_type: ๋ฌธ์„œ ์œ ํ˜•
documents: ๋ฌธ์„œ ๋ชฉ๋ก
chunk_size: ์ฒญํฌ ํฌ๊ธฐ
overlap: ์ฒญํฌ ๊ฐ„ ๊ฒน์น˜๋Š” ๋ฌธ์ž ์ˆ˜
batch_size: ๋ฐฐ์น˜ ํฌ๊ธฐ
reset: ๊ธฐ์กด ๋ฐ์ดํ„ฐ ๋ฆฌ์…‹ ์—ฌ๋ถ€
Returns:
(์ธ๋ฑ์‹ฑ๋œ ๋ฌธ์„œ ์ˆ˜, ์ฒญํฌ ์ˆ˜) ํŠœํ”Œ
"""
if doc_type not in self.collections:
raise VectorDBError(f"{doc_type} ์ปฌ๋ ‰์…˜์ด ์—†์Šต๋‹ˆ๋‹ค.", "collection_missing")
collection = self.collections[doc_type]
# ์ฒญํฌ ์ƒ์„ฑ
chunks = []
processed_docs = 0
for doc in documents:
processed = self.process_document(doc, doc_type)
if not processed:
continue
# ๋ฌธ์„œ๋ฅผ ์ฒญํฌ๋กœ ๋‚˜๋ˆ„๊ธฐ
doc_chunks = self.create_document_chunks(processed, chunk_size, overlap)
chunks.extend(doc_chunks)
processed_docs += 1
logger.info(f"{doc_type}: {processed_docs}๊ฐœ ๋ฌธ์„œ์—์„œ {len(chunks)}๊ฐœ ์ฒญํฌ ์ƒ์„ฑ๋จ")
# ์ปฌ๋ ‰์…˜ ๋ฆฌ์…‹ (ํ•„์š”์‹œ)
if reset and collection.count() > 0:
logger.info(f"{doc_type} ์ปฌ๋ ‰์…˜ ์ดˆ๊ธฐํ™” ์ค‘... (๊ธฐ์กด {collection.count()}๊ฐœ ํ•ญ๋ชฉ)")
collection.delete(where={})
# ์ฒญํฌ ๋ฐ์ดํ„ฐ ์ค€๋น„
if not chunks:
logger.warning(f"{doc_type}: ์ธ๋ฑ์‹ฑํ•  ์ฒญํฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")
return 0, 0
# ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ
total_batches = (len(chunks) + batch_size - 1) // batch_size
for batch_idx in range(total_batches):
start_idx = batch_idx * batch_size
end_idx = min((batch_idx + 1) * batch_size, len(chunks))
batch_chunks = chunks[start_idx:end_idx]
try:
ids = []
documents = []
metadatas = []
embeddings = []
for chunk in batch_chunks:
# ์ž„๋ฒ ๋”ฉํ•  ํ…์ŠคํŠธ ์ค€๋น„
text = f"{chunk['name']} {chunk['title'] or ''}: {chunk['content']}"
# ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์ค€๋น„
metadata = {
"id": chunk["id"],
"code": chunk["code"],
"full_code": chunk["full_code"],
"name": chunk["name"],
"title": chunk["title"],
"doc_type": chunk["doc_type"],
"version": chunk["version"]
}
# ์ž„๋ฒ ๋”ฉ ์ƒ์„ฑ
if self._model is None:
raise VectorDBError("์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ์ด ์ดˆ๊ธฐํ™”๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.", "model_missing")
embedding = self._model.encode(text)
ids.append(chunk["chunk_id"])
documents.append(text)
metadatas.append(metadata)
embeddings.append(embedding.tolist())
# ๋ฐ์ดํ„ฐ ์ถ”๊ฐ€
collection.add(
ids=ids,
documents=documents,
metadatas=metadatas,
embeddings=embeddings
)
logger.info(f"{doc_type} ๋ฐฐ์น˜ {batch_idx+1}/{total_batches} ์ธ๋ฑ์‹ฑ ์™„๋ฃŒ ({len(ids)}๊ฐœ ํ•ญ๋ชฉ)")
except Exception as e:
logger.error(f"{doc_type} ๋ฐฐ์น˜ {batch_idx+1} ์ธ๋ฑ์‹ฑ ์‹คํŒจ: {str(e)}")
raise VectorDBError(f"{doc_type} ๋ฐฐ์น˜ ์ธ๋ฑ์‹ฑ ์‹คํŒจ: {str(e)}", "batch_indexing")
return processed_docs, len(chunks)
def build_hybrid_index_from_standards(self, file_path: str = None, chunk_size: int = 4000, limit: int = None) -> Dict[str, Any]:
"""
standards.json์„ ๋กœ๋“œํ•˜์—ฌ ํ•„๋“œ ๊ธฐ๋ฐ˜ ์ฒญํ‚น ํ›„ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ ์ธ๋ฑ์Šค ๊ตฌ์ถ•.
์ „๋žต: scope / materials / construction / quality / safety 5๊ฐœ ํ•ต์‹ฌ ํ•„๋“œ์˜
๋ฐœ์ทŒ๋ฌธ์„ ํ•ฉ์ณ ๋ฌธ์„œ๋‹น 1๊ฐœ ๋Œ€ํ‘œ ์ฒญํฌ๋ฅผ ๋งŒ๋“ ๋‹ค.
"""
file_path = file_path or os.path.join(config.DATA_DIR, "raw", "standards.json")
if not os.path.exists(file_path):
logger.warning(f"ํ‘œ์ค€ ๋ฐ์ดํ„ฐ ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: {file_path}")
return {"status": "error", "message": "ํ‘œ์ค€ ๋ฐ์ดํ„ฐ ํŒŒ์ผ ์—†์Œ"}
logger.info(f"ํ‘œ์ค€ ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์ค‘... ({file_path})")
try:
with open(file_path, 'r', encoding='utf-8') as f:
standards_data = json.load(f)
except Exception as e:
return {"status": "error", "message": f"๋กœ๋“œ ์‹คํŒจ: {e}"}
items = list(standards_data.values()) if isinstance(standards_data, dict) else standards_data
if limit:
items = items[:limit]
# ๋ฌธ์„œ๋‹น 1๊ฐœ ๋Œ€ํ‘œ ์ฒญํฌ ์ „๋žต:
# standard_code + title + ๊ฐ ํ•ต์‹ฌ ํ•„๋“œ ์•ž N์ž๋ฅผ ํ•ฉ์‚ฐ โ†’ ํ•˜๋‚˜์˜ ๊ฒ€์ƒ‰ ๊ฐ€๋Šฅํ•œ ๋‹จ์œ„ ์ƒ์„ฑ
# ๋ชฉํ‘œ: 5,233 ๋ฌธ์„œ = 5,233 ์ฒญํฌ โ†’ ์ž„๋ฒ ๋”ฉ ๋ฐฐ์น˜ 11๊ฐœ โ†’ ~2๋ถ„ ์™„๋ฃŒ
FIELD_EXCERPT = [
("scope", "์ ์šฉ๋ฒ”์œ„", 600),
("materials", "์ž์žฌ", 600),
("construction", "์‹œ๊ณต", 600),
("quality", "ํ’ˆ์งˆ๊ด€๋ฆฌ", 400),
("safety", "์•ˆ์ „", 400),
]
SKIP_VALUES = {"๋‚ด์šฉ ์—†์Œ", "", None}
all_chunks: List[Dict[str, Any]] = []
processed_docs = 0
fallback_code_docs = []
skipped_docs = []
failed_docs = []
def resolve_standard_code(doc: Dict[str, Any], doc_idx: int) -> Tuple[str, str]:
raw_code = doc.get("standard_code") or doc.get("code") or ""
code = _normalize_standard_code(raw_code)
if code:
return code, "standard_code"
title_codes = _extract_standard_codes(doc.get("title", ""))
if len(title_codes) == 1:
return title_codes[0], "title"
if len(title_codes) > 1:
failed_docs.append({
"doc_idx": doc_idx,
"title": str(doc.get("title", ""))[:160],
"reason": "multiple_codes_in_title",
"candidates": title_codes,
})
return "", ""
for field_name in ("full_content", "summary", "scope"):
labeled_code = _extract_labeled_standard_code(doc.get(field_name, ""))
if labeled_code:
return labeled_code, f"{field_name}_label"
for field_name in ("summary", "scope", "full_content"):
field_codes = _extract_standard_codes(doc.get(field_name, ""))
if len(field_codes) == 1:
return field_codes[0], field_name
if len(field_codes) > 1:
failed_docs.append({
"doc_idx": doc_idx,
"title": str(doc.get("title", ""))[:160],
"reason": f"multiple_codes_in_{field_name}",
"candidates": field_codes[:10],
})
return "", ""
failed_docs.append({
"doc_idx": doc_idx,
"title": str(doc.get("title", ""))[:160],
"reason": "code_not_found",
"candidates": [],
})
return "", ""
for doc_idx, doc in enumerate(tqdm(items, desc="๋ฌธ์„œ ๋Œ€ํ‘œ ์ฒญํฌ ์ƒ์„ฑ ์ค‘")):
code, code_source = resolve_standard_code(doc, doc_idx)
title = doc.get("title", "")
if not code:
continue
if code_source != "standard_code":
fallback_code_docs.append({
"doc_idx": doc_idx,
"code": code,
"source": code_source,
"title": str(title)[:160],
})
doc_type = canonical_doc_type(code)
collection_type = collection_for_doc_type(doc_type)
standard_family = standard_family_name(doc_type)
# ์งง์€ ๋‹จ์–ดํ˜• ์ œ๋ชฉ์€ ์ œ๋ชฉ๋งŒ์œผ๋กœ ์˜๋ฏธ๊ฐ€ ๋ชจํ˜ธํ•˜๋ฏ€๋กœ ๊ธฐ์ค€์ฝ”๋“œ๋ฅผ ์ฒญํฌ ์•ž์— ๋ช…์‹œํ•œ๋‹ค.
parts = [
f"[๊ธฐ์ค€์ฝ”๋“œ] {code}",
f"[๊ธฐ์ค€์ข…๋ฅ˜] {standard_family}",
f"[์ œ๋ชฉ] {code} {title}".strip(),
]
for field_key, field_label, max_len in FIELD_EXCERPT:
val = str(doc.get(field_key, "") or "").strip()
if val and val not in SKIP_VALUES:
effective_max_len = 1000 if field_key == "scope" and "(์ „๋ฌธ)" in title else max_len
excerpt = val[:effective_max_len]
parts.append(f"[{field_label}] {excerpt}")
page_content = "\n".join(parts)
if len(page_content) < 50: # ๋‚ด์šฉ์ด ๊ฑฐ์˜ ์—†๋Š” ๋ฌธ์„œ ๊ฑด๋„ˆ๋œ€
skipped_docs.append({
"doc_idx": doc_idx,
"code": code,
"title": str(title)[:160],
"reason": "representative_chunk_too_short",
})
continue
chunk_id = f"{code}_main_{doc_idx}"
all_chunks.append({
"id": chunk_id,
"page_content": page_content,
"metadata": {
"code": code,
"full_code": code,
"name": title,
"title": title,
"doc_type": doc_type,
"collection_type": collection_type,
"standard_family": standard_family,
"standard_code_source": code_source,
"field": "combined",
"chunk_index": 0,
}
})
processed_docs += 1
logger.info(f"์ด {processed_docs}๊ฐœ ๋ฌธ์„œ์—์„œ {len(all_chunks)}๊ฐœ ์ฒญํฌ ์ƒ์„ฑ๋จ.")
if fallback_code_docs:
logger.info(f"ํ‘œ์ค€์ฝ”๋“œ fallback ์ ์šฉ: {len(fallback_code_docs)}๊ฐœ ๋ฌธ์„œ")
if failed_docs:
logger.error(f"ํ‘œ์ค€์ฝ”๋“œ ์ถ”์ถœ ์‹คํŒจ: {len(failed_docs)}๊ฐœ ๋ฌธ์„œ")
return {
"status": "error",
"message": "ํ‘œ์ค€์ฝ”๋“œ ์ถ”์ถœ ์‹คํŒจ ๋ฌธ์„œ๊ฐ€ ์žˆ์–ด ์ธ๋ฑ์Šค ๋นŒ๋“œ๋ฅผ ์ค‘๋‹จํ–ˆ์Šต๋‹ˆ๋‹ค.",
"processed_docs": processed_docs,
"total_input_docs": len(items),
"fallback_code_docs": len(fallback_code_docs),
"skipped_docs": len(skipped_docs),
"failed_docs": len(failed_docs),
"failed_doc_samples": failed_docs[:20],
}
# โ”€โ”€ BM25 ์ธ๋ฑ์Šค ๋นŒ๋“œ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
self.bm25_client.build_index(all_chunks)
self.bm25_client.save_index("kcsc_bm25")
# โ”€โ”€ ChromaDB ์ธ๋ฑ์Šค ๋นŒ๋“œ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
for doc_type in ["KDS", "KCS"]:
try:
self.client.delete_collection(name=doc_type)
except Exception:
pass
collection = self.client.create_collection(
name=doc_type,
metadata={"description": f"{doc_type} ์„ค๊ณ„๊ธฐ์ค€ ๋ฌธ์„œ"}
)
self.collections[doc_type] = collection
type_chunks = [c for c in all_chunks if c["metadata"].get("collection_type") == doc_type]
if not type_chunks:
logger.info(f"{doc_type}: ์ธ๋ฑ์‹ฑํ•  ์ฒญํฌ ์—†์Œ (๊ฑด๋„ˆ๋œ€)")
continue
batch_size = 512 # ์ž„๋ฒ ๋”ฉ ๋ฐฐ์น˜ ํฌ๊ธฐ (์†๋„ ์ตœ์ ํ™”)
total_batches = (len(type_chunks) + batch_size - 1) // batch_size
logger.info(f"{doc_type} ChromaDB ์ธ๋ฑ์‹ฑ ์‹œ์ž‘: {len(type_chunks)}๊ฐœ ์ฒญํฌ, {total_batches}๊ฐœ ๋ฐฐ์น˜")
for batch_idx in tqdm(range(total_batches), desc=f"{doc_type} ์ž„๋ฒ ๋”ฉ"):
s = batch_idx * batch_size
e = min(s + batch_size, len(type_chunks))
batch = type_chunks[s:e]
ids = [c["id"] for c in batch]
documents = [c["page_content"] for c in batch]
metadatas = [c["metadata"] for c in batch]
embeddings = self._model.encode(documents, show_progress_bar=False).tolist()
collection.add(ids=ids, documents=documents, metadatas=metadatas, embeddings=embeddings)
logger.info(f"ChromaDB {doc_type} ์ธ๋ฑ์‹ฑ ์™„๋ฃŒ ({len(type_chunks)}๊ฐœ ์ฒญํฌ)")
return {
"status": "success",
"processed_docs": processed_docs,
"total_input_docs": len(items),
"fallback_code_docs": len(fallback_code_docs),
"skipped_docs": len(skipped_docs),
"failed_docs": len(failed_docs),
"total_chunks": len(all_chunks),
"kds_chunks": sum(1 for c in all_chunks if c["metadata"].get("doc_type") == "KDS"),
"kcs_chunks": sum(1 for c in all_chunks if c["metadata"].get("doc_type") == "KCS"),
"excs_chunks": sum(1 for c in all_chunks if c["metadata"].get("doc_type") == "EXCS"),
"kcs_collection_chunks": sum(1 for c in all_chunks if c["metadata"].get("collection_type") == "KCS"),
}
def build_index_from_files(
self,
chunk_size: int = None,
overlap: int = None,
reset: bool = True
) -> Dict[str, Dict[str, int]]:
"""
๋ชจ๋“  ๋ฌธ์„œ ํŒŒ์ผ์„ ์ธ๋ฑ์‹ฑ
Args:
chunk_size: ์ฒญํฌ ํฌ๊ธฐ
overlap: ์ฒญํฌ ๊ฐ„ ๊ฒน์น˜๋Š” ๋ฌธ์ž ์ˆ˜
reset: ๊ธฐ์กด ๋ฐ์ดํ„ฐ ๋ฆฌ์…‹ ์—ฌ๋ถ€
Returns:
์ธ๋ฑ์‹ฑ ํ†ต๊ณ„ ์ •๋ณด
"""
stats = {}
# ์„ธ๋ถ€ ์ •๋ณด ํŒŒ์ผ ๊ฐ€์ ธ์˜ค๊ธฐ
detail_files = get_latest_data_files("KDS_details", self.data_dir) + \
get_latest_data_files("KCS_details", self.data_dir)
if not detail_files:
logger.warning("์ธ๋ฑ์‹ฑํ•  ๋ฐ์ดํ„ฐ ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.")
return {"status": "warning", "message": "์ธ๋ฑ์‹ฑํ•  ๋ฐ์ดํ„ฐ ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค."}
for file_path in detail_files:
try:
# ํŒŒ์ผ๋ช…์—์„œ ๋ฌธ์„œ ์œ ํ˜• ์ถ”์ถœ
file_name = os.path.basename(file_path)
doc_type = file_name.split('_')[0]
logger.info(f"{file_path} ํŒŒ์ผ ์ธ๋ฑ์‹ฑ ์‹œ์ž‘")
# ๋ฐ์ดํ„ฐ ๋กœ๋“œ
documents = load_json_data(file_path)
if documents:
# ์ธ๋ฑ์‹ฑ
doc_count, chunk_count = self.index_documents(
doc_type,
documents,
chunk_size=chunk_size,
overlap=overlap,
reset=reset
)
stats[doc_type] = {
"documents": doc_count,
"chunks": chunk_count,
"file": file_path
}
logger.info(f"{doc_type} ์ธ๋ฑ์‹ฑ ์™„๋ฃŒ: {doc_count}๊ฐœ ๋ฌธ์„œ, {chunk_count}๊ฐœ ์ฒญํฌ")
else:
logger.warning(f"{file_path} ํŒŒ์ผ์— ์ธ๋ฑ์‹ฑํ•  ๋ฌธ์„œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")
stats[doc_type] = {"documents": 0, "chunks": 0, "file": file_path}
except Exception as e:
logger.error(f"{file_path} ํŒŒ์ผ ์ธ๋ฑ์‹ฑ ์‹คํŒจ: {str(e)}")
stats[os.path.basename(file_path)] = {"error": str(e)}
return {
"status": "success" if stats else "error",
"stats": stats
}
def search(
self,
query: str,
doc_types: List[str] = None,
limit: int = None,
min_relevance: float = 0.3
) -> List[Dict[str, Any]]:
"""
์ž์—ฐ์–ด ์ฟผ๋ฆฌ๋กœ ๊ด€๋ จ ๋ฌธ์„œ ๊ฒ€์ƒ‰
Args:
query: ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ
doc_types: ๊ฒ€์ƒ‰ํ•  ๋ฌธ์„œ ์œ ํ˜• ๋ชฉ๋ก
limit: ๊ฒฐ๊ณผ ์ œํ•œ ์ˆ˜
min_relevance: ์ตœ์†Œ ๊ด€๋ จ์„ฑ ์ ์ˆ˜
Returns:
๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ๋ชฉ๋ก
"""
if not query:
logger.warning("๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ๊ฐ€ ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.")
return []
limit = limit or config.SEARCH_LIMIT
allowed_doc_types = {str(dt).upper() for dt in doc_types} if doc_types else None
collection_doc_types = (
sorted(collection_names_for_doc_types(doc_types))
if doc_types
else list(self.collections.keys())
)
logger.debug(f"๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ: '{query}', ๋Œ€์ƒ ๋ฌธ์„œ ์œ ํ˜•: {doc_types or collection_doc_types}")
try:
standard_code_pattern = STANDARD_CODE_PATTERN
def clean_title_text(value: Any) -> str:
title = standard_code_pattern.sub(" ", str(value or ""))
title = re.sub(r"[_\-]+", " ", title)
title = re.sub(r"\([^)]*(?:\d{2,4}(?:[.\-]\d{1,2})?|์ „๋ฌธ|์ˆ˜์ •|์ œ์ •)[^)]*\)", " ", title)
title = re.sub(r"(?:์ˆ˜์ •|์ œ์ •|์ „๋ฌธ)", " ", title)
return re.sub(r"\s+", " ", title).strip()
def normalize_title_match_text(value: Any) -> str:
return "".join(ch for ch in str(value or "").lower() if ch.isalnum())
normalized_query = normalize_title_match_text(query)
clean_normalized_query = normalize_title_match_text(clean_title_text(query))
query_tokens = set(self.bm25_client._tokenize(query)) if self.bm25_client.documents else set()
wants_excs = (
"๊ณ ์†๋„๋กœ๊ณต์‚ฌ์ „๋ฌธ์‹œ๋ฐฉ์„œ" in normalized_query
or "์ „๋ฌธ์‹œ๋ฐฉ์„œ" in normalized_query
or "๊ณ ์†๋„๋กœ๊ณต์‚ฌ" in normalized_query
)
wants_kds = "์„ค๊ณ„๊ธฐ์ค€" in normalized_query
wants_kcs = "ํ‘œ์ค€์‹œ๋ฐฉ์„œ" in normalized_query
generic_signal_tokens = {
"๊ธฐ์ค€", "์„ค๊ณ„", "์„ค๊ณ„๊ธฐ์ค€", "๊ณต์‚ฌ", "๊ตฌ์กฐ", "์ผ๋ฐ˜", "์ˆ˜์ •", "์ œ์ •",
"๊ฒ€ํ† ", "๊ฒ€ํ† ํ• ", "๊ธฐ์ค€์œผ๋กœ", "์ฐพ์•„์ค˜", "์•Œ๋ ค์ฃผ์„ธ์š”", "์ฝ˜ํฌ", "ํฌ๋ฆฌ",
"๋ฆฌํŠธ", "์ฝ˜ํฌ๋ฆฌ", "ํฌ๋ฆฌํŠธ", "ํŠธ๊ตฌ", "๋ฆฌํŠธ๊ตฌ", "ํŠธ๊ตฌ์กฐ", "ํฌ๋ฆฌํŠธ๊ตฌ",
"๋ฆฌํŠธ๊ตฌ์กฐ", "๊ณ„๊ธฐ", "๊ณ„๊ธฐ์ค€", "๊ธธ์ด", "์กฐ๊ฑด", "ํ™•์ธ", "๊ฒฐ๊ณผ",
"์–ด๋–ค", "ํ•˜๋Š”", "ํ•˜๋Š”๋ฐ", "ํ•ด์•ผ", "ํ•˜๋‚˜์š”", "๊ฒ€ํ† ํ•ด", "๊ฒ€ํ† ํ•ด์•ผ",
"ํ† ํ•ด", "ํ† ํ•ด์•ผ", "์ ํ•ฉ", "์ ํ•ฉํ•œ", "์ถฉ๋ถ„", "์ถฉ๋ถ„ํ•œ",
}
important_short_terms = {
"๋‚ด์ง„", "์ง€์ง„", "์ •์ฐฉ", "์ด์Œ", "์ฒ ๊ทผ", "์ˆ˜์••", "์‹œํ—˜", "๊ฒ€์‚ฌ",
"์•ˆ์ „", "๋ณด๊ฑด", "๊ด€๋ฆฌ", "๊ณ„ํš", "ํ•ฉ์„ฑ", "๊ฐ•๋„", "ํ”ผ๋ณต", "์ „๋‹จ",
"์••์ถ•", "๋ณผํŠธ", "์ ‘ํ•ฉ", "์šฉ์ ‘", "๋ฐฉ์ˆ˜", "์ง€๋ฐ˜", "๊ธฐ์ดˆ", "ํ’ˆ์งˆ",
"์•ต์ปค", "๊ฐ€์„ค", "์Šฌ๋ž˜๋ธŒ", "๋ง๋š", "๊ต๋Ÿ‰", "ํ„ฐ๋„", "๋ฐฐ๊ด€",
"๋„ค์ผ", "์˜น๋ฒฝ", "ํ•œ์ค‘", "์กฐ์ž„", "์ฒด๊ฒฐ",
}
query_signal_tokens = {
token for token in query_tokens
if token not in generic_signal_tokens and (len(token) >= 3 or token in important_short_terms)
}
token_idf = getattr(self.bm25_client.bm25, "idf", {}) if self.bm25_client.bm25 else {}
def token_importance(token: str) -> float:
try:
return max(float(token_idf.get(token, 0.0)), 0.1)
except Exception:
return 0.1
vector_candidate_count = max(limit, 5)
lexical_candidate_count = max(limit * 30, 150)
# ์ฟผ๋ฆฌ ์ž„๋ฒ ๋”ฉ. ๋ชจ๋ธ/Chroma ์ชฝ์ด ๋ฉ”๋ชจ๋ฆฌ ๋ถ€์กฑ์œผ๋กœ ์‹คํŒจํ•ด๋„
# ์•„๋ž˜ BM25 ๊ฒฝ๋กœ๋Š” ๊ณ„์† ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค.
query_embedding = None
if self._model is None:
logger.warning("์ž„๋ฒ ๋”ฉ ๋ชจ๋ธ์ด ์ดˆ๊ธฐํ™”๋˜์ง€ ์•Š์•„ ๋ฒกํ„ฐ ๊ฒ€์ƒ‰์„ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค.")
else:
try:
query_embedding = self._model.encode(query)
except Exception as e:
logger.warning(f"์ฟผ๋ฆฌ ์ž„๋ฒ ๋”ฉ ์‹คํŒจ, BM25 ๊ฒ€์ƒ‰๋งŒ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค: {e}")
all_results = []
# ๊ฐ ์ปฌ๋ ‰์…˜์—์„œ ๊ฒ€์ƒ‰
if query_embedding is not None:
for doc_type in collection_doc_types:
if doc_type not in self.collections:
logger.warning(f"{doc_type} ์ปฌ๋ ‰์…˜์ด ์—†์Šต๋‹ˆ๋‹ค.")
continue
collection = self.collections[doc_type]
try:
collection_count = collection.count()
except Exception as e:
logger.warning(f"{doc_type} ์ปฌ๋ ‰์…˜ count ์‹คํŒจ, ๋ฒกํ„ฐ ๊ฒ€์ƒ‰ ๊ฑด๋„ˆ๋œ€: {e}")
continue
# ์ปฌ๋ ‰์…˜์— ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ๊ฑด๋„ˆ๋›ฐ๊ธฐ
if collection_count == 0:
logger.warning(f"{doc_type} ์ปฌ๋ ‰์…˜์— ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.")
continue
try:
# ๊ฒ€์ƒ‰ ์‹คํ–‰
results = collection.query(
query_embeddings=[query_embedding.tolist()],
n_results=min(vector_candidate_count, collection_count) # ํ•„ํ„ฐ๋ง ์ „์— ๋” ๋งŽ์ด ๊ฐ€์ ธ์˜ด
)
except Exception as e:
logger.warning(f"{doc_type} Chroma ๋ฒกํ„ฐ ๊ฒ€์ƒ‰ ์‹คํŒจ, BM25 ๊ฒ€์ƒ‰์œผ๋กœ ๊ณ„์† ์ง„ํ–‰: {e}")
continue
# ๊ฒฐ๊ณผ ํ˜•์‹ ๋ณ€ํ™˜
for i in range(len(results['ids'][0])):
distance = float(results['distances'][0][i])
relevance = float(1.0 / (1.0 + max(distance, 0.0))) # ๊ฑฐ๋ฆฌ๊ฐ’์„ 0~1 ์œ ์‚ฌ๋„๋กœ ์ •๊ทœํ™”
result = {
"id": results['ids'][0][i],
"text": results['documents'][0][i],
"metadata": results['metadatas'][0][i],
"distance": distance,
"relevance": relevance
}
all_results.append(result)
# BM25 ๊ฒ€์ƒ‰ (Lexical)
bm25_results = self.bm25_client.search(query, top_k=lexical_candidate_count) if self.bm25_client.bm25 else []
title_results = []
if self.bm25_client.documents and normalized_query:
for doc in self.bm25_client.documents:
metadata = doc.get("metadata", {})
title = metadata.get("title") or metadata.get("name")
clean_title = clean_title_text(title)
normalized_title = normalize_title_match_text(clean_title)
if not normalized_title:
continue
code = metadata.get("code", "")
raw_type = canonical_doc_type(code, metadata.get("doc_type", ""))
title_score = 0.0
if normalized_title in clean_normalized_query:
title_score += 80.0 + min(len(normalized_title), 30)
else:
title_tokens = set(self.bm25_client._tokenize(clean_title))
overlap = title_tokens & query_tokens
if overlap:
title_score += float(len(overlap))
title_score += sum(
min(token_importance(token), 5.0)
for token in overlap & query_signal_tokens
)
if len(overlap) / max(len(title_tokens), 1) >= 0.5:
title_score += 3.0
if title_score > 0:
if wants_excs and raw_type == "EXCS":
title_score += 10.0
elif wants_kds and raw_type == "KDS":
title_score += 8.0
elif wants_kcs and raw_type == "KCS":
title_score += 8.0
title_results.append((doc, title_score))
title_results.sort(key=lambda item: item[1], reverse=True)
title_results = title_results[:lexical_candidate_count]
substring_results = []
if self.bm25_client.documents:
query_terms = [normalize_title_match_text(term) for term in query.split()]
query_terms = [term for term in query_terms if len(term) >= 2]
for doc in self.bm25_client.documents:
metadata = doc.get("metadata", {})
title_text = normalize_title_match_text(clean_title_text(metadata.get("title") or metadata.get("name")))
content_text = normalize_title_match_text(doc.get("page_content", ""))
score = 0
for term in query_terms:
if term in title_text:
score += 3
elif term in content_text:
score += 1
if score > 0:
substring_results.append((doc, score))
substring_results.sort(key=lambda item: item[1], reverse=True)
substring_results = substring_results[:lexical_candidate_count]
# RRF (Reciprocal Rank Fusion) ๊ฒฐํ•ฉ
rrf_k = 60
fused_scores = {}
fused_results = {}
# ChromaDB ์ ์ˆ˜ ๋ฐ˜์˜
for rank, res in enumerate(sorted(all_results, key=lambda x: x['distance'])):
doc_id = res["id"]
if doc_id not in fused_scores:
fused_scores[doc_id] = 0
fused_results[doc_id] = res
fused_scores[doc_id] += 1.0 / (rrf_k + rank + 1)
# BM25 ์ ์ˆ˜ ๋ฐ˜์˜
for rank, (bm25_doc, score) in enumerate(bm25_results):
# BM25 ์ธ๋ฑ์Šค๋Š” ๊ฐ™์€ ์ฒญํฌ id๋ฅผ ๋ณด์œ ํ•˜๋ฏ€๋กœ ๋ฌธ์ž์—ด ๋น„๊ต ์—†์ด id๋กœ ๊ฒฐํ•ฉํ•œ๋‹ค.
matched_id = bm25_doc.get("id") or f"bm25_only_{rank}"
if matched_id not in fused_results:
fused_results[matched_id] = {
"id": matched_id,
"text": bm25_doc["page_content"],
"metadata": bm25_doc["metadata"],
"distance": 0,
"relevance": min(score / 10.0, 1.0) # ์ž„์˜ ์ •๊ทœํ™”
}
fused_scores[matched_id] = 0
fused_scores[matched_id] += 1.0 / (rrf_k + rank + 1)
# ์ž์—ฐ์–ด ์งˆ์˜์— ํฌํ•จ๋œ ์ œ๋ชฉ๊ตฌ ํ›„๋ณด๋Š” BM25 ์ƒ์œ„๊ถŒ์— ์—†์–ด๋„ id ๊ธฐ์ค€์œผ๋กœ ๊ฒฐํ•ฉํ•œ๋‹ค.
for rank, (title_doc, score) in enumerate(title_results):
matched_id = title_doc.get("id") or f"title_only_{rank}"
if matched_id not in fused_results:
fused_results[matched_id] = {
"id": matched_id,
"text": title_doc["page_content"],
"metadata": title_doc["metadata"],
"distance": 0,
"relevance": min(score / 50.0, 1.0)
}
fused_scores[matched_id] = 0
fused_scores[matched_id] += 4.0 / (rrf_k + rank + 1)
# BM25๊ฐ€ ๊ณต๋ฐฑ ๊ธฐ๋ฐ˜ ํ† ํฐํ™”๋กœ ๋ณตํ•ฉ์–ด๋ฅผ ๋†“์น  ๋•Œ๋งŒ ๋ถ€๋ถ„๋ฌธ์ž์—ด ํ›„๋ณด๋ฅผ ๋ณด๊ฐ•ํ•œ๋‹ค.
for rank, (substring_doc, score) in enumerate(substring_results):
doc_text = substring_doc["page_content"]
matched_id = substring_doc.get("id")
if matched_id not in fused_results:
fused_results[matched_id] = {
"id": matched_id,
"text": doc_text,
"metadata": substring_doc["metadata"],
"distance": 0,
"relevance": min(score / 10.0, 1.0)
}
fused_scores[matched_id] = 0
fused_scores[matched_id] += 2.0 / (rrf_k + rank + 1)
# ์ œ๋ชฉ ๊ทธ๋Œ€๋กœ ๊ฒ€์ƒ‰ํ•˜๋Š” ์งˆ์˜๋Š” ๊ฐ™์€ ์ œ๋ชฉ์˜ ๊ธฐ์ค€์„ ์šฐ์„ ํ•œ๋‹ค.
if normalized_query:
for doc_id, res in fused_results.items():
metadata = res.get("metadata", {})
clean_title = clean_title_text(metadata.get("title") or metadata.get("name"))
normalized_title = normalize_title_match_text(clean_title)
if not normalized_title:
continue
raw_type = canonical_doc_type(metadata.get("code", ""), metadata.get("doc_type", ""))
if clean_normalized_query == normalized_title:
fused_scores[doc_id] += 1.0
elif clean_normalized_query in normalized_title or normalized_title in clean_normalized_query:
fused_scores[doc_id] += 0.75
title_tokens = set(self.bm25_client._tokenize(clean_title))
query_tokens = set(self.bm25_client._tokenize(query))
meaningful_overlap = {
token for token in title_tokens & query_tokens
if token in query_signal_tokens
}
if meaningful_overlap:
fused_scores[doc_id] += min(
0.08 * sum(token_importance(token) for token in meaningful_overlap),
0.65,
)
content_text = res.get("text", "")
content_tokens = set(self.bm25_client._tokenize(content_text[:2500]))
content_overlap = {
token for token in query_tokens & content_tokens
if token in query_signal_tokens
}
if content_overlap:
fused_scores[doc_id] += min(
0.05 * sum(token_importance(token) for token in content_overlap),
0.75,
)
rare_title_hits = {
token for token in meaningful_overlap
if token_importance(token) >= 2.0
}
rare_content_hits = {
token for token in content_overlap
if token_importance(token) >= 2.0
}
if rare_title_hits:
fused_scores[doc_id] += min(
0.18 * sum(token_importance(token) for token in rare_title_hits),
0.85,
)
if rare_content_hits:
fused_scores[doc_id] += min(
0.12 * sum(token_importance(token) for token in rare_content_hits),
0.75,
)
code = metadata.get("code", "")
fused_scores[doc_id] += score_ranking_rules(normalized_query, code, "vector")
if wants_excs:
if raw_type == "EXCS":
fused_scores[doc_id] += 0.75
elif raw_type in {"KCS", "KDS"}:
fused_scores[doc_id] -= 0.35
# ์ตœ์ข… RRF ์Šค์ฝ”์–ด๋กœ ์ •๋ ฌ
final_results = sorted(list(fused_results.values()), key=lambda x: fused_scores[x["id"]], reverse=True)
deduped_results = []
seen_codes = set()
for res in final_results:
res = dict(res)
raw_fusion_score = float(fused_scores.get(res["id"], 0.0))
res["fusion_score"] = raw_fusion_score
res["relevance"] = min(raw_fusion_score / 0.08, 1.0)
if res["relevance"] < min_relevance:
continue
metadata = dict(res.get("metadata", {}) or {})
if not doc_type_matches(metadata, allowed_doc_types):
continue
metadata["doc_type"] = canonical_doc_type(metadata.get("code", ""), metadata.get("doc_type", ""))
metadata.setdefault("collection_type", collection_for_doc_type(metadata["doc_type"]))
res["metadata"] = metadata
code = metadata.get("code") or res.get("id")
if code in seen_codes:
continue
seen_codes.add(code)
deduped_results.append(res)
# ๊ฒฐ๊ณผ ์ˆ˜ ์ œํ•œ
limited_results = deduped_results[:limit]
logger.debug(f"ํ•˜์ด๋ธŒ๋ฆฌ๋“œ RRF ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ: {len(limited_results)}๊ฐœ ํ•ญ๋ชฉ")
return limited_results
except Exception as e:
logger.error(f"๊ฒ€์ƒ‰ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {str(e)}")
raise VectorDBError(f"๊ฒ€์ƒ‰ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {str(e)}", "search")
def get_document_by_id(self, doc_type: str, doc_id: str) -> Optional[Dict[str, Any]]:
"""
ID๋กœ ๋ฌธ์„œ ๊ฐ€์ ธ์˜ค๊ธฐ
Args:
doc_type: ๋ฌธ์„œ ์œ ํ˜•
doc_id: ๋ฌธ์„œ ID
Returns:
๋ฌธ์„œ ์ •๋ณด ๋˜๋Š” None (์ฐพ์ง€ ๋ชปํ•œ ๊ฒฝ์šฐ)
"""
if doc_type not in self.collections:
logger.warning(f"{doc_type} ์ปฌ๋ ‰์…˜์ด ์—†์Šต๋‹ˆ๋‹ค.")
return None
collection = self.collections[doc_type]
try:
results = collection.get(ids=[doc_id])
if not results or not results["ids"]:
return None
return {
"id": results["ids"][0],
"text": results["documents"][0],
"metadata": results["metadatas"][0]
}
except Exception as e:
logger.error(f"ID {doc_id}๋กœ ๋ฌธ์„œ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {str(e)}")
return None
def get_collection_stats(self) -> Dict[str, int]:
"""
์ปฌ๋ ‰์…˜ ํ†ต๊ณ„ ์ •๋ณด ์กฐํšŒ
Returns:
์ปฌ๋ ‰์…˜๋ณ„ ๋ฌธ์„œ ์ˆ˜
"""
stats = {}
for doc_type, collection in self.collections.items():
try:
stats[doc_type] = collection.count()
except Exception as e:
logger.error(f"{doc_type} ์ปฌ๋ ‰์…˜ ํ†ต๊ณ„ ์กฐํšŒ ์‹คํŒจ: {str(e)}")
stats[doc_type] = -1
return stats
def similarity_search(
self,
text: str,
doc_types: List[str] = None,
limit: int = None
) -> List[Dict[str, Any]]:
"""
ํ…์ŠคํŠธ ์œ ์‚ฌ์„ฑ ๊ฒ€์ƒ‰ (์ž์—ฐ์–ด ๊ฒ€์ƒ‰ ๋Œ€์‹  ์ง์ ‘ ํ…์ŠคํŠธ ๋น„๊ต)
Args:
text: ๋น„๊ตํ•  ํ…์ŠคํŠธ
doc_types: ๊ฒ€์ƒ‰ํ•  ๋ฌธ์„œ ์œ ํ˜• ๋ชฉ๋ก
limit: ๊ฒฐ๊ณผ ์ œํ•œ ์ˆ˜
Returns:
์œ ์‚ฌํ•œ ๋ฌธ์„œ ๋ชฉ๋ก
"""
# ์ผ๋ฐ˜ ๊ฒ€์ƒ‰ ๋ฉ”์„œ๋“œ ํ™œ์šฉ
return self.search(text, doc_types, limit)
def reset_collection(self, doc_type: str = None) -> Dict[str, Any]:
"""
์ปฌ๋ ‰์…˜ ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™”
Args:
doc_type: ์ดˆ๊ธฐํ™”ํ•  ๋ฌธ์„œ ์œ ํ˜• (None์ด๋ฉด ๋ชจ๋“  ์ปฌ๋ ‰์…˜)
Returns:
์ดˆ๊ธฐํ™” ๊ฒฐ๊ณผ ํ†ต๊ณ„
"""
results = {}
if doc_type:
if doc_type not in self.collections:
raise VectorDBError(f"{doc_type} ์ปฌ๋ ‰์…˜์ด ์—†์Šต๋‹ˆ๋‹ค.", "collection_missing")
# ๋‹จ์ผ ์ปฌ๋ ‰์…˜ ์ดˆ๊ธฐํ™”
collection = self.collections[doc_type]
count_before = collection.count()
collection.delete(where={})
results[doc_type] = {"before": count_before, "after": collection.count()}
else:
# ๋ชจ๋“  ์ปฌ๋ ‰์…˜ ์ดˆ๊ธฐํ™”
for type_name, collection in self.collections.items():
count_before = collection.count()
collection.delete(where={})
results[type_name] = {"before": count_before, "after": collection.count()}
return {
"status": "success",
"reset_stats": results
}
# ์‹คํ–‰ ์˜ˆ์‹œ
if __name__ == "__main__":
# ๋ฒกํ„ฐ DB ์ดˆ๊ธฐํ™”
vector_db = KCSCVectorDB()
# ๊ธฐ์กด ๋ฐ์ดํ„ฐ ํ†ต๊ณ„ ํ™•์ธ
print("๋ฒกํ„ฐ DB ์ดˆ๊ธฐ ์ƒํƒœ:", vector_db.get_collection_stats())
# ํ…Œ์ŠคํŠธ ๊ฒ€์ƒ‰ (๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ)
for collection_name, count in vector_db.get_collection_stats().items():
if count > 0:
print(f"\n{collection_name} ์ปฌ๋ ‰์…˜์—์„œ ํ…Œ์ŠคํŠธ ๊ฒ€์ƒ‰:")
results = vector_db.search("์ฒ ๊ทผ์ฝ˜ํฌ๋ฆฌํŠธ ๊ธฐ๋‘ฅ ์„ค๊ณ„", [collection_name], 3)
for i, result in enumerate(results):
print(f"\n--- ๊ฒฐ๊ณผ {i+1} ---")
print(f"๋ฌธ์„œ: {result['metadata']['name']} ({result['metadata']['code']})")
print(f"์œ ์‚ฌ๋„: {result['relevance']:.4f}")
print(f"๋‚ด์šฉ ๋ฏธ๋ฆฌ๋ณด๊ธฐ: {result['text'][:150]}...")