# InsuWiki - 하이브리드 RAG 아키텍처 구현 스펙 (대안 F + 질의 라우팅)

> **이 문서의 목적**: Claude Code가 InsuWiki 프로젝트에 RAG(검색 증강 생성) 기능을 구현하기 위한 완전한 기술 명세서입니다. 모든 아키텍처 결정 사항은 확정되었으며, 이 문서를 기준으로 코드를 작성합니다.

---

## 1. 프로젝트 컨텍스트

- **프레임워크**: Next.js (Vercel 호스팅)
- **데이터베이스**: Firebase Firestore (Blaze 플랜 필수 - Cloud Functions 외부 API 호출을 위해)
- **원본 파일 저장소**: Google Drive
- **AI 엔진**: Google Gemini 1.5 Pro API
- **주요 도메인**: 보험 약관, 소식지, 보험료 테이블, 위키 본문

---

## 2. 데이터 유형별 아키텍처 (핵심)

데이터를 **3가지 유형**으로 분리하여 각각 다른 방식으로 처리합니다. 이 분리가 전체 설계의 핵심입니다.

### 2-1. 위키 본문 (마크다운 텍스트)
```
저장: Firestore 기존 컬렉션
검색: Dify.ai 클라우드 또는 Firestore Vector Search
방식: 의미 기반 RAG (Semantic Search)
비용: 사실상 $0 (Firestore 무료 티어 범위 내)
```

### 2-2. 보험료 / 해지환급금 테이블
```
저장: Firestore 구조화 컬렉션 (insurance_tables)
검색: 조건 쿼리 (나이/성별/납입기간 등 정확한 필터링)
방식: NoSQL 키-값 조건 쿼리 (RAG 사용 금지 - 1원 오차도 허용 불가)
비용: $0

⚠️ Phase 0 PoC 선행 필수 - 코드 작성 전 반드시 검증
```

### 2-3. 약관 PDF (수백~수천 페이지)
```
저장: Google Drive (원본 보관)
검색: 메타데이터 인덱스로 파일 선별 → Gemini 롱 컨텍스트 직접 처리
방식: 비동기 Cloud Functions + Gemini File API + Firestore 캐싱
비용: Gemini 1.5 Pro 무료 티어 (128K 토큰 이하 시 $0, 1일 50회 한도)
```

---

## 3. 질의 유형 분류기 (Query Router) - 핵심 추가 레이어

> **이 레이어가 없으면 설계사의 실제 업무 패턴을 커버할 수 없습니다.**
> 설계사 질의는 크게 3가지 유형으로 나뉘며, 각각 완전히 다른 처리 방식이 필요합니다.

### 3-1. 질의 유형 분류표

| 유형 | 예시 질문 | 처리 방식 | 이유 |
|------|----------|----------|------|
| **A. 수치 조회형** | "40세 남성 삼성생명 종신보험 월 보험료?" | Firestore 구조화 쿼리 | 1원 오차 불허 |
| **B. 횡단 검색형** | "뇌혈관 보장 가장 넓은 회사 찾아줘" | Vector RAG (전체 문서) | 특정 파일 특정 불가 |
| **C. 심층 분석형** | "삼성생명 이 특약 면책기간 정확히?" | Long-Context (단일 PDF) | 원문 정밀 분석 필요 |

### 3-2. 라우팅 흐름도

```
설계사 질문 입력
       ↓
[Query Router - Gemini 분류 or 규칙 기반]
       │
       ├─── 수치 조회형 (A)
       │         → /api/ai/table-query
       │         → Firestore insurance_tables 조건 쿼리
       │         → 즉시 응답 (< 1초)
       │
       ├─── 횡단 검색형 (B)
       │         → /api/ai/vector-search
       │         → Firestore Vector Search or Dify
       │         → 관련 청크 TOP 5 수집 → Gemini 답변 생성
       │         → 출처: "○○사 약관 p.12" 형태로 복수 출처 표시
       │
       └─── 심층 분석형 (C)
                 → /api/ai/deep-query
                 → [UX에서 회사/상품 선택 강제]
                 → Cloud Functions 비동기 처리
                 → 단일 PDF Long-Context 분석
                 → 가장 정밀한 답변, 가장 느림
```

