Commit Graph

113 Commits

Author SHA1 Message Date
Hyungi Ahn 751cdc5be8 fix(queue): enqueue 경로 중복 방어 — partial unique index + 중앙 enqueue_stage 함수
기존 UNIQUE(document_id, stage, status)는 pending+processing 동시 존재를
허용해서 stale 복구 시 충돌 발생. 2-layer 방어로 근본 차단:

1) DB: partial unique index uq_queue_active — 활성 행(pending/processing)은
   (document_id, stage)당 최대 1개만 허용
2) App: enqueue_stage() 중앙 함수 — INSERT ON CONFLICT DO NOTHING으로
   모든 9개 경로의 check-then-insert TOCTOU race 제거

migration 117은 guard check 포함 — 활성 중복이 남아있으면 RAISE EXCEPTION
으로 중단, 수동 정리 유도.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:37:32 +09:00
Hyungi Ahn 8ec1e53ca4 fix(queue): reset_stale_items UniqueViolationError로 큐 소비 전체 중단 수정
stale processing 행을 pending으로 bulk UPDATE 시 이미 같은
(document_id, stage, pending) 행이 존재하면 unique constraint 위반으로
APScheduler consume_queue 잡 전체가 크래시. 2-step 접근으로 변경:
1) pending 중복 있는 stale processing 행은 DELETE
2) 나머지만 pending으로 UPDATE
+ 예외 삼키기로 stale reset 실패가 전체 큐 소비를 죽이지 않게 방어

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:41:20 +09:00
Hyungi Ahn ef89d48bfe fix(library): 자료실 루트 업로드 시 @library/ 태그 누락 수정
폴더 미선택 상태에서 업로드하면 doc_purpose='business'만 설정되고
@library/ 태그가 빠져서 자료실에 문서가 표시되지 않던 버그 수정.
백엔드: business 업로드에 library_path 없으면 @library/미분류 자동 태깅.
프론트: activePath 없을 때 기본값 '미분류' 전송.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:36:40 +09:00
Hyungi Ahn 5c58778a41 feat(library): doc_purpose 필드 + 자료실 업로드 기능
지식/업무 문서 1차 구분을 위한 doc_purpose(business|knowledge) 추가.
- 마이그레이션: document_purpose enum + 컬럼
- AI 분류: docPurpose 자동 추론 (빈 값만 채움)
- 업로드 API: doc_purpose + library_path Form 파라미터
- 자료실 업로드: business 기본값 + 선택 경로 자동 태깅
- FileInfoView: 용도 select (수동 변경, 실패 롤백)
- DocumentCard: 업무/참조 배지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:26:59 +09:00
Hyungi Ahn 96ab2369a7 fix(memos): 수정 500 에러 + 줄바꿈 렌더링
1. DocumentChunk.document_id → doc_id (실제 컬럼명)
2. marked breaks: true — 단일 줄바꿈이 <br>로 변환

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:24:46 +09:00
Hyungi Ahn 5ce0e848a3 feat(memos): 선택적 제목, 툴바 버튼, 대시보드 핀 펼침
메모 입력/편집:
- 선택적 제목 토글 (기본 숨김, "제목" 버튼으로 활성화)
- 툴바 버튼: 체크리스트/굵게/제목 (모바일에서 마크다운 수동 입력 불필요)
- 편집 모드에도 동일 툴바

대시보드 핀 메모:
- 클릭 시 /memos 이동 대신 인라인 펼침/접힘 (details)
- 제목이 있으면 제목 표시, 없으면 첫 줄
- 펼치면 마크다운 렌더링된 본문 + "메모함에서 보기" 링크

Backend:
- MemoCreate/MemoUpdate에 선택적 title 파라미터 복원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 15:06:43 +09:00
Hyungi Ahn deb5c1b704 feat(library): 자료실 — 태그 기반 트리 문서 관리 기능
목적성 문서(양식, 템플릿, 연간보고서)를 @library/ 태그로 분류하고
트리 구조로 탐색하는 자료실 페이지 추가.

