# task-2989 — 아누 시스템 공용 임베딩 서비스 신설 + 인슈위키 벡터검색 복구

- **작업 ID**: task-2989 · **팀**: dev4-team (비슈누) · **레벨**: Lv.3
- **작성**: 2026-08-20
- **대상 저장소**: `/home/jay/workspace` (신규 서비스) + `/home/jay/projects/insuwiki` (배선·재색인)
- **판정**: **완료** — 핵심 완료조건(유사도 검색 hits > 0) 실증 달성
- **부분 미달 1건**: 검증 2항 "실제 영상 1건 전 구간 처리"는 **요약 단계의 Gemini 키가 폐기**되어 구조적으로 불가. 임베딩·저장·검색 구간은 실서비스로 전량 실증. 상세는 아래 L1 절.

---

## S — 상황

인슈위키 벡터 검색은 한 번도 작동한 적이 없었다. 생산자는 3072차원을 넣고, 인덱스 선언은 768이며, 소비자는 768로 질의했다. `GEMINI_API_KEY` 마저 폐기되어 임베딩 생성 자체가 멈춰 있었다. 회장 결정은 임베딩을 **아누 시스템의 공용 인프라**로 옮기되, InsuRo 의 `server/anu_provider.py`(유료 과금 커넥터)는 건드리지 않는 것이었다.

## C — 갈등

임베딩은 **생산자와 소비자가 같은 벡터공간**일 때만 의미가 있다. 그런데 소비자(`nextapp/**`)는 본 태스크 허용 경로 밖이다. 게다가 클라우드 함수는 GCP 에서 실행되어 이 서버의 로컬 서비스에 접근할 수 없다. 즉 "파이프라인만 고치면 된다"는 접근으로는 완료조건인 **검색이 실제로 되는 상태**를 증명할 수 없는 구조였다.

## Q — 질문

외부 키 0으로 768차원 임베딩을 공급하면서, 기존 303건을 살리고, **유사도 검색이 실제로 hits > 0** 을 반환하게 만들 수 있는가.

## A — 답변

가능했고, 달성했다. 다만 **차원 불일치는 결함의 절반에 불과했다.** 재색인으로 303건을 전부 768차원으로 맞춘 뒤에도 검색은 `hits = 0` 이었다. 실측으로 밝힌 진짜 원인은 **벡터가 Firestore 네이티브 Vector 타입이 아니라 일반 배열로 저장**되어 있었다는 것이다. `find_nearest` 는 네이티브 Vector 필드만 매칭한다. 3072 시절에는 "2048 초과" 400 에러가 이 두 번째 결함을 가리고 있었다.

---

## ★ 근본원인 — 결함은 하나가 아니라 셋이었다

| # | 결함 | 발견 경로 | 상태 |
|---|---|---|---|
| 1 | 차원 불일치 (생산 3072 vs 인덱스·소비 768) | task-2987 사전조사 | 해소 — 전건 768 |
| 2 | **벡터가 일반 배열로 저장 — `find_nearest` 원천 미매칭** | **본 태스크 팀장 실측 (신규 발견)** | 해소 — 전건 네이티브 Vector |
| 3 | 재색인 대상 선별이 **차원만** 보고 타입을 안 봄 → 재실행해도 전건 skip | 팀원 하누만 (지시 밖 자체 발견) | 해소 — `is_search_ready()` 로 교체 |

결함 2의 증명 (값은 그대로 두고 **타입만** 바꿔 대조):

```
저장 타입 = list  →  find_nearest HITS = 0
같은 값을 Vector(...) 로만 재기록 (값 변경 0)  →  find_nearest HITS = 1
```

계수 로직이 이 결함을 못 잡은 이유도 실측됐다. `Vector` 는 `list` 의 하위 타입이 아니라 `Sequence` 라, `isinstance(x, list)` 기반 통계가 **정상 문서를 미상으로, 결함 문서를 정상으로** 뒤집어 계수하고 있었다.

---

## 설계 결정과 근거

**1순위 방안(로컬 파이썬 파이프라인 일원화)을 채택**했다. task md 가 이미 1순위로 지정한 경로이므로 별도 설계 승인 절차 없이 진행했다. 다른 설계를 제안하지 않았다.