### 3-3. 라우터 구현 방식 (규칙 기반 우선)

Gemini를 한 번 더 호출해서 질의 유형을 분류하면 비용과 지연이 추가됩니다. **규칙 기반 분류를 우선 적용**하고, 판단 불가 시에만 Gemini에 위임합니다.

```typescript
function classifyQuery(question: string, context: QueryContext): QueryType {

  // A. 수치 조회형: 숫자/금액 관련 키워드 + 특정 회사/상품 언급
  const numericKeywords = ["보험료", "월납", "연납", "해지환급금", "환급률", "얼마"];
  if (numericKeywords.some(k => question.includes(k)) && context.companyId) {
    return "TABLE_QUERY"; // A형
  }

  // B. 횡단 검색형: 비교/추천/전체 탐색 키워드
  const crossKeywords = ["가장", "비교", "어느 회사", "추천", "찾아줘", "어디가"];
  if (crossKeywords.some(k => question.includes(k))) {
    return "VECTOR_SEARCH"; // B형
  }

  // C. 심층 분석형: 특정 회사+상품이 이미 선택된 상태의 조항 질의
  if (context.companyId && context.productId) {
    return "DEEP_QUERY"; // C형
  }

  // 판단 불가 → UX에서 사용자에게 유형 선택 유도
  return "AMBIGUOUS";
}
```

### 3-4. AMBIGUOUS 처리 (판단 불가 질문)

분류가 불가할 경우 AI에게 바로 던지지 말고 **UX에서 사용자가 선택**하게 합니다.

```
"질문을 어떻게 도와드릴까요?"
  ○ 보험료/금액 조회  → A형으로 처리
  ○ 여러 회사 비교    → B형으로 처리
  ○ 특정 약관 상세 분석 → C형으로 처리 (회사/상품 선택 필요)
```

---

## 4. 약관 PDF 질의응답 시스템 - 상세 구현 스펙 (C형 Deep Query)

### 4-1. 전체 흐름도

```
[사용자]
  │ ① 회사명/상품명 선택 (UX에서 먼저 특정)
  │ ② 질문 입력
  ▼
[Next.js API Route /api/ai/query]
  │ ③ jobId 생성 → Firestore jobs/{jobId} = {status: "pending"} 저장
  │ ④ Cloud Function 트리거 (비동기, 즉시 리턴)
  │ ⑤ jobId 프론트에 반환 (1초 이내, Vercel 타임아웃 회피)
  ▼
[프론트엔드]
  │ ⑥ 3~5초 간격 Polling → GET /api/ai/status?jobId=xxx
  │ ⑦ 최대 대기 3분 타이머 → 초과 시 로컬 failed 처리
  ▼
[Firebase Cloud Functions - 백그라운드]
  │ ⑧ Firestore cache 조회: gemini_file_cache/{fileKey} 존재 & 유효?
  │    ├── 캐시 HIT → file_id 재사용 (업로드 생략)
  │    └── 캐시 MISS → Google Drive에서 PDF 다운로드 → Gemini File API 업로드
  │ ⑨ file_id + 질문 → Gemini 1.5 Pro 롱 컨텍스트 추론
  │ ⑩ Firestore jobs/{jobId} = {status: "complete", answer: "...", citations: [...]} 저장
  │    (또는 에러 시 → {status: "failed", error: "..."})
  ▼
[프론트엔드]
  └ ⑪ Polling에서 complete 감지 → 답변 UI 렌더링
```

### 4-2. Firestore 컬렉션 스키마

#### `jobs` 컬렉션
```typescript
interface Job {
  jobId: string;
  status: "pending" | "complete" | "failed";
  question: string;
  companyId: string;      // 약관 식별자
  productId: string;      // 상품 식별자
  answer?: string;
  citations?: string[];
  error?: string;
  createdAt: Timestamp;
  // TTL: 생성 후 10분 자동 삭제 (Firestore TTL 설정)
  expireAt: Timestamp;    // createdAt + 10분
}
```