백엔드: 경로 정규화 유틸, library-tree/library 엔드포인트,
다운로드 Content-Disposition 개선(원본/PDF 분리, 한글 filename*)
프론트: /library 페이지, LibraryPathEditor, 상단 nav/사이드바 링크

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:55:45 +09:00
Hyungi Ahn c9eeee5fd5 feat(news): 모바일 스플릿뷰 + 책갈피 기능
모바일 풀스크린 오버레이를 제거하고 리스트(35%)+미리보기(65%) 분할뷰로 전환.
pinned 필드를 활용한 책갈피 토글 및 필터 추가.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:43:04 +09:00
Hyungi Ahn 2b5a6d410b refactor(dashboard): 상황판 재설계 — 사용자 지시서 기반 구현
대시보드를 통계판에서 상황판으로 전환:
- 헤더 + 시스템 상태 인라인 (비클릭)
- 핀 메모 최상단 조건부 (컴팩트 띠, 최대 3개)
- 카드 4개 (문서함/메모/뉴스/승인대기) 모바일 2×2
- 최근 활동 전체 너비 7건, 2줄 스캔형 + 법령 배지
- 파이프라인 details 접힘 (실패 시 자동 open)
- 제거: 도메인 분포, 법령/시스템 별도 카드, 8:4 분할

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:42:15 +09:00
Hyungi Ahn 3fe4c16d3a refactor(dashboard): UI/UX 재설계 — 정보 위계 + 모바일 최적화
대시보드 전면 재작성:
- 핀 메모: 최상단 조건부 컴팩트 띠 (pinned=true API 파라미터 추가)
- 4개 핵심 카드: 문서함/메모/뉴스/승인대기 (2×2 모바일, 4열 데스크탑)
- 승인 대기: 액션형 카드 (warning + 검토하기 CTA)
- 최근 활동: 전체 너비, 2줄 스캔형, 법령 알림 뱃지
- 파이프라인: details 기반 접힘 (실패 시 자동 펼침, 수동 접힘 유지)
- 시스템 상태: 헤더 인라인 배지 (비클릭)
- CalDAV stub/도메인 분포 위젯 제거

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 14:25:19 +09:00
Hyungi Ahn fa0175058a feat(dashboard): 카운트 분리 — 문서함/메모/뉴스/승인대기
전체 문서 1개 카드를 6개로 분리: 문서함, 메모, 뉴스, 승인대기,
법령알림, 시스템. 단일 FILTER 쿼리로 효율적 카운트.
각 카드 클릭 시 해당 페이지로 이동.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:19:00 +09:00
Hyungi Ahn 087cbdc900 fix(memos): 뉴스 혼입 방지 + 스크롤 차단 수정
1. 메모 목록 쿼리에 source_channel='memo' 조건 추가.
   뉴스가 file_type='note'로 저장되어 메모 피드에 혼입됨.
2. main 컨테이너 overflow-hidden → overflow-auto.
   메모 페이지가 body 스크롤에 의존하는데 차단되어 있었음.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:09:01 +09:00
Hyungi Ahn e435332ea1 feat(memos): UX 개선 — 편집 수정, 제목 제거, 체크박스, 아카이브
Phase A: 편집 버그 수정 (content만 PATCH, Ctrl+Enter/Esc),
제목 UI 제거 (자동생성 80자, 내부용), 카드 경량화.

Phase B: GFM task list 지원, taskIndex 기반 인터랙티브 토글,
DOMPurify checkbox 최소 허용, optimistic update + 롤백.

Phase C: archived 컬럼 (메모 UX 전용, 문서 미노출),
멱등 세팅 API (토글 아님), 활성/아카이브 뷰 분리 쿼리,
핀은 활성 메모용 (archived 시 무시).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:36:16 +09:00
Hyungi Ahn ee74a9ba78 fix(extract): scale kordoc timeout by file size for large PDFs
대형 PDF(14~40MB)에서 kordoc 파싱 timeout(60초) 실패하던 문제.
10MB당 60초 추가, 최소 60초 최대 300초로 조정.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 06:47:22 +09:00
Hyungi Ahn b46a75758b feat(memos): 내장 메모 기능 — 파일 없는 문서(file_type='note')
Document Server에 Memos 앱 대체 기능 내장. 메모를 documents 테이블의
file_type='note' 레코드로 관리하여 기존 AI 파이프라인(classify/embed/
chunk/search/ask) 재활용.

Backend:
- migration 105: source_channel 'memo', file_path NULL 허용,
  user_tags/pinned/ask_includable 컬럼, 메모 인덱스
