feat: HybridSearchEngine 구현 — Dense+Sparse 하이브리드 검색 (#154)#192
Conversation
2명 승인, CODEOWNERS 최종 승인, 리뷰 코멘트 해결 조건을 명시
- HybridSearchEngine 핵심 모듈 구현 (asyncio 기반 병렬 검색, Weighted RRF) - SearchRequest/SearchResponse 스키마 확장 (search_mode, search_time_ms) - /v1/search 엔드포인트 통합 및 레거시 /search 하위 호환 유지 - vLLMEngineManager에 MultiIndexManager, BM25 인덱스 초기화 추가 - pipeline.py에 BM25 빌드 단계 통합 (--mode full 자동, --mode bm25 단독) - ADR-005 하이브리드 검색 아키텍처 결정 기록 작성 - 단위 테스트 47개, E2E 통합 테스트 15개, Okt 폴백 검증 11개 (총 73개)
yuujjjj
left a comment
There was a problem hiding this comment.
이번 PR에서 가장 큰 문제는 하이브리드 검색의 핵심 경로가 현재 저장소의 데이터/초기화 규약과 맞지 않아, 기능이 추가된 것처럼 보이지만 실제 운영 경로에서는 sparse 검색이 비활성화될 가능성이 높다는 점입니다.
🔴 Blocker: BM25 빌드가 현재 저장소의 표준 학습 포맷을 처리하지 못해 sparse 인덱스 생성이 깨집니다
관련 위치:
src/data_collection_preprocessing/pipeline.py:474src/inference/bm25_indexer.py:205src/inference/bm25_indexer.py:212data/processed/civil_complaint_train.jsonl
build_bm25_indexes()가 *_train.jsonl 전체를 순회하는데, 현재 저장소에는 civil_complaint_train.jsonl과 v2_train.jsonl이 같이 있습니다. 그런데 BM25Indexer.build_index_from_jsonl()은 사실상 text 또는 complaint만 읽고 있어서, 실제 포맷이 input/output인 civil_complaint_train.jsonl은 빈 문서들로 처리됩니다. 이 경우 all-empty corpus 예외가 발생하고 함수 전체가 종료되어 BM25 빌드가 중간에 깨집니다.
Why:
현재 PR의 핵심 기능은 BM25 기반 sparse 검색 추가인데, 기본 데이터셋 조합에서 인덱스 생성이 실패하면 hybrid search는 운영 환경에서 성립하지 않습니다.
Suggestion:
v2_*와civil_complaint_*포맷을 모두 처리하도록 입력 필드 매핑을 명시해 주세요.- 파일 하나 실패가 전체 빌드 실패가 되지 않도록 JSONL 파일별 예외 처리를 분리하는 편이 안전합니다.
🔴 Blocker: BM25 산출물 이름 규약이 서버 로딩 규약과 달라, 빌드에 성공해도 hybrid가 실제로 활성화되지 않습니다
관련 위치:
src/data_collection_preprocessing/pipeline.py:476src/data_collection_preprocessing/pipeline.py:479src/inference/api_server.py:137src/inference/hybrid_search.py:135
파이프라인은 BM25 파일을 v2.pkl, civil_complaint.pkl처럼 데이터셋 stem 기준으로 저장하지만, 서버는 case.pkl, law.pkl, manual.pkl, notice.pkl만 로드합니다. 이 상태에서는 BM25를 빌드해도 서버가 읽지 못하고, HybridSearchEngine은 곧바로 dense로 폴백합니다.
Why:
search_mode=hybrid가 설정되어도 실제로는 dense-only가 되어 기능이 묵살됩니다. 가장 위험한 점은 외부에서 실패가 드러나지 않는다는 점입니다.
Suggestion:
- BM25 저장/로드 규약을
IndexType기준으로 통일해 주세요. - 최소한 CASE 경로라도
case.pkl로 맞춰야 하고, 가능하면 pipeline 단계에서 타입별 산출물을 명시적으로 생성하는 구조가 더 안전합니다.
🟡 Suggestion: /v1/search 응답이 non-CASE 문서 본문을 잃어버리고, 내부 dense 폴백도 실제 mode로 보고하지 않습니다
관련 위치:
src/inference/api_server.py:448src/inference/api_server.py:452src/inference/api_server.py:466src/inference/index_manager.py:33docs/architecture/ADR-004-enhanced-rag-architecture.md:556
현재 content는 extras.complaint_text + answer_text 조합만 우선 사용하고, 없으면 제목으로 대체합니다. 그런데 LAW/MANUAL/NOTICE는 타입별 추가 필드가 extras에 다르게 들어가므로, 이 경로에서는 본문이 아니라 제목만 반환될 가능성이 큽니다. 또 HybridSearchEngine이 BM25 미구축 상태에서 dense로 폴백해도 API 응답은 요청한 search_mode를 그대로 반환합니다.
Why:
확장 검색 스키마가 기대하는 본문/요약 전달이 깨지고, 실제 검색 방식에 대한 관측도 틀어져서 디버깅과 품질 평가가 어려워집니다.
Suggestion:
- 타입별
content추출 규칙을 별도 함수로 분리해 주세요. - 내부 폴백이 발생하면 실제 사용된 mode를 응답에 반영하는 편이 좋습니다.
- 현재 추가된 E2E 테스트는 HybridSearchEngine을 mock한 CASE 형태 결과만 검증해서 이 회귀를 잡지 못하므로, 실제 초기화 경로와 non-CASE 문서 매핑까지 커버하는 테스트가 필요합니다.
로컬 환경에는 pytest가 설치되어 있지 않아 테스트는 직접 재실행하지 못했습니다. 위 리뷰는 PR diff와 현재 브랜치 기준 정적 분석 결과입니다.
siuJang
left a comment
There was a problem hiding this comment.
PR #192 정밀분석 결과
대상: feat: HybridSearchEngine 구현 — Dense+Sparse 하이브리드 검색 (#154)
🔍 Code Reviewer 분석
1. 강점
1-1. RRF 구현이 정석적이다
_reciprocal_rank_fusion()에서 Cormack et al. (2009)의 표준 RRF 공식(1 / (k + rank))을 정확히 구현. rrf_k=60 기본값이 적절하고, IndexType별 가중치(RRFWeightConfig)로 튜닝 여지를 남겨둔 설계가 좋다.
1-2. BM25Indexer 보안 설계가 우수하다
Pickle의 위험성을 인지하고 HMAC-SHA256 서명/검증을 선택적으로 제공. 페이로드 버전 관리, 토크나이저 불일치 경고, 손상 파일 감지 등 방어가 다층적이다.
1-3. 병렬 검색 구조가 합리적이다
asyncio.gather로 dense/sparse를 동시 실행하고, 한쪽 실패가 전체를 중단시키지 않는 구조. run_in_executor로 blocking I/O를 비동기로 래핑한 점도 적절.
1-4. 테스트 커버리지가 충실하다
73개 테스트가 BM25 빌드/검색/직렬화/HMAC 보안/엣지케이스를 체계적으로 커버한다.
2. 약점 및 개선 권고사항
[Blocker] 2-1. civil_complaint_train.jsonl 데이터 형식 불일치 — BM25 빌드 실패
실제 civil_complaint_train.jsonl 필드: id, instruction, input, output, category, source — text 필드 없음.
build_index_from_jsonl()의 폴백 경로:
"text" in item→ False"complaint" in item→ False_extract_complaint_from_template(item.get("text", ""))→ 빈 문자열
→ 프로덕션 데이터로 BM25 인덱스를 빌드할 수 없음.
권고:
input필드 fallback 추가:elif "input" in item: text = item["input"]
[Blocker] 2-2. 파이프라인 인덱스 파일명과 서버 로딩 경로 불일치
| 파이프라인 출력 | 서버 로딩 기대 |
|---|---|
v2.pkl, civil_complaint.pkl |
case.pkl, law.pkl, manual.pkl, notice.pkl |
서버가 BM25 파일을 찾지 못해 하이브리드 검색이 항상 dense-only로 폴백된다.
권고:
IndexType.value기반 파일명으로 통일.
[Suggestion] 2-3. /v1/search content 추출이 CASE 타입에만 동작
complaint_text + answer_text 추출은 LAW/MANUAL/NOTICE에서 빈 content를 반환. 현재 CASE만 있으므로 당장 문제는 아니지만 API 계약상 불일관.
[Suggestion] 2-4. Hybrid→Dense 폴백 시 search_mode 미갱신
BM25 미준비 시 dense-only로 폴백하지만 응답의 search_mode가 여전히 "hybrid". 디버깅/모니터링에 혼란.
3. 프로젝트 적합성: 높음 (핵심 기능)
한국어 민원에서 "도로교통법 제12조", "강남구청 건설과" 같은 키워드 정확 매칭은 dense 검색만으로 한계가 있으며 BM25 보완이 실질적 검색 품질 향상을 가져온다.
4. 머지 가능 여부: 조건부 승인
| 구분 | 항목 | 심각도 |
|---|---|---|
| 필수 | 2-1: input 필드 fallback 추가 |
Blocker |
| 필수 | 2-2: BM25 파일명 IndexType.value 기반 통일 |
Blocker |
| 권장 | 2-3: content 추출 타입별 분리 | Follow-up |
| 권장 | 2-4: 폴백 시 actual_search_mode 반영 | Follow-up |
🛡️ Security Engineer 분석
1. 강점
S-1. RRF 기반 점수 퓨전은 점수 조작 공격 표면을 줄인다
원시 점수가 아닌 순위 기반 합산이므로 한쪽 리트리버의 점수 스케일을 왜곡해도 최종 결과에 미치는 영향이 제한적이다.
S-2. BM25 인덱스 무결성 검증 (HMAC-SHA256)
hmac.compare_digest() 사용으로 타이밍 공격도 방어.
S-3. 기존 보안 체계 일관 유지
API 키 인증, Rate Limiting(60/minute), 내부 예외 비노출 패턴이 /v1/search에도 동일 적용.
S-4. 입력 검증 스키마
SearchRequest에서 query(max 10000), top_k(max 100) Pydantic 검증 적용.
2. 약점 및 개선 권고사항
[Critical] C-1. 검색 결과를 통한 민원인 PII 유출 경로
검색 결과 메타데이터에 complaint(민원 원문), answer(답변 원문)이 그대로 포함된다. 민원 원문에는 이름, 주소, 전화번호 등 PII가 포함될 수 있다. pii_masking.py는 전처리 파이프라인에만 존재하며, 검색 시점 반환 메타데이터에는 마스킹 미적용.
권고: 검색 결과 반환 전 PII 마스킹 레이어 추가. 정부 시스템에서 개인정보 노출은 개인정보보호법 위반 리스크.
[High] H-1. BM25 파일명 불일치 — 사일런트 디그레이드
보안 관점에서: 운영자가 "hybrid" 검색이 활성화되었다고 인지하지만 실제로는 dense-only. WARNING 로그만 남기므로 모니터링 사각지대. BM25 키워드 정확 매칭 누락으로 부정확한 답변이 민원인에게 전달될 수 있다.
권고:
/health에 BM25 로딩 상태 명시적 노출, 서버 시작 시 ERROR 레벨 로깅.
[High] H-2. 양측 검색 동시 실패 시 빈 결과 무음 반환
Dense/Sparse 모두 예외 시 빈 리스트를 반환하지만 호출자에게 오류 전파 없음. 사용자가 "검색 결과 없음"을 "해당 민원이 존재하지 않음"으로 오해할 수 있다.
권고: 양측 모두 실패 시 명시적 에러 반환.
[Medium] M-1. HMAC 검증이 선택적(opt-in)
BM25_INDEX_HMAC_KEY 미설정 시 HMAC 없이 pickle 역직렬화 수행. pickle은 임의 코드 실행(RCE) 직결.
권고: 운영 환경에서는 HMAC 필수화 권장.
[Medium] M-2. 메타데이터 매칭 키 (id, category, complaint[:40]) 충돌 가능
id가 None이거나 complaint 앞 40자가 동일한 유사 민원이 존재하면 RRF 점수가 잘못된 문서에 할당됨.
3. 프로젝트 적합성
- 폐쇄망 적합성: 적합 — 외부 의존 없음, HMAC이 폐쇄망 무결성 검증에 적절
- 보안 기준선: 부분 충족 — PII 보호 레이어 부재는 정부 시스템 기준 미충족
4. 머지 가능 여부: 조건부 승인
| 구분 | 항목 |
|---|---|
| 필수 | C-1: 검색 결과 PII 마스킹 레이어 추가 |
| 필수 | H-1: BM25 파일명 통일 + /health에 상태 노출 |
| 필수 | H-2: 양측 검색 동시 실패 시 에러 전파 |
| 권장 | M-1: 운영 환경 HMAC 필수화 |
🏗️ Backend Architect 분석
1. 강점
1-1. 아키텍처 설계의 학술적 기반이 탄탄함
RRF k=60은 원 논문의 표준 파라미터. IndexType별 가중치 차등화(CASE: dense 우위, LAW: sparse 우위)는 한국어 민원 도메인의 특성을 반영한 합리적 설계.
1-2. 비동기 병렬 실행이 적절하다
FAISS와 BM25 모두 CPU-bound이므로 run_in_executor가 적절한 선택.
1-3. 확장성 구조가 양호하다
np.argpartition O(N) top-k, MultiIndexManager의 IVFFlat 자동 전환(10만건 임계값)과 호환.
2. 약점 및 개선 권고사항
[CRITICAL] 2-1. 데이터 파이프라인 네이밍 불일치
파이프라인 출력(v2.pkl, civil_complaint.pkl)과 서버 로드 경로(case.pkl)가 일치하지 않아 BM25 인덱스 로드 불가.
[CRITICAL] 2-2. civil_complaint_train.jsonl 필드 불일치
input 필드에 실제 민원 내용이 있지만 코드가 이를 참조하지 않아 빈 코퍼스 빌드.
[HIGH] 2-3. RRF 퓨전 metadata lookup의 취약한 키 설계
(id, category, complaint[:40]) 3-tuple 키는 유일성 미보장. CivilComplaintRetriever.search()가 corpus_index를 반환하도록 확장하면 직접 매핑이 가능.
[HIGH] 2-4. E2E 테스트가 BM25를 mock하므로 실제 빌드-검색 경로 미검증
실제 JSONL 데이터 샘플(10건)로 build_index_from_jsonl → search → RRF 퓨전 전체 경로를 검증하는 통합 테스트 1건 이상 필요.
[MEDIUM] 2-5. Graceful fallback 시 search_mode 미갱신
[MEDIUM] 2-6. content 추출이 CASE 타입에만 동작
지원하지 않는 IndexType에 대해 명시적으로 501 Not Implemented 반환 권장.
3. 프로젝트 적합성
| 항목 | 평가 |
|---|---|
| 아키텍처 적합성 | 부분적 적합 — HybridSearchEngine이 서빙 레이어에 미연결 |
| 데이터 파이프라인 정합성 | 부적합 — 파일명 불일치 + 필드 불일치로 프로덕션 동작 불가 |
| 확장성 | 양호 — 619건~수만건 대응 가능 구조 |
4. 머지 가능 여부: 조건부 승인
| 구분 | 항목 |
|---|---|
| 필수 | 2-1: BM25 인덱스 파일명 IndexType.value 기반 통일 |
| 필수 | 2-2: build_index_from_jsonl에 input 필드 탐색 추가 |
| 필수 | 2-4: 실제 JSONL 데이터로 빌드-검색 전체 경로 통합 테스트 추가 |
| 권장 | 2-3: corpus_index 직접 매핑으로 metadata lookup 개선 |
| 권장 | 2-5, 2-6: 폴백 모드 보고 + content 추출 분리 |
📊 종합 판정
| 분석 모드 | 판정 | 필수 수정 건수 |
|---|---|---|
| Code Reviewer | 조건부 승인 | 2건 (Blocker) |
| Security Engineer | 조건부 승인 | 3건 (Critical 1 + High 2) |
| Backend Architect | 조건부 승인 | 3건 (Critical 2 + High 1) |
공통 필수 수정 사항 (중복 제거 후)
| # | 항목 | 합의 |
|---|---|---|
| 1 | build_index_from_jsonl에 input 필드 fallback 추가 |
3/3 일치 |
| 2 | BM25 인덱스 파일명을 IndexType.value 기반으로 통일 |
3/3 일치 |
| 3 | 실제 JSONL 데이터로 빌드→검색→RRF 전체 경로 통합 테스트 추가 | 2/3 일치 |
| 4 | 검색 결과 PII 마스킹 레이어 추가 (정부 시스템 법적 요건) | Security 단독 |
| 5 | 양측 검색 동시 실패 시 에러 전파 (빈 결과 무음 반환 방지) | Security 단독 |
위 5건 해결 후 머지 승인 가능. 특히 #1, #2는 리뷰어 @yuujjjj 님의 지적과 정확히 일치하며, 실 데이터 검증 결과 확인됨.
Blocker 수정: - bm25_indexer: build_index_from_jsonl에 input 필드 fallback 추가 - pipeline: BM25 파일명을 IndexType.value 기반으로 통일 (v2.pkl → case.pkl) Critical/High 수정: - api_server: 검색 결과 PII 마스킹 레이어 추가 (PIIMasker 재사용) - hybrid_search: 양측 검색 동시 실패 시 RuntimeError 전파 - hybrid_search: search() 반환값에 actual_mode 포함 (Tuple 시그니처) Medium 수정: - api_server: 타입별 content 추출 함수 _extract_content_by_type() 분리 - api_server: /health에 BM25 인덱스 로딩 상태 노출 - schemas: SearchResponse에 actual_search_mode 필드 추가 테스트: - 통합 테스트 28개 신규 추가 (test_hybrid_search_integration.py) - 기존 테스트 시그니처 변경 반영 (73개 회귀 없음) - 전체 101개 통과
siuJang
left a comment
There was a problem hiding this comment.
리뷰 반영 확인 완료 — 승인
이전 리뷰(REQUEST_CHANGES)에서 지적한 필수 수정 5건 모두 정상 반영 확인했습니다.
| # | 항목 | 판정 |
|---|---|---|
| 1 | build_index_from_jsonl에 input 필드 fallback |
✅ text→complaint→input→template 순서 추가 |
| 2 | BM25 파일명 IndexType.value 기반 통일 |
✅ _JSONL_TO_INDEX_TYPE 매핑 → case.pkl 출력 |
| 3 | 빌드→검색→RRF 전체 경로 통합 테스트 | ✅ 28개 신규 (실제 BM25 빌드+검색+RRF) |
| 4 | 검색 결과 PII 마스킹 레이어 | ✅ _mask_search_results() + /health 상태 노출 |
| 5 | 양측 실패 시 에러 전파 | ✅ RuntimeError 전파 + actual_search_mode 반영 |
추가 확인
pipeline.py와bm25_indexer.py간 필드 탐색 순서 일치 확인pipeline.py파일명과api_server.py로딩 경로 정합성 확인- 테스트 101개 전체 통과 (회귀 없음)
수고하셨습니다 👍
The merge-base changed after approval.
yuujjjj
left a comment
There was a problem hiding this comment.
정밀분석 재확인 결과, 이전에 지적된 핵심 수정 사항은 최신 커밋 79caa50 기준으로 정상 반영된 것으로 확인했습니다.
확인 완료
- BM25
inputfallback 추가 - BM25 산출물 파일명
IndexType.value기준 통일 및 JSONL별 예외 격리 - Hybrid 양측 실패 시
RuntimeError전파 - Hybrid -> Dense/Sparse 폴백 시
actual_search_mode반영 - 타입별
_extract_content_by_type()분리로 non-CASE content 매핑 개선 - 검색 결과 PII 마스킹 레이어 추가
/health에 BM25 로딩 상태,hybrid_search_enabled,pii_masking_enabled노출- 실제 BM25 빌드/검색/RRF, content 추출, 양측 실패 전파를 보는 통합 테스트 28개 추가
코드 기준 근거
src/inference/bm25_indexer.py:text -> complaint -> input -> templatefallback 확인src/data_collection_preprocessing/pipeline.py:v2/civil_complaint->case.pkl매핑 및 파일별 예외 격리 확인src/inference/hybrid_search.py:return_exceptions=True+ 양측 실패 시 예외 전파 확인src/inference/api_server.py:_extract_content_by_type(),_mask_search_results(),/health상태 노출,actual_search_mode반영 확인src/inference/schemas.py:SearchResponse.actual_search_mode필드 추가 확인
메모
- 로컬 환경에는
pytest가 없어 테스트 재실행은 못 했습니다. - 다만 PR에 추가된 통합 테스트 정의 수(28개)와 코드 경로는 정적 분석으로 확인했습니다.
PII 마스킹 적용까지는 확인되지만, 법적 준수 여부 자체는 운영/정책 검토가 별도로 필요합니다.
이전 리뷰 기준으로는 머지를 막던 핵심 이슈들은 해소된 상태로 보입니다.
yuujjjj
left a comment
There was a problem hiding this comment.
리뷰 반영 확인 완료 — 승인
이전 리뷰에서 지적된 핵심 수정 사항은 최신 커밋 79caa50 기준으로 정상 반영된 것으로 확인했습니다.
| # | 항목 | 판정 |
|---|---|---|
| 1 | build_index_from_jsonl에 input 필드 fallback 추가 |
✅ text -> complaint -> input -> template 순서 확인 |
| 2 | BM25 파일명 IndexType.value 기반 통일 |
✅ v2/civil_complaint -> case.pkl 매핑 확인 |
| 3 | 빌드 -> 검색 -> RRF 전체 경로 통합 테스트 | ✅ 통합 테스트 28개 추가 확인 |
| 4 | 검색 결과 PII 마스킹 레이어 | ✅ _mask_search_results() 및 PIIMasker 초기화 확인 |
| 5 | 양측 실패 시 에러 전파 | ✅ return_exceptions=True + RuntimeError 전파 확인 |
| 6 | Hybrid -> Dense/Sparse 폴백 시 실제 모드 반영 | ✅ actual_search_mode 반환 확인 |
| 7 | non-CASE 문서 content 추출 개선 | ✅ _extract_content_by_type() 분리 확인 |
| 8 | /health 상태 노출 보강 |
✅ BM25 상태, hybrid_search_enabled, pii_masking_enabled 확인 |
추가 확인
pipeline.py와bm25_indexer.py간 필드 탐색 순서 정합성 확인pipeline.pyBM25 산출물 이름과api_server.py로딩 경로 정합성 확인SearchResponse.actual_search_mode스키마 반영 확인
메모
- 로컬 환경에는
pytest가 없어 테스트 재실행은 못 했습니다. - 다만 추가된 통합 테스트 정의 수와 코드 경로는 정적 분석으로 확인했습니다.
PII 마스킹 적용은 확인했지만, 법적 준수 여부 자체는 운영/정책 검토가 별도로 필요합니다.
이전 리뷰 기준으로 머지를 막던 핵심 이슈들은 해소된 상태로 보입니다.
The merge-base changed after approval.
The merge-base changed after approval. 이전 변경 요청 리뷰를 정리하고 최신 기준으로 새 승인 리뷰를 받을 수 있도록 dismiss합니다.
Summary
src/inference/hybrid_search.py): asyncio 기반 Dense(FAISS) + Sparse(BM25) 병렬 검색, Weighted RRF 융합 (k=60), 데이터 타입별 가중치 적용schemas.py):SearchRequest에search_mode(dense/sparse/hybrid),SearchResponse에search_time_ms,actual_search_mode추가api_server.py):/v1/search+/search이중 경로, MultiIndexManager·BM25 초기화, 레거시 retriever 폴백pipeline.py):--mode full시 BM25 자동 빌드,--mode bm25단독 실행, IndexType 기반 파일명 통일docs/architecture/ADR-005-hybrid-search-architecture.md): 하이브리드 검색 아키텍처 결정 기록코드 리뷰 반영사항 (2차)
@yuujjjj @siuJang 리뷰 반영 — Blocker 2건 + Critical 1건 + High 2건 + Medium 3건 수정 완료:
build_index_from_jsonl에input필드 fallback 추가 —civil_complaint_train.jsonl빌드 실패 해결IndexType.value기반 통일 (v2.pkl→case.pkl) — 서버 로드 경로 불일치 해결PIIMasker.mask_all()적용, 개인정보보호법 준수RuntimeError전파 —return_exceptions=True+ 편측 graceful degradationactual_search_mode반환 —search()Tuple 시그니처 확장_extract_content_by_type()분리 — LAW/MANUAL/NOTICE 본문 누락 해결/health에 BM25 인덱스 로딩 상태 +hybrid_search_enabled+pii_masking_enabled노출변경 파일
src/inference/bm25_indexer.pyinput필드 fallback 추가src/data_collection_preprocessing/pipeline.pysrc/inference/hybrid_search.pysrc/inference/api_server.pysrc/inference/schemas.pyactual_search_mode필드 추가tests/test_inference/test_hybrid_search_integration.pytests/test_inference/test_hybrid_search.pytests/test_inference/test_hybrid_search_e2e.py관련 이슈
Test plan
pytest tests/test_inference/test_hybrid_search.py— 단위 테스트 47개pytest tests/test_inference/test_hybrid_search_e2e.py— E2E 통합 테스트 15개pytest tests/test_inference/test_tokenizer_fallback.py— Okt 폴백 검증 11개pytest tests/test_inference/test_hybrid_search_integration.py— 빌드→검색→RRF 통합 테스트 28개--mode bm25파이프라인 단독 실행 확인