#### `gemini_file_cache` 컬렉션
```typescript
interface FileCache {
  fileKey: string;        // "{companyId}_{productId}_{fileHash}" 형태
  fileId: string;         // Gemini File API가 반환한 file_id
  driveFileId: string;    // Google Drive 원본 파일 ID
  driveModifiedAt: Timestamp; // Drive 파일 최종 수정 시각
  uploadedAt: Timestamp;
  expireAt: Timestamp;    // uploadedAt + 48시간 (Gemini File API TTL)
}
```

#### `insurance_metadata` 컬렉션 (파일 선별용 인덱스)
```typescript
interface InsuranceMetadata {
  companyId: string;      // "samsung_life", "hyundai_marine" 등
  companyName: string;    // "삼성생명"
  productId: string;
  productName: string;    // "삼성생명 종신보험 플러스"
  category: "life" | "non_life" | "variable"; // 생보/손보/변액
  driveFileId: string;    // Google Drive 파일 ID
  driveFileName: string;
  pageCount?: number;
  updatedAt: Timestamp;
}
```

#### `insurance_tables` 컬렉션 (보험료 테이블)
```typescript
interface InsuranceTable {
  companyId: string;
  productId: string;
  age: number;
  gender: "M" | "F";
  smokingStatus: "smoker" | "non_smoker";
  paymentPeriod: number;  // 납입기간 (년)
  coverageAmount: number; // 가입금액 (원)
  premium: number;        // 보험료 (원, 1원 단위 정확)
  tableType: "premium" | "surrender_value"; // 보험료 or 해지환급금
  effectiveDate: string;  // "2024-05" 형식
  sourceNotes?: string;   // 조건부 주석 원문
}
```

#### `insurance_chunks` 컬렉션 (B형 횡단 검색용 Vector RAG)
```typescript
interface InsuranceChunk {
  chunkId: string;
  companyId: string;
  companyName: string;
  productId: string;
  productName: string;
  category: "life" | "non_life" | "variable";
  pageNumber: number;
  chunkText: string;          // 청크 원문 텍스트
  embedding: number[];        // Gemini Text Embedding (768차원)
  driveFileId: string;        // 원본 파일 역추적용
  effectiveDate: string;      // "2024-05"
  createdAt: Timestamp;
}
// Firestore Vector Index: embedding 필드에 설정
// 청크 단위: 약 500~800자 (단락 기준, 표 제외)
// ⚠️ 보험료/해지환급금 표 내용은 이 컬렉션에 넣지 않음 (insurance_tables로 분리)
```

---

## 5. Cloud Functions 구현 스펙

### 5-1. 함수 설정
```typescript
// Firebase Cloud Functions 2세대 사용 (타임아웃 최대 9분)
export const processInsuranceQuery = onDocumentCreated(
  { document: "jobs/{jobId}", timeoutSeconds: 540, memory: "1GiB" },
  async (event) => { ... }
);
```

### 5-2. 캐시 무효화 로직
```
트리거: Google Drive 파일 수정 이벤트 (Webhook 또는 동기화 스크립트)
동작: 수정된 driveFileId와 매칭되는 gemini_file_cache 문서 즉시 삭제
결과: 다음 질문 시 신규 PDF 강제 업로드 → 항상 최신 약관 기준 답변
```

### 5-3. 에러 처리 규칙
```typescript
// Cloud Function 내 반드시 구현
try {
  // ... 처리 로직
  await updateJob(jobId, { status: "complete", answer: result });
} catch (error) {
  // 어떤 에러든 반드시 failed 기록 (좀비 pending 방지)
  await updateJob(jobId, {
    status: "failed",
    error: error.message,
    stackTrace: error.stack
  });
}
```

---

## 6. 프론트엔드 구현 스펙

### 6-1. 약관 질의 UX 흐름
```
① 검색 모달 or 문서 사이드바에서 "약관 AI 질문" 모드 진입
② [필수] 회사명 선택 드롭다운 (insurance_metadata 컬렉션에서 로드)
③ [필수] 상품명 선택 드롭다운 (회사 선택 후 필터링)
④ 질문 텍스트 입력
⑤ 제출 → jobId 수신 → Polling 시작
⑥ 로딩 UI (Skeleton + "약관을 분석 중입니다..." 안내)
⑦ 완료 → 답변 + 출처 배지 렌더링
```

