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