Hyungi Ahn
21a78fbbf0
fix(search): semaphore로 LLM concurrency=1 강제 + run_eval analyze 파라미터 추가
...
## 배경
1차 Phase 2.2 eval에서 발견: 23개 쿼리가 순차 호출되지만 각 request의
background analyzer task는 모두 동시에 MLX에 요청 날림 → MLX single-inference
서버 queue 폭발 → 22개가 15초 timeout. cache 채워지지 않음.
## 수정
### query_analyzer.py
- LLM_CONCURRENCY = 1 상수 추가
- _LLM_SEMAPHORE: lazy init asyncio.Semaphore (event loop 바인딩)
- analyze() 내부: semaphore → timeout(실제 LLM 호출만) 이중 래핑
semaphore 대기 시간이 timeout에 포함되지 않도록 주의
### run_eval.py
- --analyze true|false 파라미터 추가 (Phase 2.1+ 측정용)
- call_search / evaluate 시그니처에 analyze 전달
## 기대 효과
- prewarm/background/동기 호출 모두 1개씩 순차 MLX 호출
- 23개 대기 시 최악 230초 소요, 단 모두 성공해서 cache 채움
- MLX 서버 부하 안정
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com >
2026-04-08 15:12:13 +09:00
Hyungi Ahn
f5c3dea833
feat(search): Phase 2.2 multilingual vector retrieval + query embed cache
...
## 변경 사항
### app/services/search/retrieval_service.py
- **_QUERY_EMBED_CACHE**: 모듈 레벨 LRU (maxsize=500, TTL=24h)
- sha256(text|bge-m3) 키. fixed query 재호출 시 vector_ms 절반 감소.
- **_get_query_embedding(client, text)**: cache-first helper. 기존 search_vector()도 이를 사용하도록 교체.
- **search_vector_multilingual(session, normalized_queries, limit)**: 신규
- normalized_queries 각 언어별 embedding 병렬 생성 (cache hit 활용)
- 각 embedding에 대해 docs+chunks hybrid retrieval 병렬
- weight 기반 score 누적 merge (lang_weight 이미 1.0 정규화)
- match_reason에 "ml_ko+en" 등 언어 병합 표시
- 호출 조건 문서화 — cache hit + analyzer_tier=analyzed 시에만
### app/api/search.py
- use_multilingual 결정 로직:
- analyzer_cache_hit == True
- analyzer_tier == "analyzed" (confidence >= 0.85)
- normalized_queries >= 2 (다언어 버전 실제 존재)
- 위 3조건 모두 만족할 때만 search_vector_multilingual 호출
- 그 외 모든 경로 (cache miss, low conf, single lang)는 기존 search_vector 그대로 사용 (회귀 0 보장)
- notes에 `multilingual langs=[ko, en, ...]` 기록
## 기대 효과
- crosslingual_ko_en NDCG 0.53 → 0.65+ (Phase 2 목표)
- 기존 경로 완전 불변 → 회귀 0
- Phase 2.1 async 구조와 결합해 "cache hit일 때만 활성" 조건 준수
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com >
2026-04-08 14:59:20 +09:00
Hyungi Ahn
1e80d4c613
fix(search): query_analyzer가 setup_logger 사용하도록 수정
...
기본 logging.getLogger()는 WARNING 레벨이라 prewarm/analyze 진행 로그가
stdout/파일 어디에도 안 찍혔음. setup_logger("query_analyzer")로 교체하면
logs/query_analyzer.log + stdout 둘 다 INFO 레벨 출력.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com >
2026-04-08 14:52:46 +09:00
Hyungi Ahn
324537cbc8
fix(search): LLM_TIMEOUT_MS 5000 → 15000 (실측 반영)
...
축소 프롬프트 재측정:
- prompt_tok 2406 → 802 (1/3 감소 성공)
- latency 10.5초 → 7~11초 (generation이 dominant)
- max_tokens 내려도 무효 (자연 EOS ~289 tok)
5000ms로는 여전히 모든 prewarm timeout. async 구조이므로
background에서 15초 기다려도 retrieval 경로 영향 0.
추가: prewarm delay_between 0.5 → 0.2 (총 prewarm 시간 단축).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com >
2026-04-08 14:50:56 +09:00
Hyungi Ahn
c81b728ddf
refactor(search): Phase 2.1 QueryAnalyzer를 async-only 구조로 전환
...
## 철학 수정 (실측 기반)
gemma-4-26b-a4b-it-8bit MLX 실측:
- full query_analyze.txt (prompt_tok=2406) → 10.5초
- max_tokens 축소 무효 (모델 자연 EOS 조기 종료)
- 쿼리 길이 영향 거의 없음 (프롬프트 자체가 지배)
→ 800ms timeout 가정은 13배 초과. 동기 호출 완전히 불가능.
따라서 QueryAnalyzer는 "즉시 실행하는 기능" → "미리 준비해두는 기능"으로
포지셔닝 변경. retrieval 경로에서 analyzer 동기 호출 **금지**.
## 구조
```
query → retrieval (항상 즉시)
↘ trigger_background_analysis (fire-and-forget)
→ analyze() [5초+] → cache 저장
다음 호출 (동일 쿼리) → get_cached() 히트 → Phase 2 파이프라인 활성화
```
## 변경 사항
### app/prompts/query_analyze.txt
- 5971 chars → 2403 chars (40%)
- 예시 4개 → 1개, 규칙 설명 축약
- 목표 prompt_tok 2406 → ~600 (1/4)
### app/services/search/query_analyzer.py
- LLM_TIMEOUT_MS 800 → 5000 (background이므로 여유 OK)
- PROMPT_VERSION v1 → v2 (cache auto-invalidate)
- get_cached / set_cached 유지 — retrieval 경로 O(1) 조회
- trigger_background_analysis(query) 신규 — 동기 함수, 즉시 반환, task 생성
- _PENDING set으로 task 참조 유지 (premature GC 방지)
- _INFLIGHT set으로 동일 쿼리 중복 실행 방지
- prewarm_analyzer() 신규 — startup에서 15~20 쿼리 미리 분석
- DEFAULT_PREWARM_QUERIES: 평가셋 fixed 7 + 법령 3 + 뉴스 2 + 실무 3
### app/api/search.py
- 기존 sync analyzer 호출 완전 제거
- analyze=True → get_cached(q) 조회만 O(1)
- hit: query_analysis 활용 (Phase 2.2/2.3 파이프라인 조건부 활성화)
- miss: trigger_background_analysis(q) + 기존 경로 그대로
- timing["analyze_ms"] 제거 (경로에 LLM 호출 없음)
- notes에 analyzer cache_hit/cache_miss 상태 기록
- debug.query_analysis는 cache hit 시에만 채워짐
### app/main.py
- lifespan startup에 prewarm_analyzer() background task 추가
- 논블로킹 — 앱 시작 막지 않음
- delay_between=0.5로 MLX 부하 완화
## 기대 효과
- cold 요청 latency: 기존 Phase 1.3 그대로 (회귀 0)
- warm 요청 + prewarmed: cache hit → query_analysis 활용
- 예상 cache hit rate: 초기 70~80% (prewarm) + 사용 누적
- Phase 2.2/2.3 multilingual/filter 기능은 cache hit 시에만 동작
## 참조
- memory: feedback_analyzer_async_only.md (영구 룰 저장)
- plan: ~/.claude/plans/zesty-painting-kahan.md ("철학 수정" 섹션)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com >
2026-04-08 14:47:09 +09:00
Hyungi Ahn
d28ef2fca0
feat(search): Phase 2.1 QueryAnalyzer + LRU cache + confidence 3-tier
...
QueryAnalyzer 스켈레톤 구현. 자연어 쿼리를 구조화된 분석 결과로 변환.
Phase 2.1은 debug 노출 + tier 판정까지만 — retrieval 경로는 변경 X (회귀 0 목표).
multilingual/filter 실제 분기는 2.2/2.3에서 이 분석 결과를 활용.
app/prompts/query_analyze.txt
- gemma-4 JSON-only 응답 규약
- intent/query_type/domain_hint/language_scope/normalized_queries/
hard_filters/soft_filters/expanded_terms/analyzer_confidence
- 4가지 예시 (자연어 법령, 정확 조항, 뉴스 다국어, 의미 불명)
- classify.txt 구조 참고
app/services/search/query_analyzer.py
- LLM_TIMEOUT_MS=800 (MLX 멈춤 시 검색 전체 멈춤 방지, 절대 늘리지 말 것)
- MAX_NORMALIZED_QUERIES=3 (multilingual explosion 방지)
- in-memory FIFO LRU (maxsize=1000, TTL=86400)
- cache key = sha256(query + PROMPT_VERSION + primary.model)
→ 모델/프롬프트 변경 시 자동 invalidate
- 저신뢰(<0.5) / 실패 결과 캐시 금지
- weight 합=1.0 정규화 (fusion 왜곡 방지)
- 실패 시 analyzer_confidence=float 0.0 (None 금지, TypeError 방지)
app/api/search.py
- ?analyze=true|false 파라미터 (default False — 회귀 영향 0)
- query_analyzer.analyze() 호출 + timing["analyze_ms"] 기록
- _analyzer_tier(conf) → "ignore" | "original_fallback" | "merge" | "analyzed"
(tier 게이트: 0.5 / 0.7 / 0.85)
- debug.query_analysis 필드 채움 + notes에 tier/fallback_reason
- logger 라인에 analyzer conf/tier 병기
app/services/search_telemetry.py
- record_search_event(analyzer_confidence=None) 추가
- base_ctx에 analyzer_confidence 기록 (다층 confidence 시드)
- result confidence와 분리된 축 — Phase 2.2+에서 failure 분류에 활용
검증:
- python3 -m py_compile 통과
- 런타임 검증은 GPU 재배포 후 수행 (fixed 7 query + 평가셋)
참조: ~/.claude/plans/zesty-painting-kahan.md (Phase 2.1 섹션)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com >
2026-04-08 14:21:37 +09:00
Hyungi Ahn
2ca67dacea
feat(search): Phase 1.2-G hybrid retrieval (doc + chunks)
...
Phase 1.2-C 평가셋: chunks-only Recall 0.788 → 0.660 catastrophic.
ivfflat probes 1 → 10 → 20 진단 결과 잔여 차이는 chunks vs docs embedding의
본질적 차이 (segment 매칭 vs 전체 본문 평균).
해결: doc + chunks hybrid retrieval (정석).
신규 구조:
- search_vector(): 두 SQL을 asyncio.gather로 병렬 호출
- _search_vector_docs(): documents.embedding cosine top N (recall robust)
- _search_vector_chunks(): document_chunks.embedding window partition
(doc당 top 2 chunks, ivfflat top inner_k 후 ROW_NUMBER PARTITION)
- _merge_doc_and_chunk_vectors(): 가중치 + dedup
- chunk score * 1.2 (segment 매칭 더 정확)
- doc score * 1.0 (recall 보완)
- doc_id 기준 dedup, chunks 우선
데이터 흐름:
1. query embedding 1번 (bge-m3)
2. asyncio.gather([_docs_call(), _chunks_call()])
3. _merge_doc_and_chunk_vectors → list[SearchResult]
4. compress_chunks_to_docs (그대로 사용)
5. fusion (그대로)
6. (Phase 1.3) chunks_by_doc 회수 → reranker
검증 게이트 (회복 목표):
- Recall@10 ≥ 0.75 (baseline 0.788 - 0.04 이내)
- unique_docs per query ≥ 8
- natural_language_ko Recall ≥ 0.65
- latency p95 < 250ms
2026-04-08 13:02:23 +09:00
Hyungi Ahn
f4f9de4402
fix(search): Phase 1.2-C doc-level aggregation으로 다양성 회복
...
Phase 1.2-C 평가셋: Recall 0.788 → 0.531, natural_language 0.73 → 0.07.
진단:
단순 chunk top-N(limit*5=25)으로 raw chunks 가져왔는데 같은 doc의
여러 chunks가 상위에 몰림 → unique doc 다양성 붕괴.
warm test debug: 'chunks raw=16 compressed=5 unique_docs=10'
해결 (사용자 추천 C):
Window function ROW_NUMBER() PARTITION BY doc_id로 doc당 top 2 chunks만 반환.
SQL 흐름:
1. inner CTE topk: ivfflat 인덱스로 top inner_k chunks 빠르게
(inner_k = max(limit*10, 200))
2. ranked CTE: PARTITION BY doc_id ORDER BY dist ROW_NUMBER
3. outer: rn <= 2 (doc당 max 2 chunks) + JOIN documents
4. limit = limit * 4 (chunks 단위, ~limit*2 unique docs)
reranker 호환:
doc당 max 2 chunks 그대로 반환 → chunks_by_doc 보존
compress_chunks_to_docs는 그대로 동작 (best chunk per doc)
Phase 1.3 reranker가 chunks_by_doc에서 raw chunks 회수 가능
핵심 원칙: vector retrieval은 chunk로 찾고 doc으로 선택해야 한다.
2026-04-08 12:47:22 +09:00
Hyungi Ahn
76e723cdb1
feat(search): Phase 1.3 TEI reranker 통합 (코드 골격)
...
데이터 흐름 원칙: fusion=doc 기준 / reranker=chunk 기준 — 절대 섞지 말 것.
신규/수정:
- ai/client.py: rerank() 메서드 추가 (TEI POST /rerank API)
- services/search/rerank_service.py:
- rerank_chunks() — asyncio.Semaphore(2) + 5s soft timeout + RRF fallback
- _make_snippet/_extract_window — title + query 중심 200~400 토큰
(keyword 매치 없으면 첫 800자 fallback)
- apply_diversity() — max_per_doc=2, top score>=0.90 unlimited
- warmup_reranker() — 10회 retry + 3초 간격 (TEI 모델 로딩 대기)
- MAX_RERANK_INPUT=200, MAX_CHUNKS_PER_DOC=2 hard cap
- services/search_telemetry.py: compute_confidence_reranked() — sigmoid score 임계값
- api/search.py:
- ?rerank=true|false 파라미터 (기본 true, hybrid 모드만)
- 흐름: fused_docs(limit*5) → chunks_by_doc 회수 → rerank_chunks → apply_diversity
- text-only 매치 doc은 doc 자체를 chunk처럼 wrap (fallback)
- rerank 활성 시 confidence는 reranker score 기반
- tests/search_eval/run_eval.py: --rerank true|false 플래그
GPU 적용 보류:
- TEI 컨테이너 추가 (docker-compose.yml) — 별도 작업
- config.yaml rerank.endpoint 갱신 — GPU 직접 (commit 없음)
- 재인덱싱 완료 후 build + warmup + 평가셋 측정
2026-04-08 12:41:47 +09:00
Hyungi Ahn
b80116243f
feat(search): Phase 1.2-C chunks 기반 vector retrieval + raw chunks 보존
...
retrieval_service.search_vector를 documents.embedding → document_chunks.embedding로 전환.
fetch_limit = limit*5로 raw chunks를 넓게 가져온 후 doc 기준 압축.
신규: compress_chunks_to_docs(chunks, limit) → (doc_results, chunks_by_doc)
- doc_id 별 best score chunk만 doc_results (fusion 입력)
- 모든 raw chunks는 chunks_by_doc dict에 보존 (Phase 1.3 reranker용)
- '같은 doc 중복으로 RRF가 false boost' 방지
SearchResult: chunk_id / chunk_index / section_title optional 필드 추가.
- text 검색 결과는 None (doc-level)
- vector 검색 결과는 채워짐 (chunk-level)
search.py 흐름:
1. raw_chunks = await search_vector(...)
2. vector_results, chunks_by_doc = compress_chunks_to_docs(raw_chunks, limit)
3. fusion(text_results, vector_results) — doc 기준
4. (Phase 1.3) chunks_by_doc → reranker — chunk 기준
debug notes: raw=N compressed=M unique_docs=K로 흐름 검증.
데이터 의존: 재인덱싱(reindex_all_chunks.py 진행 중) 완료 후 평가셋으로 검증.
2026-04-08 12:36:47 +09:00
Hyungi Ahn
f9af8dd355
fix(search): trigram threshold 0.3 → 0.15 (set_limit)
...
Phase 1.2-B 평가셋 결과 recall 0.788 → 0.750 회귀.
원인: trigram default threshold 0.3이 multi-token 쿼리에서 너무 엄격.
예: '이란 미국 전쟁 글로벌 반응' 같은 5단어 한국어 뉴스 쿼리는
title/ai_summary trigram 매칭이 거의 안 됨.
해결: search_text 시작 시 set_limit(0.15) 호출.
- trigram 매칭 더 관대 (recall ↑)
- precision은 ORDER BY similarity 가중 합산이 보정
- p95 latency 169ms 여유 충분 (목표 500ms)
2026-04-08 11:58:41 +09:00
Hyungi Ahn
ca3e1952d2
fix(search): trigram % operator escape 수정 (%% → %)
...
SQLAlchemy text() + asyncpg dialect에서 trigram operator 위치의 %%는
unescape 안 되어 'text %% unknown' 에러 발생. 단일 %로 변경.
ILIKE의 string literal 안의 %%는 PostgreSQL에서 두 wildcard로 동작했으나,
operator 위치는 escape 처리 경로가 다름.
2026-04-08 11:53:24 +09:00
Hyungi Ahn
fab3c81a0f
fix(search): Phase 1.2-B UNION 분해로 trigram/FTS 인덱스 강제 활용
...
EXPLAIN 진단: OR 통합 WHERE는 PostgreSQL planner가 인덱스 결합 못 함
(small table 765 docs라 Seq Scan 선택). Filter 524ms.
해결: WHERE OR을 CTE candidates UNION으로 분해.
- title trigram → idx_documents_title_trgm (0.5ms)
- ai_summary trigram → idx_documents_ai_summary_trgm (length>0 매치 추가)
- FTS @@ → idx_documents_fts_full (0.05ms)
EXPLAIN 측정: 525ms → 26ms (95% 감소).
본 SELECT(similarity 가중 합산 + ORDER BY) 추가하면 100~150ms 예상.
2026-04-08 11:51:06 +09:00
Hyungi Ahn
22117a2a6d
feat(search): Phase 1.2-AB — migration 016 + trigram retrieval
...
migration 016: documents FTS 확장 + trigram 인덱스 (1.5초 빌드)
- idx_documents_fts_full — title+ai_tags+ai_summary+user_note+extracted_text 통합 FTS
- idx_documents_title_trgm — title 단독 trigram
- idx_documents_extracted_text_trgm — 본문 trigram (NULL 제외)
- idx_documents_ai_summary_trgm — AI 요약 trigram
- CONCURRENTLY 불필요 (765 docs / 6.5MB)
retrieval_service.search_text: ILIKE 완전 제거 → trigram % + similarity()
- WHERE: title %, ai_summary %, FTS @@ (모두 인덱스 활용)
- ORDER BY: 5컬럼 similarity 가중 합산 + ts_rank * 2.0
- 가중치 그대로 (title 3.0 / tags 2.5 / note 2.0 / summary 1.5 / extracted 1.0)
- threshold default 0.3 (필요 시 set_limit으로 조정)
목표: text_ms 470ms → 100~200ms (ILIKE 풀스캔 제거 효과)
2026-04-07 14:36:22 +09:00
Hyungi Ahn
a4eb71d368
feat(search): Phase 1.1a 모듈 분리 — services/search/ 디렉토리
...
검색 로직을 services/search/* 모듈로 분리. trigram 도입은 Phase 1.2 인덱스와 함께.
신규:
- services/search/{__init__,retrieval_service,rerank_service,query_analyzer,evidence_service,synthesis_service}.py
- retrieval_service는 search_text/search_vector 이전 (ILIKE 동작 그대로)
- 나머지는 Phase 1.3/2/3 placeholder
이동:
- services/search_fusion.py → services/search/fusion_service.py (R100)
수정:
- api/search.py — thin orchestrator로 축소 (251줄 → 178줄)
동작 변경 없음 — 구조만 분리. 회귀 검증 후 Phase 1.2 진입.
2026-04-07 13:46:04 +09:00