Spaces:
Sleeping
Sleeping
| """ | |
| ๋ฒกํฐ ๋ฐ์ดํฐ๋ฒ ์ด์ค ๊ด๋ฆฌ ๋ชจ๋ | |
| """ | |
| 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") | |
| 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]}...") | |