- **위치**: `/home/jay/workspace/services/embedding/` — 기존 `services/whisper-gpu` 선례와 동일 계층
- **모델**: `jhgan/ko-sroberta-multitask` · CPU · 768차원 · **외부 API 키 0**
  - GPU 미사용은 선택이 아니라 제약이다. 이 머신 GPU 는 sm_61 이고 설치된 torch 는 sm_70+ 만 지원한다(task-2987 실측).
- **호출 방식**: **HTTP 서비스** (파이썬 모듈 아님). 근거 3가지 —
  1. 소비자가 **프로세스 경계를 넘는다**: 인슈위키 파이프라인(별도 저장소·별도 venv), 재색인 스크립트, 향후 InsuRo. 모듈이면 저장소 간 import 결합이 생긴다.
  2. task md 요구인 "매 호출마다 모델을 로드하지 않을 것"을 **프로세스 수명과 무관하게** 만족한다. oneshot 파이프라인이 모듈을 import 하면 회차마다 재로딩된다.
  3. 사내 선례(`whisper-gpu`, 포트 8200)와 동일 패턴이라 운영 방식이 이미 검증돼 있다.
- **바인드**: `127.0.0.1:8300` **루프백 전용**. 초안은 `0.0.0.0` 이었으나 팀장 검증에서 적발해 교정했다. 인증이 없는 서비스이고 소비자가 전부 로컬이므로 노출 이유가 0이다. Tailscale 주소 프로브가 connection refused 로 전환됨을 실측했다.

---

## 수정 파일별 검증 상태

| 파일 | 변경 요지 | 검증 방법 | 상태 |
|---|---|---|---|
| /home/jay/workspace/services/embedding/server.py | FastAPI 임베딩 서비스. startup 1회 로드, 768 fail-closed | 실기동 + curl 실호출 + pytest 25건 | verified |
| /home/jay/workspace/services/embedding/client.py | 얇은 클라이언트. 미기동 시 명시적 예외(폴백 없음) | pytest + 연결거부 실측 | verified |
| /home/jay/workspace/services/embedding/settings.py | 설정 단일소스. 기본 바인드 127.0.0.1 | ss 실측 + env override 확인 | verified |
| /home/jay/workspace/services/embedding/start-embedding.sh | 기동 스크립트. settings 에서 host/port 취득 | 실기동 | verified |
| /home/jay/workspace/services/embedding/anu-embedding.service | systemd user 유닛 | 설치 후 is-active/is-enabled 실측 | verified |
| /home/jay/workspace/services/embedding/test_server.py | 서비스 테스트 25건 | pytest 25 passed | verified |
| /home/jay/.worktrees/task-2989-dev4/scripts/youtube-pipeline/youtube_pipeline/summarizer.py | generate_embedding 을 아누 서비스 HTTP 호출로 교체. 차원 fail-closed | 실서비스 실호출 len=768 + 뮤테이션 검증 | verified |
| /home/jay/.worktrees/task-2989-dev4/scripts/youtube-pipeline/youtube_pipeline/config.py | ANU_EMBEDDING_URL·EMBEDDING_DIMENSION 도입, 임베딩용 Gemini 설정 제거 | grep + pytest | verified |
| /home/jay/.worktrees/task-2989-dev4/scripts/youtube-pipeline/youtube_pipeline/firestore_writer.py | 임베딩을 네이티브 Vector 로 저장 (벡터 쓰기 2지점 전수) | 저장 후 타입 실측 Vector + 검색 실증 | verified |
| /home/jay/.worktrees/task-2989-dev4/scripts/youtube-pipeline/tests/test_firestore_writer.py | Vector 타입·인코딩 대조 테스트 20건 | pytest + 뮤테이션 7 failed | verified |
| /home/jay/.worktrees/task-2989-dev4/scripts/youtube-pipeline/tests/test_summarizer.py | 임베딩 배선 테스트 21건 | pytest + 뮤테이션 7 failed | verified |
| /home/jay/.worktrees/task-2989-dev4/functions/src/crawlYoutubeChannels.ts | 임베딩 생성 중단(3072 오염원 차단). 수집·저장 유지 | npx tsc --noEmit 해당파일 오류 0 | verified |
| /home/jay/.worktrees/task-2989-dev4/scripts/reindex/reindex_embeddings.py | 재색인 CLI. dry-run 기본, 체크포인트 재개, Vector 쓰기 | dry-run + apply 실행 303건 | verified |
| /home/jay/.worktrees/task-2989-dev4/scripts/reindex/checkpoint.py | schema_version 기반 체크포인트 자동 무효화 | pytest + 재개 실측 | verified |
| /home/jay/.worktrees/task-2989-dev4/scripts/reindex/tests/test_reindex.py | 재색인 테스트 37건 | pytest 37 passed | verified |
| /home/jay/.worktrees/task-2989-dev4/.gitignore | __pycache__/ 및 *.pyc 추가 | 추적 .pyc 36 → 0 실측 | verified |