- api/memos.py: CRUD 7개 엔드포인트 + #태그 파싱 + stale AI 초기화
  + 큐 pending 중복 방지
- queue_consumer: note extract/preview skip
- documents API: file_path NULL 가드, 목록에서 메모 제외
- search /ask: ask_includable=false 문서 evidence 제외

Frontend:
- /memos 타임라인 페이지 (빠른 입력 + 피드 + 인라인 편집 + 태그 필터)
- QuickMemoButton FAB (Ctrl+M, 모든 페이지)
- Sidebar 메모 링크

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 16:00:00 +09:00
Hyungi Ahn 141eb77938 fix(news): allow HTTP redirect for HTTP_EXCEPTION_DOMAINS sources
SCMP(www.scmp.com)처럼 HTTPS 원본이 HTTP로 301 redirect하는 소스에서
redirect target이 차단되던 문제 수정. allow_http를 원본 스킴이 아닌
소스 도메인의 allowlist 등록 여부로 판단하도록 변경.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:05:05 +09:00
Hyungi Ahn cbef646a3f fix(news): add SCMP to HTTP exception allowlist for HK news source
SCMP(South China Morning Post) RSS가 HTTPS→HTTP 301 redirect 패턴.
HTTP_EXCEPTION_DOMAINS에 www.scmp.com 추가 (2026-07 재검토)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:02:18 +09:00
Hyungi Ahn 6b189f0d47 fix(digest): multi-word ai_sub_group matching + NYT_API_KEY example
- loader.py: first-token + all-but-last-token 이중 키 매칭 (Le Monde, Der Spiegel 대응)
- chunk_worker.py: startswith 매칭 보강
- credentials.env.example: NYT_API_KEY 항목 추가

핫픽스 — 단계 3에서 news_source_id FK 정규화로 문자열 매칭 제거 예정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:33:04 +09:00
Hyungi Ahn 5038007998 fix(news): SSRF validation + admin auth + API key masking + collect lock + XML safety
- 신규 url_validator.py: SSRF 차단 (private IP/loopback/link-local/reserved/multicast/CGNAT 블록, HTTPS only)
- require_admin dependency 추가 — 소스 CRUD, /collect, /digest/regenerate에 적용
- User.is_admin 컬럼 + migration 104
- NYT API key 로그 마스킹 (쿼리스트링 제거)
- RSS fetch: redirect 수동 처리(3회, target 재검증), 5MB 크기 제한, content-type 허용목록, feed.bozo 체크
- /collect 재진입 차단 (asyncio.Lock, 단일 인스턴스 한정)
- HTTP feed allowlist (코드 레벨 상수, API 미노출)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:32:55 +09:00
Hyungi Ahn e405ed3414 fix(ask): evidence sparse 문제 해결 — 프롬프트 + supplement + source 분리
근본 원인: evidence 프롬프트가 "<0.5 = 탈락" 명시 → LLM 하향 편향 →
candidates 5개 중 4개 탈락 → synthesis 자체 거부.

Change 2: evidence_extract.txt
- relevance 스케일 재정의: "탈락" 라벨 제거
- 0.3~0.5 약한 부분 연관 / 0.5~0.7 명확한 부분 연관 구간 세분화
- "directly answer" → "no connection at all" 완화

Change 3: search_synthesis.txt
- refused 조건: "직접 답 아니면 거부" → "완전 무관일 때만 거부"
- "covered only" 제한: partial evidence로 missing part 추론 금지
- supplement evidence weight 지시 추가 (보조 취급)

Change 1: evidence_service.py
- sparse evidence supplement: kept 1~2 + candidates 3+ → rule-only 보충
- substring + critical token 필터 (recall+precision)
- critical token: 길이 3자+ OR 의미 기반 suffix (조건/기준/처벌 등)
- EvidenceItem.source 필드 ("llm"|"supplement"|"rule_fallback")

Change 4: search.py
- defense_log["evidence"] 추가 (skip_reason, kept_count)

synthesis_service.py
- supplement evidence [n] (보충) 마킹

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:11:57 +09:00
Hyungi Ahn b2306c3afd feat(ask): Phase 3.5b guardrails — verifier + telemetry + grounding 강화
Phase 3.5a(classifier+refusal gate+grounding) 위에 4개 Item 추가:

