본문으로 건너뛰기

ADR 0055: 상담 기억 회상

배경

ADR 0049 는 상담을 일급 모델로 만들며 "미수임 상담도 사무소 자산" 이라 선언했지만, 그 자산이 실제로 회수되는 경로가 없다:

  1. 이해충돌 검사 (P0-4) 는 당사자 이름·연락처의 exact-match 대조뿐 — "비슷한 사건 경위의 과거 상담" 은 어떤 경로로도 다시 떠오르지 않는다.
  2. 변호사가 새 상담을 받을 때 가장 가치 있는 질문 — "우리 사무소가 이런 상담을 받은 적 있나? 그때 수임했나? 왜 거절했나?" — 에 제품이 침묵.
  3. 한 줄 본질 ("자기 데이터로 자기 AI 학습·활용") 관점에서 consultations 는 적재만 되고 학습·활용 루프 밖에 있는 유일한 텍스트 자산이었다.

기존 인프라가 이미 존재: tenant 격리 임베딩 (ADR 0015 — legacyDocuments .embedding), 자동 회상 통일 패턴 (ADR 0048 — 입력 멈춤 → 의미 검색 → 패널), guardedFindNearest 경계 가드. 상담에 같은 패턴을 수평 적용한다.

결정

1. 임베딩 파이프라인 — tenants/{tid}/consultations/{id}.embedding

  • Cloud Function onConsultationWrittenEmbed (functions/src/embed-consultation.ts, onDocumentWritten) 이 상담 write 마다 발화.
  • 입력 SSoT = consultation-embed-input.ts (functions + apps/web 미러, 의미 변경 시 양측 동시 수정 — embed-input-prepare 와 동일 계약): 사건명: {standardCaseName}\n{maskConsultationPII(summary)}. summary 가 비면 사건명만으로는 임베딩하지 않는다 (라벨 벡터는 의미 검색 노이즈).
  • 이후는 기존 경로 재사용 (drift 0): prepareEmbedInput → chunk → embedChunksParallel (text-multilingual-embedding-002, 768-dim) → meanPoolVectorsFieldValue.vector.
  • 자기 발화 루프 차단: embedding 에 inputHash (정규화 입력의 FNV-1a) 저장. 재발화 시 hash 동일 → skip. 실패 시 embeddingError (legacy 패턴)
    • embeddingErrorHash — 동일 입력 무한 재시도를 차단하고, 입력이 바뀌면 자연 재시도. 입력이 너무 짧아지면 (경위 삭제) embedding 을 제거해 stale 벡터가 회상에 남지 않게 한다.
  • 판단은 순수 함수 decideConsultationEmbed (consultation-embed-decision.ts) — 단위 테스트로 루프·재시도·clear 경로 전부 고정.

2. PII 정책 — 경위 원문 redaction

  • maskConsultationPII: 주민번호·전화번호 → [MASK_SSN]/[MASK_PHONE] 토큰 (cases maskPII 와 동일 정규식 계열) → normalizeEmbedInput[MASK_*] 제거 규칙이 토큰째 삭제. 즉 Vertex 로 나가는 임베딩 입력에 주민번호·전화번호가 아예 없다.
  • 이름은 마스킹하지 않는다 — 경위 원문은 이미 casePrefill (P2-13) 이 동일 flag 체계 아래 Gemini 로 전송 중이며, 임베딩 입력은 그보다 보수적이다. 벡터·원문 모두 tenant 경로 안에만 저장 (ADR 0015 격리 불변).

3. 자동 회상 — 새 상담 등록 폼 (ADR 0048 통일 패턴)

  • SimilarConsultationsPanel — ConsultationForm 의 이해충돌 패널 아래. 경위 입력 멈춤 (1.2s 디바운스, ≥20자) → findSimilarConsultationsAction.
  • 쿼리도 동일 입력 규칙 (마스킹·사건명 prefix·chunk→meanPool) 으로 임베딩 — 문서/쿼리 대칭이 cosine 의미를 보존.
  • guardedFindNearest(consultationsRef(tenantId))assertTenantScopedPath 로 cross-tenant 차단. COSINE, 후보 20 → 자기 자신 제외 → 3중 게이트 → top 4.
  • 노이즈 게이트 (2026-06-22 재보정). prod 측정: text-multilingual-embedding-002 는 같은 언어·격식의 법률 텍스트를 의미 무관해도 cosine 0.77 (음주운전 형사 ↔ 어업손실보상 민사) 로 묶는다 — 다국어 임베딩 anisotropy. 즉 절대 임계값만으로는 신호/노이즈 분리가 불가능. 세 게이트로 방어:
    1. cold-start corpus 가드 (SIMILAR_CONSULTATION_MIN_CORPUS=5) — 임베딩 corpus 가 작으면 배경 분포 추정 불가 → 회상 숨김. 신호 = findNearest 반환 개수 (= min(임베딩 corpus, 후보 20), 추가 쿼리 0).
    2. 도메인 family 게이트 (consultationDomainFamily/isDomainCompatible) — 형사 ↔ 민사처럼 양립 불가한 도메인은 cosine 무관하게 차단. recoveryType (criminal-*) 우선, 부재 시 standardCaseName "형사" 파싱 (사전은 민사 전용).
    3. 상대(마진) 거리 게이트 (passesRecallDistanceGate/medianDistance) — 절대 임계값 대신 배경(후보 거리 중앙값 = 이 corpus 의 anisotropy floor) 보다 RELATIVE_MARGIN(0.08) 이상 가까운 outlier 만 노출. 배경이 0.2 든 0.3 든 게이트가 따라 움직여 모델·corpus baseline 변동에 자동 적응 — 절대 컷의 근본 한계(노이즈 대역 안)를 해소. 보조: 외곽 ceiling MAX_DISTANCE(0.3, 상대 outlier 여도 절대적으로 너무 멀면 탈락) + 절대 근접 floor STANDOUT_DISTANCE(0.1, 거의 동일하면 배경 무관 노출 — 균질하게 매우 유사한 corpus 보호). 상대 게이트는 후보만 추가 제거하므로 새 false-positive 불가 (구 절대컷의 상위집합 아님 — 부분집합).
  • 카드: 상태 배지 (수임/미수임/상담중) + 상담일 + 사건명/유형 + 경위 스니펫 + 유사도 %. 미수임 건은 거절 사유, 수임 건은 사건 링크 — "그때 왜 거절했나 / 무엇이 사건이 됐나" 가 자산 회수의 본체.
  • 결과 0건·cold_start·flag_off·too_short → 패널 숨김, 검색 실패만 명시 표시 (ADR 0029 — '기억 없음' 위장 금지).
  • 후속: 배경 추정을 per-query 중앙값 대신 tenant 레벨 precomputed 통계 (corpus 표본 평균 pairwise cosine, aggregate 스크립트)로 정밀화 — 큰 corpus 의 K-최근접 편향 제거. 텔레메트리 topSimilarity 분포로 RELATIVE_MARGIN·MIN_CORPUS 재조정. 진단 스크립트 scripts/analyze-consultation-recall-calibration.ts (쌍별 거리 행렬 + 텔레메트리).