---

## 검증 결과 (task md 6항목 대조)

| # | 요구 | 실측 결과 | 판정 |
|---|---|---|---|
| 1 | 임베딩 서비스 단독 호출 → 768 벡터 | HTTP 200, `actual lengths [768, 768]`, 의미검증 유사 0.919 vs 무관 0.164 | PASS |
| 2 | 파이프라인이 영상 1건 처리 → embedding_len 0 아님 | `save_insurance_chunk` 실호출 → 저장 타입 Vector, embedding_len 768. **단 영상 전 구간은 요약용 Gemini 키 폐기로 불가** | 부분 PASS |
| 3 | **유사도 검색 hits > 0 실증** | 3개 질의 × 2개 컬렉션 **전건 HITS=3**, 의미 정합 확인 | **PASS** |
| 4 | 재색인 전후 건수 대조 · 원문 유실 0 | 151/152 유지, **원문 필드 4,249개 대조 불일치 0건** | PASS |
| 5 | pytest 회귀 유지 | 아래 기준선 절 참조 | PASS |
| 6 | 외부 키 없이 전 과정 동작 | 전 검증을 `env -u GEMINI_API_KEY` 로 수행 | PASS |

### 핵심 완료조건 실증 원문 (검증 3항)

```
[insurance_chunks] q="암 진단비 보장 한도가 얼마인가요" HITS=3
    dist=0.3209 | 입통원 구별없이 암치료당 1천만원씩주는 암치료보험!!
[insurance_chunks] q="실손의료보험 자기부담금" HITS=3
    dist=0.3005 | 10억 비급여 통장! 써도 다시 10억 리세
[insurance_chunks] q="수술비 보험 추천" HITS=3
    dist=0.2176 | 수술비보험 추천할만한 몇군데 소개합니다!! (한도 가성비 굿~)
[youtube_knowledge] 동일 3개 질의 전건 HITS=3
```

건수가 아니라 **의미 정합**까지 확인했다. "수술비 보험 추천" 질의의 1순위가 실제 수술비보험 추천 영상(거리 0.218)이다.

### 재색인 실측

```
youtube_knowledge  전체 152건 | 전: 일반 list 768=152건 → 후: 네이티브 Vector 768=152건 (쓰기 152, 실패 0)
insurance_chunks   전체 151건 | 전: 일반 list 768=150건, Vector=1건 → 후: Vector 768=151건 (쓰기 150, 실패 0)
최종 상태 재조회: insurance_chunks {('Vector',768):151} / youtube_knowledge {('Vector',768):152}
```

`insurance_chunks` 의 Vector 1건은 팀장이 결함 증명용으로 먼저 재기록한 문서이며, 선별 로직이 그 1건만 정확히 skip 했다 — 판정 로직이 양방향으로 옳게 동작함이 프로덕션 데이터로 교차 확인됐다.

### 원문 유실 0건 증명

재색인 **전** 303개 문서의 벡터 제외 전 필드 지문을 스냅샷하고, 2회 재색인 **후** 재조회해 대조했다.

```
insurance_chunks: pre=151 post=151 소실=0
youtube_knowledge: pre=152 post=152 소실=0
원문 필드 4249 개 검사 / 불일치 0
FINAL VERDICT: 원문 유실 0건
```