### 6-2. Polling 구현
```typescript
const MAX_WAIT_MS = 3 * 60 * 1000; // 3분
const POLL_INTERVAL_MS = 4000;      // 4초

async function pollJobStatus(jobId: string) {
  const startTime = Date.now();

  const interval = setInterval(async () => {
    // 3분 초과 시 로컬 강제 종료
    if (Date.now() - startTime > MAX_WAIT_MS) {
      clearInterval(interval);
      setStatus("failed");
      showErrorModal("서버 응답 지연. 잠시 후 다시 시도해 주세요.");
      return;
    }

    const res = await fetch(`/api/ai/status?jobId=${jobId}`);
    const job = await res.json();

    if (job.status === "complete") {
      clearInterval(interval);
      renderAnswer(job.answer, job.citations);
    } else if (job.status === "failed") {
      clearInterval(interval);
      showErrorModal(job.error || "처리 중 오류가 발생했습니다.");
    }
    // "pending"이면 계속 대기
  }, POLL_INTERVAL_MS);

  // 컴포넌트 언마운트 시 정리
  return () => clearInterval(interval);
}
```

### 6-3. 필수 UI 요소
- Skeleton UI (답변 대기 중)
- "최근 편집된 문서는 AI에 반영되기까지 최대 48시간 소요될 수 있습니다" 안내 문구
- 출처(Citation) 배지 - 클릭 시 해당 약관 원문으로 이동
- 에러 모달 (실패 상태 처리)

---

## 7. Phase 0: 보험료 테이블 파싱 PoC (개발 착수 전 필수)

> **⚠️ 이 단계가 완료되기 전까지 `insurance_tables` 관련 코드를 일절 작성하지 않습니다.**

### 7-1. 테스트 샘플 (3가지 유형 교차 검증)
| # | 유형 | 선정 기준 |
|---|------|-----------|
| 1 | **생명보험사 표준 표** | 가로/세로 복합 축, 일반적인 종신/정기보험 (예: 삼성생명) |
| 2 | **손해보험사 복합 표** | 실비+운전자 특약 혼재, 다중 보장 믹스 (예: 현대해상) |
| 3 | **최극악 난이도** | 변액/CI/유니버셜, 투자수익률/납입면제 조건부 주석 덕지덕지 |

### 7-2. 통과 기준 (Pass Criteria)
```
✅ 합격: 3개 샘플 모두 수치 오류율 0% (금액 1원, 나이 1살 오차 없음)
❌ 불합격: 단 하나라도 환각(Hallucination) 또는 수치 오차 발생
```

### 7-3. Plan B (불합격 시 즉시 전환)
```
Gemini Vision 자동 파싱 포기
→ Google Sheets를 입력 인터페이스로 사용
→ Google Sheets API로 데이터 읽기
→ Firestore insurance_tables 컬렉션에 동기화
→ 수동 정확도 100% 보장
```

### 7-4. PoC 테스트 프롬프트 (Gemini Vision API)
```
아래 보험료 표 이미지를 분석하여 모든 데이터를 다음 JSON 형식으로 추출해 주세요.
단 하나의 수치도 임의로 추정하거나 생략하지 마세요.

{
  "tableType": "premium | surrender_value",
  "headers": { "row": [...], "col": [...] },
  "data": [
    { "age": 40, "gender": "M", "smokingStatus": "non_smoker",
      "paymentPeriod": 20, "coverageAmount": 100000000, "premium": 234560 }
  ],
  "conditions": "조건부 주석 원문 그대로",
  "confidence": "high | medium | low"
}
```

---

## 8. 비용 구조 요약

| 항목 | 플랜 | 예상 월 비용 |
|------|------|-------------|
| Firebase Firestore | Blaze (종량제, 무료 구간 내) | ~$0 |
| Firebase Cloud Functions | Blaze (200만 회 무료) | ~$0 |
| Google Drive | 개인 100GB 플랜 | ~₩2,000 |
| Gemini Embedding API | 초기 인덱싱 시 1회성 비용 | ~$1~5 (수천 문서 기준) |
| Gemini 1.5 Pro API | 무료 티어 (1일 50회, 128K 이하) | ~$0 |
| Gemini 1.5 Pro API (초과 시) | 종량제 $3.5/1M tokens | 사용량 따라 |
| Dify.ai (위키 RAG) | Sandbox 무료 or $19/월 | $0~$19 |