Item 0: ask_events telemetry 배선
- AskEvent ORM 모델 + record_ask_event() — ask_events INSERT 완성
- defense_layers에 input_snapshot(query, chunks, answer) 저장
- refused/normal 두 경로 모두 telemetry 호출

Item 3: evidence 간 numeric conflict detection
- 동일 단위 다른 숫자 → weak flag
- "이상/이하/초과/미만" threshold 표현 → skip (FP 방지)

Item 4: fabricated_number normalization 개선
- 단위 접미사 건/원 추가, 범위 표현(10~20%) 양쪽 추출
- bare number 2자리 이상만 (1자리 FP 제거)

Item 1: exaone semantic verifier (판단권 잠금 배선)
- verifier_service.py — 3s timeout, circuit breaker, severity 3단계
- direct_negation만 strong, numeric/intent→medium, 나머지→weak
- verifier strong 단독 refuse 금지 — grounding과 교차 필수
- 6-tier re-gate (4라운드 리뷰 확정)
- grounding strong 2+ OR max_score<0.2 → verifier skip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:49:56 +09:00
Hyungi Ahn a0e1717206 fix(grounding): citation marker [n] 을 fabricated_number 에서 제외
[1][2][4] 같은 citation 마커의 숫자가 evidence 에 없다고 판정되어
모든 정상 답변이 refuse(2+strong) 되는 critical bug.
answer 에서 \[\d+\] 제거 후 숫자 추출.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:59:29 +09:00
Hyungi Ahn 06443947bf feat(ask): Phase 3.5a guardrails (classifier + refusal gate + grounding + partial)
신규 파일:
- classifier_service.py: exaone binary classifier (sufficient/insufficient)
  parallel with evidence, circuit breaker, timeout 5s
- refusal_gate.py: multi-signal fusion (score + classifier)
  AND 조건, conservative fallback 3-tier (classifier 부재 시)
- grounding_check.py: strong/weak flag 분리
  strong: fabricated_number + intent_misalignment(important keywords)
  weak: uncited_claim + low_overlap + intent_misalignment(generic)
  re-gate: 2+ strong → refuse, 1 strong → partial
- sentence_splitter.py: regex 기반 (Phase 3.5b KSS 업그레이드)
- classifier.txt: exaone Y+ prompt (calibration examples 포함)
- search_synthesis_partial.txt: partial answer 전용 프롬프트
- 102_ask_events.sql: /ask 관측 테이블 (completeness 3-분리 지표)
- queries.yaml: Phase 3.5 smoke test 평가셋 10개

수정 파일:
- search.py /ask: classifier parallel + refusal gate + grounding re-gate
  + defense_layers 로깅 + AskResponse completeness/aspects/confirmed_items
- config.yaml: classifier model 섹션 (exaone3.5:7.8b GPU Ollama)
- config.py: classifier optional 파싱
- AskAnswer.svelte: 4분기 렌더 (full/partial/insufficient/loading)
- ask.ts: Completeness + ConfirmedItem 타입

P1 실측: exaone ternary 불안정 → binary gate 축소. partial은 grounding이 담당.
토론 9라운드 확정. plan: quiet-meandering-nova.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:49:11 +09:00
Hyungi Ahn 010e25cb23 fix(queue): doc-level embed metadata 기반 + NUL 바이트 strip + 빈 예외 fallback
embed_worker:
- extracted_text[:6000] → title + ai_summary + tags(top 5) metadata 입력
- 500k자 문서의 표지+목차가 임베딩되는 구조적 버그 해결
- Ollama 기본 context 안전 (~1500자 이하), num_ctx 조정 불필요
- ai_summary < 50자 시 본문 800자 fallback
- ai_domain 은 초기 제외 (taxonomy 노이즈 방지)

extract_worker:
- kordoc / 직접 읽기 / LibreOffice 3 경로 모두 \x00 strip
- asyncpg CharacterNotInRepertoireError 재발 방지

queue_consumer:
- str(e) or repr(e) or type(e).__name__ fallback
- 빈 메시지 예외(24건 발생) 다음부터 클래스명이라도 기록

