ADR 0055: 상담 기억 회상
배경
ADR 0049 는 상담을 일급 모델로 만들며 "미수임 상담도 사무소 자산" 이라 선언했지만, 그 자산이 실제로 회수되는 경로가 없다:
- 이해충돌 검사 (P0-4) 는 당사자 이름·연락처의 exact-match 대조뿐 — "비슷한 사건 경위의 과거 상담" 은 어떤 경로로도 다시 떠오르지 않는다.
- 변호사가 새 상담을 받을 때 가장 가치 있는 질문 — "우리 사무소가 이런 상담을 받은 적 있나? 그때 수임했나? 왜 거절했나?" — 에 제품이 침묵.
- 한 줄 본질 ("자기 데이터로 자기 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) →meanPoolVectors→FieldValue.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]토큰 (casesmaskPII와 동일 정규식 계열) →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. 즉 절대
임계값만으로는 신호/노이즈 분리가 불가능. 세 게이트로 방어:
- cold-start corpus 가드 (
SIMILAR_CONSULTATION_MIN_CORPUS=5) — 임베딩 corpus 가 작으면 배경 분포 추정 불가 → 회상 숨김. 신호 = findNearest 반환 개수 (= min(임베딩 corpus, 후보 20), 추가 쿼리 0). - 도메인 family 게이트 (
consultationDomainFamily/isDomainCompatible) — 형사 ↔ 민사처럼 양립 불가한 도메인은 cosine 무관하게 차단. recoveryType (criminal-*) 우선, 부재 시 standardCaseName "형사" 파싱 (사전은 민사 전용). - 상대(마진) 거리 게이트 (
passesRecallDistanceGate/medianDistance) — 절대 임계값 대신 배경(후보 거리 중앙값 = 이 corpus 의 anisotropy floor) 보다RELATIVE_MARGIN(0.08) 이상 가까운 outlier 만 노출. 배경이 0.2 든 0.3 든 게이트가 따라 움직여 모델·corpus baseline 변동에 자동 적응 — 절대 컷의 근본 한계(노이즈 대역 안)를 해소. 보조: 외곽 ceilingMAX_DISTANCE(0.3, 상대 outlier 여도 절대적으로 너무 멀면 탈락) + 절대 근접 floorSTANDOUT_DISTANCE(0.1, 거의 동일하면 배경 무관 노출 — 균질하게 매우 유사한 corpus 보호). 상대 게이트는 후보만 추가 제거하므로 새 false-positive 불가 (구 절대컷의 상위집합 아님 — 부분집합).
- cold-start corpus 가드 (
- 카드: 상태 배지 (수임/미수임/상담중) + 상담일 + 사건명/유형 + 경위 스니펫 + 유사도 %. 미수임 건은 거절 사유, 수임 건은 사건 링크 — "그때 왜 거절했나 / 무엇이 사건이 됐나" 가 자산 회수의 본체.
- 결과 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:
consultationsembedding.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)
운영 절차 (순서 고정)
firebase deploy --only functions:onConsultationWrittenEmbed(수동 관행)- vector index 생성 (gcloud firestore indexes composite create — indexes.json 동기)
- seed 스크립트로
features.ai.consultationEmbedding=true주입 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추가.
- 형사↔민사 도메인 family 게이트 추가, MAX_DISTANCE 0.45→0.3. 진단 스크립트
- 1.2 (2026-06-22): 상대(마진) 거리 게이트로 anisotropy 근본 해소. 절대 거리 컷을
배경(후보 거리 중앙값) 대비 상대 판정(
passesRecallDistanceGate)으로 대체 — 배경보다RELATIVE_MARGIN(0.08) 이상 가까운 outlier 만 노출, baseline cosine 변동에 자동 적응. ceiling(MAX_DISTANCE)·STANDOUT floor 보조. 배경 표본 확보 위해 후보 폭 8→20. 상대 게이트는 부분집합 필터라 false-positive 추가 불가.