**예상 총 비용: 월 ₩2,000~₩25,000 (사용량에 따라)**

---

## 9. 환경 변수 목록

```env
# Google
GOOGLE_SERVICE_ACCOUNT_KEY=...    # Cloud Functions용 서비스 계정 JSON
GOOGLE_DRIVE_FOLDER_ID=...        # 약관 PDF 저장 폴더
GEMINI_API_KEY=...                # Gemini API Key (서버 전용, 클라이언트 노출 금지)

# Firebase
FIREBASE_PROJECT_ID=...
FIREBASE_CLIENT_EMAIL=...
FIREBASE_PRIVATE_KEY=...

# Dify (위키 RAG용)
DIFY_API_KEY=...
DIFY_BASE_URL=...
```

---

## 10. 개발 순서 (권장)

```
Step 1: Phase 0 PoC 실행
  └ Gemini Vision으로 샘플 3종 파싱 테스트
  └ 합격 or Plan B 결정

Step 2: Firestore 컬렉션 + TTL 설정
  └ jobs, gemini_file_cache, insurance_metadata,
    insurance_chunks, insurance_tables 컬렉션 생성
  └ jobs TTL 10분 설정 (Firestore Console)
  └ insurance_chunks.embedding 벡터 인덱스 설정

Step 3: Query Router 구현
  └ /api/ai/query → classifyQuery() → 유형별 라우팅
  └ AMBIGUOUS 처리 UX

Step 4: Cloud Functions 구현 (C형 Deep Query)
  └ processInsuranceQuery 함수
  └ 캐시 조회 → Drive 다운로드 → Gemini 업로드 → 추론 → 결과 저장
  └ 에러 시 반드시 failed 상태 기록

Step 5: Vector 인덱싱 파이프라인 구현 (B형 횡단 검색)
  └ PDF → 청크 분할 → Gemini Embedding → insurance_chunks 저장
  └ 신규 파일 감지 시 자동 인덱싱 (Cloud Functions 트리거)
  └ 표(Table) 내용은 인덱싱에서 제외, insurance_tables로 분리

Step 6: Next.js API Routes 구현
  └ POST /api/ai/query  → Router → 유형별 처리
  └ GET  /api/ai/status → job 상태 조회 (C형용)
  └ POST /api/ai/vector-search → B형 처리

Step 7: 프론트엔드 구현
  └ 질의 유형 선택 UX (또는 AMBIGUOUS 처리)
  └ A형: 즉시 응답 UI
  └ B형: 복수 출처 카드 UI
  └ C형: 회사/상품 선택 → Polling (4초 간격, 3분 타임아웃)

Step 8: 캐시 무효화 연동
  └ Drive 파일 수정 감지 → gemini_file_cache 삭제 + 해당 청크 재인덱싱

Step 9: insurance_tables 구현
  └ Phase 0 결과에 따라: Gemini Vision ETL 또는 Sheets 동기화
```

---

## 11. 금지 사항 (절대 하지 말 것)

```
❌ Gemini API Key를 프론트엔드 코드에 포함시키는 것
❌ 브라우저에서 Gemini File API를 직접 호출하는 것
❌ onSnapshot 실시간 구독으로 job 상태를 감시하는 것 (Polling으로 대체)
❌ 보험료/해지환급금 수치를 RAG로 답변하는 것 (구조화 쿼리만 허용)
❌ 보험료/해지환급금 표 내용을 insurance_chunks에 넣는 것
❌ 질의 유형 분류 없이 모든 질문을 Long-Context로 처리하는 것
❌ Phase 0 PoC 완료 전 insurance_tables 코드 작성
❌ Public 위키와 Private 노트를 동일한 RAG 파이프라인에 넣는 것
❌ Vercel 서버에서 대용량 PDF를 직접 스트리밍 처리하는 것
```