plan: ~/.claude/plans/quiet-meandering-nova.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:45:55 +09:00
Hyungi Ahn 4615fb4ce3 fix(documents): page_size 한도 100 → 500 (inbox 291건 누락 회피)
Inbox 가 review_status=pending 서버 필터로 받는데 pending 이 291 건 이라
page_size 100 으론 191 건 누락. inbox 는 작업 큐 성격이라 한 번에 보는 게 UX.
500 으로 상향: data 폭발 없음(filter 로 boundedness 보장), latency 영향 미미.

전략적 임시 — Phase 4.5 UI 작업에서 inbox 에 infinite scroll 또는 pagination
추가하면 le=100 으로 다시 내려도 됨.
2026-04-09 08:35:58 +09:00
Hyungi Ahn cdcbb07561 fix(inbox): page_size=200 → 422 해결, review_status 서버 필터 추가
Inbox 페이지가 /documents/?page_size=200 를 호출하는데 백엔드 Query 가
le=100 이라 422 발생 — Phase 2 첫 commit(2026-04-02)부터 dormant 버그.
inbox 코드 안에 'TODO(backend): review_status filter 지원 시 page_size 축소'
주석이 있던 상태.

backend:
- list_documents 에 review_status: str | None Query 파라미터 추가
- WHERE 절에 review_status 매칭 분기 추가

frontend:
- /documents/?review_status=pending&page_size=100 으로 변경
- 클라이언트 필터링 코드 제거 (서버 필터로 대체)

100 미만 안전. pending 이 100 넘으면 다음 페이지 로직 추가 필요 (별도 작업).
2026-04-09 08:31:51 +09:00
Hyungi Ahn 46ba9dd231 fix(digest/loader): raw SQL pgvector string 형태 파싱 지원
raw text() SQL + asyncpg 조합에서는 pgvector Vector(1024) 컬럼이
'[0.087,0.305,...]' 형태의 string 으로 반환되며 numpy 변환이 실패함
(ORM 을 쓰면 type 등록되지만 raw SQL 은 안 됨).

_to_numpy_embedding 에서 string 이면 json.loads 로 먼저 파싱한 뒤
numpy.asarray. 변환 실패 시 None 반환 (해당 doc 자동 drop).

Phase 4 deploy 워커 첫 실행 검증 중 발견.
2026-04-09 08:00:43 +09:00
Hyungi Ahn 4468c2baba fix(database): 마이그레이션 실행을 raw driver 로 변경
text(sql) 은 SQLAlchemy 가 :name 패턴을 named bind parameter 로 해석하므로
SQL 주석이나 literal 안에 콜론이 들어가면 InvalidRequestError 발생.
ai_summary[:200] 같은 표기가 들어간 101_global_digests.sql 적용 시 fastapi
startup 자체가 떨어지는 문제가 발생.

exec_driver_sql 은 SQL 을 driver(asyncpg) 에 그대로 전달하므로 콜론 escape 불요.
schema_migrations INSERT 만 named bind 가 필요하므로 그건 그대로 유지.

Phase 4 deploy 검증 중 발견. 다음 마이그레이션부터는 자동 적용 가능.
2026-04-09 07:59:25 +09:00
Hyungi Ahn dd9a0f600a fix(database): migrations dir 경로 한 단계 잘못된 버그 수정
_run_migrations 가 Path(__file__).parent.parent.parent / "migrations" 로 계산했는데
/app/core/database.py 기준으로 parent.parent.parent = / (root) 가 되어
실제 경로는 /migrations 였음. 컨테이너 안에는 /app/migrations 에 마운트되므로
디렉토리 부재로 자동 스킵 → 추가 마이그레이션이 자동 적용되지 않는 dormant 버그.

parent.parent (= /app) 로 수정. 회귀 위험 0 (기존엔 어차피 동작 안 했음).
Phase 4 deploy 검증 중 발견 — 직전 commit 의 volume mount 와 함께 동작.
2026-04-09 07:55:10 +09:00
Hyungi Ahn 75a1919342 feat(digest): Phase 4 Global News Digest (cluster-level batch summarization)
7일 rolling window 뉴스를 country × topic 2-level로 묶어 매일 04:00 KST 배치 생성.
search 파이프라인 미사용. documents → clustering → cluster-level LLM summarization → digest.