4. Kill switch — features.ai.consultationEmbedding

  • 단일 플래그가 생성 (Cloud Function) 과 회상 (Server Action) 양쪽 gate. functions 는 명시 true 만 활성 (tenantEmbedding 시맨틱), web 도 동일.
  • seed: scripts/seed-consultation-embedding-flag.ts (--dry-run 지원, dot-path update 로 sibling 보존). .default() 금지 규약 준수.

5. 인덱스 · 텔레메트리

  • vector index: consultations embedding.vector 단독 (firestore.indexes.json). pre-filter 없음 → composite 불필요 (ADR 0041 의 sourceType composite 는 다중 sourceType 균형 fetch 용도 — 상담은 단일 유형).
  • 텔레메트리 tenants/{tid}/consultationRecallEvents/*: shown (hitCount · topSimilarity · elapsedMs) / clicked (rankIndex · converted). writer 가 서버에서 tenantId·uid 덮어쓰기 (relatedMemoriesEvents 패턴).

비목표

  • legacyDocuments 와의 결합 검색 (수임 전환 위저드의 findSimilarMemoriesForPrefillAction 이 이미 담당 — 표면 분리 유지)
  • LLM rerank · multi-query 확장 (ADR 0024 경로 — 상담 corpus 규모가 정당화할 때)
  • 상담 목록/상세에서의 회상 (등록 폼이 가치 최대 지점 — 후속 검토)

구현 위치

  • functions: embed-consultation.ts · consultation-embed-decision.ts · consultation-embed-input.ts · app-metadata-cache.ts (flag 헬퍼)
  • web: consultations/_lib/consultation-embed-input.ts (미러) · _lib/similar-consultations-impl.ts (DI 순수) · _lib/consultation-recall-telemetry.ts · _actions/find-similar-consultations-action.ts · _actions/write-consultation-recall-event-action.ts · _components/SimilarConsultationsPanel.tsx
  • 백필: scripts/backfill-consultation-embeddings.ts (--only-missing 기본 / --only-failed / --stale, --dry-run)

운영 절차 (순서 고정)

  1. firebase deploy --only functions:onConsultationWrittenEmbed (수동 관행)
  2. vector index 생성 (gcloud firestore indexes composite create — indexes.json 동기)
  3. seed 스크립트로 features.ai.consultationEmbedding=true 주입
  4. backfill-consultation-embeddings.ts --dry-run → 실행 (기존 상담 소급)

— 1 전에 3 을 먼저 켜도 안전 (트리거 미배포면 생성만 안 됨, 회상은 빈 결과). 4 는 1·2·3 완료 후.

변경 이력

  • 1.0 (2026-06-11): 최초 작성 — 파이프라인 + 회상 + 플래그 + 백필.
  • 1.1 (2026-06-22): 노이즈 게이트 재보정. prod 측정으로 무관 쌍(음주운전 형사 ↔ 어업손실보상 민사)이 cosine 0.77 임을 확인 — 절대 임계값(0.45/55%)이 노이즈 대역 안이라 false-positive 노출. cold-start corpus 가드(MIN_CORPUS=5)
    • 형사↔민사 도메인 family 게이트 추가, MAX_DISTANCE 0.45→0.3. 진단 스크립트 analyze-consultation-recall-calibration.ts 추가.
  • 1.2 (2026-06-22): 상대(마진) 거리 게이트로 anisotropy 근본 해소. 절대 거리 컷을 배경(후보 거리 중앙값) 대비 상대 판정(passesRecallDistanceGate)으로 대체 — 배경보다 RELATIVE_MARGIN(0.08) 이상 가까운 outlier 만 노출, baseline cosine 변동에 자동 적응. ceiling(MAX_DISTANCE)·STANDOUT floor 보조. 배경 표본 확보 위해 후보 폭 8→20. 상대 게이트는 부분집합 필터라 false-positive 추가 불가.