쓰기는 전부 부분 `update({vector_field: Vector(...)})` 이며 `set()` 호출 0건을 테스트로 강제했다.

### 회귀 기준선 — task md 의 83 과 불일치, 근거와 함께 보고

task md 검증 5항은 기준선을 **83 passed** 로 적었으나 실측과 다르다. **계수 오류가 아니라 트리 차이**다.

| 트리 | `scripts/youtube-pipeline` pytest | 비고 |
|---|---|---|
| master `d5859a9` (본 태스크 base) | **25 passed** | systemd 가 실제로 실행하는 프로덕션 트리 |
| task-2980 브랜치 `a6710f7` (미머지) | 101 passed | task-2987 이 측정한 트리 |
| task md 기재 | 83 | 어느 트리와도 불일치 |

base 를 master 로 택한 근거는 세 가지다 — ① systemd 유닛의 `WorkingDirectory=/home/jay/projects/insuwiki/scripts/youtube-pipeline` ② task md 가 지목한 `summarizer.py:104 generate_embedding()` 이 master 에서 정확히 일치(task-2980 브랜치는 260행) ③ task-2980 브랜치는 미머지 별건 작업(Drive 프록시 등)이라 스택할 대상이 아니다.

**작업 후 실측 (3개 스위트 전부 통과)**

```
scripts/youtube-pipeline   66 passed   (기준선 25 + 신규 41)
scripts/reindex            37 passed   (신규)
services/embedding         25 passed   (신규)
합계                      128 passed, 0 failed
```

테스트가 실제로 결함을 잡는지 **뮤테이션 검증**으로 확인했다. 차원 검사 제거 → 7 failed / `_as_vector_field` 무력화 → 7 failed / `to_native_vector` 제거 → 6 failed. 복원 후 전건 통과.

---

## L1 스모크테스트

- **서버 재시작**: **성공** — `anu-embedding.service` 를 `~/.config/systemd/user/` 에 설치 후 `systemctl --user enable --now` 실행. `is-active=active`, `is-enabled=enabled`. 부팅 후 자동 기동되도록 `default.target.wants` 심볼릭 생성 확인.
- **API 응답 확인** (전부 실호출):
  - `GET /health` → `{"status":"ok","model":"jhgan/ko-sroberta-multitask","dimension":768,"loaded":true,"device":"cpu","load_count":1,...}`
  - `POST /embed` → HTTP 200, `actual lengths [768, 768]`
  - `ss -ltnp` → `LISTEN 127.0.0.1:8300` (루프백 전용 실측). Tailscale `100.76.130.39:8300` 프로브 → connection refused
  - 파이프라인 `generate_embedding()` 실호출(`env -u GEMINI_API_KEY`) → `len = 768`
  - `save_insurance_chunk()` 실호출 → 저장 타입 `Vector`, `embedding_len = 768` → 유사도 검색 `HITS=3` 이며 **해당 프로브 문서가 검색 결과에 포함됨** → 프로브 삭제 및 삭제 확인 완료(프로덕션 잔여물 0)
  - Firestore `find_nearest` 실호출 → 3질의 × 2컬렉션 전건 `HITS=3`
  - Gemini 요약 API 실프로브 → **HTTP 400 `API_KEY_INVALID`** (요약 단계 불가 근거)
- **스크린샷**: 해당없음 — 백엔드/데이터 전용 작업이며 UI 변경 0건.

### 검증 2항이 부분 PASS 인 이유 (숨기지 않고 명시)

영상 1건의 **전 구간** 처리는 `전사 → 요약 → 임베딩 → 저장` 이다. 이 중 **요약이 Gemini 를 쓰고 그 키가 폐기**되어 있다(위 400 실프로브). 키 회전은 task md 가 "별건이니 손대지 말 것"으로 명시한 범위 밖이다. 따라서 영상 전 구간 E2E 는 본 태스크 범위 내에서 **구조적으로 불가능**하다.