핵심 결정:
- adaptive threshold (0.75/0.78/0.80) + EMA centroid (α=0.7) + time-decay (λ=ln(2)/3)
- min_articles=3, max_topics=10/country, top-5 MMR diversity, ai_summary[:300] truncate
- cluster-level LLM only, drop금지 fallback (topic_label="주요 뉴스 묶음" + top member ai_summary[:200])
- importance_score country별 0~1 normalize + raw_weight_sum 별도 보존, max(score, 0.01) floor
- per-call timeout 25s + pipeline hard cap 600s
- DELETE+INSERT idempotent (UNIQUE digest_date), AIClient._call_chat 직접 호출 (client.py 수정 없음)

신규:
- migrations/101_global_digests.sql (2테이블 정규화)
- app/models/digest.py (GlobalDigest + DigestTopic ORM)
- app/services/digest/{loader,clustering,selection,summarizer,pipeline}.py
- app/workers/digest_worker.py (PIPELINE_HARD_CAP + CLI 진입점)
- app/api/digest.py (/latest, ?date|country, /regenerate, inline Pydantic)
- app/prompts/digest_topic.txt (JSON-only + 절대 금지 블록)

main.py 4줄: import 2 + scheduler add_job 1 + include_router 1.
plan: ~/.claude/plans/quiet-herding-tome.md
2026-04-09 07:45:11 +09:00
Hyungi Ahn 64322e4f6f feat(search): Phase 3 Ask pipeline (evidence + synthesis + /api/search/ask)
- llm_gate.py: MLX single-inference 전역 semaphore (analyzer/evidence/synthesis 공유)
- search_pipeline.py: run_search() 추출, /search 와 /ask 단일 진실 소스
- evidence_service.py: Rule + LLM span select (EV-A), doc-group ordering,
  span too-short 자동 확장(<80자→120자), fallback 은 query 중심 window 강제
- synthesis_service.py: grounded answer + citation 검증 + LRU 캐시(1h/300),
  refused 처리, span_text ONLY 룰 (full_snippet 프롬프트 금지)
- /api/search/ask: 15s timeout, 9가지 failure mode + 한국어 no_results_reason
- rerank_service: rerank_score raw 보존 (display drift 방지)
- query_analyzer: _get_llm_semaphore 를 llm_gate.get_mlx_gate 로 위임
- prompts: evidence_extract.txt, search_synthesis.txt (JSON-only, example 포함)

config.yaml / docker / ollama / infra_inventory 변경 없음.
plan: ~/.claude/plans/quiet-meandering-nova.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 07:34:08 +09:00
Hyungi Ahn 01f144ab25 fix(search): soft_filter boost 약화 (domain 0.01, doctype 제거)
## 1차 측정 결과 (Phase 2.3 초안)

| metric | Phase 2.2 narrow | Phase 2.3 (boost 0.03+0.02) | Δ |
|---|---|---|---|
| Recall@10 | 0.737 | 0.721 | -0.016  |
| NDCG@10 | 0.668 | 0.661 | -0.007 |
| exact_keyword NDCG | 0.96 | 0.93 | -0.03  |

## 진단
- 같은 도메인 doc이 **무차별** boost → exact match doc 상대 우위 손상
- document_type 매칭은 ai_domain/match_reason 휴리스틱 → false positive 다수

## 수정
- SOFT_FILTER_DOMAIN_BOOST 0.03 → **0.01**
- document_type 매칭 로직 제거
- domain 매칭을 "정확 일치 또는 path 포함"으로 좁힘
- max cap 0.05 유지

## Phase 2.3 위치
 - 현재 평가셋(v0.1)에는 filter 쿼리 없음 → 효과 직접 측정 불가
 - Phase 2.4에서 queries_v0.2.yaml 확장 후 재측정 예정
 - 이 커밋의 목적은 "회귀 방지" — boost가 해를 끼치지 않도록만

(+ CLAUDE.md 동기화: infra_inventory.md 참조 / soft lock 섹션 포함)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:40:04 +09:00
Hyungi Ahn e91c199537 feat(search): Phase 2.3 soft_filter boost (domain/doctype)
## 변경

### fusion_service.py
 - SOFT_FILTER_MAX_BOOST = 0.05 (plan 영구 룰, RRF score 왜곡 방지)
 - SOFT_FILTER_DOMAIN_BOOST = 0.03, SOFT_FILTER_DOCTYPE_BOOST = 0.02
 - apply_soft_filter_boost(results, soft_filters) → int
   - ai_domain 부분 문자열 매칭 (path 포함 e.g. "Industrial_Safety/Legislation")
   - document_type 토큰 매칭 (ai_domain + match_reason 헤이스택)
   - 상한선 0.05 강제
   - boost 후 score 기준 재정렬

