4b86b2a660
- server-dev start/stop/deploy 및 Gitea push 자동 배포 - local-dev 로컬 개발 환경 Co-authored-by: Cursor <cursoragent@cursor.com>
166 lines
6.8 KiB
Python
166 lines
6.8 KiB
Python
"""
|
|
검색 핸들러 모듈
|
|
──────────────
|
|
벡터 검색 및 재랭킹 처리
|
|
"""
|
|
|
|
import numpy as np
|
|
from typing import List, Dict, Any, Optional, Tuple
|
|
|
|
|
|
def _format_rerank_document(candidate: Dict[str, Any]) -> str:
|
|
"""리랭커용 FAQ passage — 질문과 답변 전체."""
|
|
q = str(candidate.get("q") or candidate.get("question") or "").strip()
|
|
a = str(candidate.get("a") or candidate.get("answer") or "").strip()
|
|
if q and a:
|
|
return f"질문: {q}\n답변: {a}"
|
|
return q or a
|
|
|
|
|
|
class SearchHandler:
|
|
"""벡터 검색 및 재랭킹 핸들러"""
|
|
|
|
def __init__(self, vector_store, embed_client, rerank_client, config):
|
|
self.vector_store = vector_store
|
|
self.embed_client = embed_client
|
|
self.rerank_client = rerank_client
|
|
self.config = config
|
|
self.last_search_info: Dict[str, Any] = {}
|
|
|
|
def embed_query(self, query: str, ts: str) -> Optional[np.ndarray]:
|
|
"""질문 임베딩
|
|
|
|
Args:
|
|
query: 검색 질문
|
|
ts: 타임스탬프 (로깅용)
|
|
|
|
Returns:
|
|
임베딩 벡터 또는 None (실패 시)
|
|
"""
|
|
self._last_query_text = query or ""
|
|
try:
|
|
embeddings = self.embed_client.embed([query], normalize=True, is_query=True)
|
|
return np.array(embeddings[0], dtype="float32")
|
|
except Exception as e:
|
|
print(f"[SearchHandler] {ts} ❌ 임베딩 실패: {e}")
|
|
return None
|
|
|
|
def search(self, query_vec: np.ndarray, threshold: float, ts: str) -> List[Dict[str, Any]]:
|
|
"""벡터 검색
|
|
|
|
Args:
|
|
query_vec: 질문 임베딩 벡터
|
|
threshold: 유사도 임계값
|
|
ts: 타임스탬프 (로깅용)
|
|
|
|
Returns:
|
|
검색 결과 리스트
|
|
"""
|
|
try:
|
|
if (
|
|
getattr(self.config, "hybrid_search_enabled", False)
|
|
and hasattr(self.vector_store, "hybrid_search")
|
|
):
|
|
results = self.vector_store.hybrid_search(
|
|
query_vec,
|
|
getattr(self, "_last_query_text", ""),
|
|
top_k=self.config.top_k,
|
|
threshold=threshold,
|
|
sparse_top_k=getattr(self.config, "sparse_top_k", 30),
|
|
merge_top_k=getattr(self.config, "hybrid_merge_top_k", 40),
|
|
)
|
|
self.last_search_info = {
|
|
"searchMode": "hybrid_sparse",
|
|
"candidateCount": len(results),
|
|
}
|
|
else:
|
|
results = self.vector_store.search(
|
|
query_vec,
|
|
top_k=self.config.top_k,
|
|
threshold=threshold
|
|
)
|
|
self.last_search_info = {
|
|
"searchMode": "vector",
|
|
"candidateCount": len(results),
|
|
}
|
|
return results
|
|
except Exception as e:
|
|
print(f"[SearchHandler] {ts} ❌ 벡터 검색 실패: {e}")
|
|
self.last_search_info = {"searchMode": "error", "error": str(e)}
|
|
return []
|
|
|
|
def rerank(self, query: str, candidates: List[Dict[str, Any]], ts: str) -> Tuple[List[Dict[str, Any]], List[float], bool, Optional[Dict]]:
|
|
"""재랭킹
|
|
|
|
Args:
|
|
query: 검색 질문
|
|
candidates: 후보 문서 리스트
|
|
ts: 타임스탬프 (로깅용)
|
|
|
|
Returns:
|
|
(상위 N개 결과, 점수 리스트, 재랭킹 사용 여부, 재랭킹 정보)
|
|
"""
|
|
if not candidates:
|
|
print(f"[SearchHandler] {ts} 재랭킹 건너뜀: 후보 문서 없음")
|
|
return [], [], False, {"detail": "No candidates to rerank"}
|
|
|
|
# 재랭킹 후보 제한
|
|
max_rerank = min(self.config.rerank_candidates, len(candidates))
|
|
rerank_candidates = candidates[:max_rerank]
|
|
|
|
print(f"[SearchHandler] {ts} 재랭킹 시작 (candidates={len(candidates)} → 상위 {max_rerank}개)")
|
|
|
|
try:
|
|
# TEI Reranker API 호출 (질문+답변 전체를 passage로 전달)
|
|
documents = [_format_rerank_document(c) for c in rerank_candidates]
|
|
print(f"[SearchHandler] {ts} 리랭커 호출: query={query[:50]}..., documents={len(documents)}개 (Q+A)")
|
|
|
|
rerank_results = self.rerank_client.rerank(
|
|
query=query,
|
|
documents=documents,
|
|
return_documents=False
|
|
)
|
|
|
|
print(f"[SearchHandler] {ts} 리랭커 응답: {len(rerank_results) if rerank_results else 0}개 결과")
|
|
|
|
if not rerank_results:
|
|
print(f"[SearchHandler] {ts} 재랭킹 결과 없음 → FAISS 상위 결과 사용")
|
|
top_n_results = rerank_candidates[:self.config.top_n_for_llm]
|
|
top_scores = [None] * len(top_n_results)
|
|
return top_n_results, top_scores, False, None
|
|
|
|
# score 기준 정렬 후 상위 N개
|
|
sorted_results = sorted(rerank_results, key=lambda x: x["score"], reverse=True)
|
|
top_n_indices = [r["index"] for r in sorted_results[:self.config.top_n_for_llm]]
|
|
top_n_results = [rerank_candidates[idx] for idx in top_n_indices]
|
|
top_scores = [
|
|
round(sorted_results[i]["score"], 4) if i < len(sorted_results) else None
|
|
for i in range(len(top_n_results))
|
|
]
|
|
|
|
# 순위 변화 계산
|
|
rank_changes = [idx - i for i, idx in enumerate(top_n_indices)]
|
|
|
|
def _question_of(candidate: Dict[str, Any]) -> str:
|
|
return str(candidate.get("q") or candidate.get("question") or "")
|
|
|
|
rerank_info = {
|
|
"used": True,
|
|
"original_top_question": _question_of(rerank_candidates[0])[:50] + "...",
|
|
"reranked_top_question": _question_of(top_n_results[0])[:50] + "...",
|
|
"rank_changes": rank_changes
|
|
}
|
|
|
|
print(f"[SearchHandler] {ts} 재랭킹 완료: 상위 {len(top_n_results)}개, 최고 점수={top_scores[0]}")
|
|
if rank_changes[0] != 0:
|
|
print(f"[SearchHandler] {ts} 순위 변화: FAISS #{top_n_indices[0]+1} → Rerank #1")
|
|
|
|
return top_n_results, top_scores, True, rerank_info
|
|
|
|
except Exception as e:
|
|
print(f"[SearchHandler] {ts} ❌ 재랭킹 실패: {e}")
|
|
print(f"[SearchHandler] {ts} FAISS 상위 결과로 대체")
|
|
top_n_results = candidates[:self.config.top_n_for_llm]
|
|
top_scores = [None] * len(top_n_results)
|
|
return top_n_results, top_scores, False, None
|