대신 도달 가능한 최대치를 실증했다 — 실제 파이프라인 저장 함수(`save_insurance_chunk`)를 실서비스 임베딩과 함께 호출해 Firestore 에 기록하고, 그 문서가 유사도 검색으로 **실제로 꺼내진 것**까지 확인한 뒤 정리했다. 요약 텍스트의 출처만 대체했을 뿐 임베딩·저장·검색 경로는 프로덕션 코드 그대로다.

---

## 3 Step Why

- **1st Why — 왜 재색인을 하고도 검색이 0건이었나?** → **A**: 벡터가 Firestore 네이티브 Vector 타입이 아니라 일반 배열로 저장돼 있었다. `find_nearest` 는 네이티브 Vector 필드만 매칭한다. 값·차원은 전부 옳았고 **타입만** 틀렸다.
- **2nd Why — 왜 그 결함이 그때까지 안 보였나?** → **B**: 3072 시절에는 "Vectors must be at most 2048 dimensions" 400 에러가 먼저 터져 타입 문제까지 도달하지 못했다. 게다가 검증 로직이 `isinstance(x, list)` 기반이라, `Sequence` 인 `Vector` 를 미상으로·일반 배열을 정상으로 **뒤집어 계수**하고 있었다. 통계가 결함을 정상으로 보고하고 있었던 것이다.
- **3rd Why — 왜 "768 float 이 맞는가" 테스트로는 못 잡았나?** → **C**: 그 검증은 길이와 원소 타입만 본다. 이번 결함은 **형식이 완벽히 올바른데 컨테이너 타입만 다른** 경우다. 그래서 값 검사 대신 `encode_value()` 로 실제 전송 표현(`array_value` vs `map_value.__type__=="__vector__"`)을 대조하는 테스트를 넣고, 뮤테이션으로 실패함을 증명했다.

A-B-C 일관성 확인됨.

---

## trip-wire 5종 (실측)

| trip-wire | 실측값 | 근거 |
|---|---|---|
| Critical7 | 0 | red-team scan 전 파일 `risk_level: low, passed: true` |
| PII net-new | 0 | 신규 커밋 7건에 시크릿 0. 커밋된 `.pyc` 0건(36 → 0 untrack). 기존 평문 키는 아래 에스컬레이션 |
| 회귀 실패 | 0 | 128 passed / 0 failed (66 + 37 + 25) |
| forbidden_paths 침범 | 0 | `firestore.indexes.json`·`firestore.rules`·`.github/**`·`nextapp/**`·`/home/jay/projects/InsuRo/**` 전부 무접촉. `git diff --stat` 으로 대조 |
| nonce | task-2989 | 발사 task_id 와 일치 |

---

## ★ 에스컬레이션 (ANU·회장 판단 필요)

### 1. [보안] 평문 API 키 5종이 소스에 남아 있다 — 키 회전 필요

`scripts/youtube-pipeline/youtube_pipeline/config.py` 의 기본값에 평문 시크릿이 있다.

| 항목 | 위치 | 비고 |
|---|---|---|
| Google API 키 | config.py:18 | 값 sha256 앞12자 `a89c386ab5e3` |
| YOUTUBE_API_KEY | config.py:14 | |
| DRIVE_CLIENT_SECRET | config.py:31 | |
| DRIVE_REFRESH_TOKEN | config.py:34 | |
| Firebase 서비스계정 키 경로 | config.py:8 | |

추적 중이던 `.pyc` 안에도 **동일한 값**(같은 sha256 `a89c386ab5e3`)이 컴파일된 채 들어 있었음을 확인했다. 이번에 `.pyc` 36개를 untrack 했으나 **이것으로 노출이 해소되지 않는다** — 소스와 git 히스토리에 값이 그대로 남는다. task md 가 "키 회전은 별건"으로 지정했으므로 손대지 않았다. **회전이 필요하다.**

### 2. [설계] 소비자(nextapp)가 여전히 다른 벡터공간으로 질의한다

`nextapp/src/app/api/ai/vector-search/route.ts:357` 은 질의를 Gemini `text-embedding-004` 로 임베딩한다. 저장 벡터가 ko-sroberta 768 이 된 지금, **차원은 일치하므로 Firestore 는 결과를 반환하지만 의미공간이 달라 결과가 무의미**해진다.