### api/search.py
 - fusion 직후 호출 조건:
   - analyzer_cache_hit == True
   - analyzer_tier != "ignore" (confidence >= 0.5)
   - query_analysis.soft_filters 존재
 - notes에 "soft_filter_boost applied=N" 기록

## Phase 2.3 범위
 - hard_filter SQL WHERE는 현재 평가셋에 명시 필터 쿼리 없어 효과 측정 불가 → Phase 2.4 v0.2 확장 후
 - document_type의 file_format 직접 매칭은 의미론적 mismatch → 제외
 - hard_filter는 Phase 2.4 이후 iteration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:30:23 +09:00
Hyungi Ahn e595283e27 fix(search): Phase 2.2 multilingual 활성 조건을 news/global 한정으로 좁힘
## 1차 측정 결과

| metric | Phase 1.3 | Phase 2.2 (all domains) | Δ |
|---|---|---|---|
| Recall@10 | 0.730 | 0.683 | -0.047  |
| natural_language_ko NDCG | 0.73 | 0.63 | -0.10  |
| news_crosslingual NDCG | 0.27 | 0.37 | +0.10 ✓ |
| crosslingual_ko_en NDCG | 0.53 | 0.50 | -0.03  |

document 도메인에서 ko→en 번역 쿼리가 한국어 법령 검색에 noise로 작용.
"기계 사고 관련 법령" → "machinery accident laws" 영어 embedding이
한국어 법령 문서와 매칭 약해서 ko 결과를 오히려 밀어냄.

## 수정

use_multilingual 조건 강화:
 - 기존: analyzer_tier == "analyzed" + normalized_queries >= 2
 - 추가: domain_hint == "news" OR language_scope == "global"

즉 document 도메인은 기존 single-query 경로 유지 → 회귀 복구.
news / global 영역만 multilingual → news_crosslingual 개선 유지.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:20:05 +09:00
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 25ef3996ec feat(chunk): Phase 1.2-G embedding 입력 강화 (title + section + text)
Phase 1.2-G hybrid retrieval 측정 결과 Recall 0.66 정체 + 진단:

직접 nl 쿼리 시도 결과 일부 정답 doc(3854, 3981, 3982, 3920, 3921)이
top-100에도 못 들어옴. doc은 corpus + chunks + embedding 모두 정상.

진짜 원인: 자연어 query ↔ 법령 조항 의미 거리 + 짧은 본문 embedding signal 약함.
- query: '유해화학물질을 다루는 회사가 지켜야 할 안전 의무'
- 본문: '화학물질관리법 제4장 유해화학물질 영업자'
- bge-m3 입장: chunk text만으로는 같은 의미인지 못 알아봄

해결: chunks embedding 입력에 doc.title + section_title 포함.
- before: embed(c['text'])
- after:  embed('[제목] {title}\n[섹션] {section}\n[본문] {text}')

기대 효과:
- 짧은 조항 문서 매칭 회복 (3920/3921 등 300자대)
- 자연어 query → 법령 조항 의미 매칭 개선
- Recall 0.66 → 0.72~0.78

영향: chunks embedding 차원/구조 변경 X — 입력 텍스트 prefix만 다름.
재인덱싱 1회로 모든 chunks 재생성 필요.
2026-04-08 13:08:23 +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 731d1396e8 fix(chunk): _chunk_legal 영어 법령 sliding window fallback
영어/외국 법령(ai_domain Foreign_Law 등)은 '제N조' 패턴이 없어 split 결과가
1개 element만 나옴 → 서문 chunk(첫 1500자)만 생성되고 본문 대부분 손실.

발견: doc 3759 (Industrial Safety, 93KB 영어) → 1개 chunk만 생성.

수정: parts split 결과가 1개 이하면 _chunk_sliding fallback 호출.
한국어 법령(제N조 패턴 있음)은 기존 분할 로직 그대로 작동.

Phase 1.2-D smoke test에서 발견. 재인덱싱 전 fix 필수.
2026-04-08 12:26:38 +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