지금 당장은 위험이 발현되지 않는다 — 같은 파일 283행이 `GEMINI_API_KEY` 부재 시 HTTP 500 으로 **먼저 차단**하기 때문이다(키 폐기 상태). 그러나 **누군가 Gemini 키를 복구하는 순간, 조용히 틀린 검색 결과를 내기 시작한다.** 0건을 반환하는 지금보다 위험하다(금소법 리스크).

`nextapp/**` 은 본 태스크 허용 경로 밖이라 손대지 않았다. **소비자측을 아누 임베딩 서비스로 전환하는 후속 태스크가 필요하다.** 그 전까지 Gemini 키를 복구하면 안 된다.

### 3. [잠재] 다른 생산자 `pdfIndexing.ts` 도 일반 배열로 저장한다

`functions/src/pdfIndexing.ts` 역시 벡터를 네이티브 Vector 로 쓰지 않는다. 현재 `insurance_chunks` 는 151건 전부 `sourceType='youtube'` 라 실피해는 없으나, PDF 색인이 가동되면 검색 불가 문서가 다시 유입된다. 허용 경로가 `crawlYoutubeChannels.ts` 한정이라 수정하지 않았다.

참고로 같은 저장소 `functions/src/embeddingMatching.ts:243` 은 `FirestoreFieldValue.vector()` 를 **올바르게** 쓰고 있다. 즉 올바른 패턴이 사내에 이미 있었고 유튜브·PDF 생산자만 따르지 않은 것이다.

### 4. [경미] `embeddingFailed=true` 플래그가 stale 로 남았다

문서 `JRp2zoZAZ9E` 는 재색인으로 정상 768 Vector 를 얻었으나 `embeddingFailed=true` 가 남아 있다. "벡터 필드만 갱신" 지시를 지킨 결과다. 코드 전역 grep 결과 **이 플래그를 읽는 소비자가 0건**이라 기능 영향은 없다. 정리를 원하시면 지시 주시면 반영한다.

### 5. [운영] 클라우드 스케줄은 PAUSED 유지가 전제다

`crawlYoutubeChannels` 는 임베딩 생성을 중단했으므로 재개되면 `embedding: []` 인 문서를 만든다(3072 오염은 없음). 벡터를 채우는 주체는 이제 로컬 파이프라인 한 곳뿐이다.

---

## 산출물

**insuwiki** — 브랜치 `task/task-2989-dev4`, base master `d5859a9`, 커밋 6건

```
aeb1687 하누만: 네이티브 Vector 타입 쓰기 + 타입 검증/테스트/체크포인트 자동 무효화
1da8239 사라스바티: Firestore 임베딩을 네이티브 Vector 로 저장 (hits=0 근본원인 수정)
dbcffeb 하누만: 3072→768 전량 재색인 스크립트 (dry-run 기본, 체크포인트 재개, fail-closed)
8146014 사라스바티: .gitignore __pycache__/*.pyc 추가 + 추적 .pyc 36개 untrack
fb12a9f 사라스바티: crawlYoutubeChannels 임베딩 생성 중단 (3072 오염원 차단)
ca5e418 사라스바티: 임베딩 생성을 아누 로컬 서비스(768차원)로 전환 + fail-closed 검증
```
비-pyc 기준 19 files, +2410 / −77

**workspace** — 브랜치 `task/task-2989-dev4`, base origin/main `9f0ec38e`, 커밋 1건

```
398c4c7a 카르티케야: 아누 공용 임베딩 서비스 신설 (로컬 768차원, 외부 키 0, 루프백 전용)
```
9 files, +950

**운영 반영**: `anu-embedding.service` 설치·enable·기동 완료 (`127.0.0.1:8300`, active/enabled)

---

## 남은 작업

1. 두 브랜치 PR 생성·머지 (ANU 검증 후)
2. 에스컬레이션 1(키 회전) · 2(nextapp 소비자 전환) 후속 태스크 판단
3. 회장 판단: `embeddingFailed` stale 플래그 정리 여부

## 세션 통계
- 총 도구 호출: 0회

