# [Model] 최신 MobileCLIP2-S3 FP16 모델 번들 연동을 위한 앱 구조 수정 필요

## 배경

현재 앱은 Sprint1 기준의 13-class INT8 단일 이미지 인코더 구조에 맞춰져 있다.

하지만 최신 모델 전달 구조는 `mobileclip2_s3_server_full_ce_hardneg_fold3_20260611_214421` 기준이며, 다음과 같이 변경되었다.

- 모델: MobileCLIP2-S3
- class 수: 23개
- precision: FP16 mixed
- image encoder와 text encoder가 분리됨
- `prototype_index.json`은 `prototype`이 아니라 `embedding` 키를 사용함
- 텍스트 검색은 text encoder + tokenizer + text index/catalog 기반으로 확장되어야 함

따라서 최신 모델 파일만 앱 asset에 교체하는 방식으로는 정상 동작을 보장하기 어렵다.

## 관련 고정 계획

- Semantic text search 구현은 별도 고정 계획을 따른다:
  [`semantic-text-search-implementation-plan.md`](semantic-text-search-implementation-plan.md)
- 이 계획은 tokenizer, text index, 전체 query regression set, 테스트 코드, PR 본문, 문서 최신화, 금지 범위를 포함한다.
- 구현 전 계획이 바뀌면 먼저 해당 계획 문서를 업데이트하고 이유를 기록한다.

## 2026-06-13 구현 반영 상태

- `assets/mobile_artifacts_fp16/` 기준 image/text encoder split bundle을 사용한다.
- `tokenizer_bundle.json`, `text_index.json`, `text_search_policy.json`, `text_query_regression_set.json`, `text_search_eval_report.json`을 추가했다.
- 텍스트 검색은 `SQLite LIKE keyword score + text encoder semantic score`를 결합한 `semantic_text_fusion`으로 동작하도록 구현했다.
- tokenizer fixture parity, text index 512d/L2 norm, artifact contract, regression report 검사를 테스트에 추가했다.
- 최신 평가 기록은 [`Semantic Text Search Evaluation`](../experiments/paper/semantic-text-search-evaluation-2026-06-13.html)에서 관리한다.
- Android 실기기 ONNX runtime QA는 아직 별도 후속 작업이다.

## 2026-06-15 App main 기준 검수 결과

검수 기준: `lpcvc-2026-CNU/App@origin/main 598da589`.

main에 반영된 항목:

- PR #11 모델 연동과 PR #12 Android asset cache 수정이 merge되어 있다.
- `assets/mobile_artifacts_fp16/`에는 `manifest.json`, `classes.json`, `prototype_index.json`, `text_index.json`, `tokenizer_bundle.json`, policy/eval JSON 등 16개 metadata/search/tokenizer 파일이 추적된다.
- 대용량 `.onnx`, `.onnx.data` 4개 파일은 Git에 올리지 않는다. 따라서 checkout 직후에는 공유 artifact를 `assets/mobile_artifacts_fp16/`에 직접 배치해야 한다.
- `manifest.json` 기준 모델은 `MobileCLIP2-S3`, `fp16`, `class_count=23`, `embedding_dim=512`, `opset=18`, `ir_version=9`이다.
- `OnnxInferenceService`는 image encoder와 text encoder session을 분리해 로드한다.
- Android `MainActivity.kt`는 `manifest.json`을 읽어 4개 ONNX/external data 파일을 내부 저장소로 복사하고, stream 기반 파일 크기 비교로 stale cache를 갱신한다.
- `SemanticTextSearchService`는 text encoder cosine score와 SQLite keyword score를 결합한 `semantic_text_fusion`으로 동작한다.
- `assets/landmark_info.json`은 26개 표시 항목을 관리하고, 12개 세부 class는 `parent_landmark_id`로 경복궁/창경궁/덕수궁 parent와 연결된다.
- `test/semantic_text_artifact_test.dart`, `test/model_score_contract_test.dart`, `scripts/check_model_contract.py`가 모델 artifact 계약의 최소 검증 역할을 한다.

남은 별도 QA:

- 실제 Android 기기 또는 emulator에서 대용량 ONNX binary를 배치한 뒤 image/text encoder 추론까지 실행하는 end-to-end QA.
- 실제 사용자 query 로그가 쌓인 뒤 `text_search_policy.json` threshold 재보정.

## 최초 확인된 문제

우선순위 기준:

- `P0`: 최신 모델을 앱에서 실행하기 위해 반드시 먼저 해결해야 하는 항목
- `P1`: 실행은 되더라도 결과 품질, 상세 연결, 사용자 이해에 직접 영향을 주는 항목
- `P2`: 운영, 디버깅, 유지보수 품질을 높이는 항목

### 1. [P0] ONNX 모델 경로가 Sprint1 단일 이미지 인코더에 고정되어 있었음

이슈 작성 당시 코드는 아래 파일만 로드했다.

- `assets/mobile_artifacts_int8/landmark_encoder.onnx`
- `assets/mobile_artifacts_int8/landmark_encoder.onnx.data`

관련 위치:

- `lib/services/onnx_inference_service.dart`
- `android/app/src/main/kotlin/com/example/landmark_demo_app/MainActivity.kt`

최신 모델은 다음처럼 image/text encoder가 분리되어 있다.

- image encoder ONNX
- image encoder external data
- text encoder ONNX
- text encoder external data
- `manifest.json`
- `preprocessing.json`
- `tokenizer.json`
- `prototype_index.json`

따라서 `manifest.json` 기반으로 모델 파일명을 읽고, image/text session을 분리해서 로드해야 한다.

### 2. [P0] asset/catalog가 13-class 기준이었음

이슈 작성 당시 앱의 `landmark_info.json`, `labels_master.json`, `prototype_index.json`은 13개 class 기준이었다.

최신 모델은 23개 class를 사용한다.

예:

- `changgyeonggung_myeongjeongjeon`
- `changgyeonggung_myeongjeongmun`
- `changgyeonggung_octagonal_seven_story_stone_pagoda`
- `changgyeonggung_okcheongyo`
- `changgyeonggung_tongmyeongjeon`
- `deoksugung_daehanmun`
- `deoksugung_hamnyeongjeon`
- `deoksugung_junghwajeon`
- `deoksugung_junghwamun`
- `gyeongbokgung_geunjeongjeon`
- `n_seoul_tower`
- `tapgol_park_palgakjeong`

따라서 앱의 상세 정보 DB, hero image, class metadata도 23-class 기준으로 갱신해야 한다.

### 3. [P0] `prototype_index.json` 스키마가 최신 모델과 맞지 않음

현재 앱은 `prototype_index.json`에서 다음 키를 읽는다.

```dart
item['prototype']
```

하지만 최신 artifact는 다음 키를 사용한다.

```json
"embedding": [...]
```

따라서 최신 artifact를 그대로 넣으면 prototype 로딩에서 오류가 날 수 있다.

수정 방향:

- `embedding` 키를 우선 읽기
- 필요하면 legacy 호환을 위해 `prototype`도 fallback으로 지원

### 4. [P1] 텍스트 검색이 모델 기반이 아니었음

이슈 작성 당시 텍스트 검색은 SQLite `LIKE` 기반 키워드 검색이었다.

현재 동작:

- `name_ko`
- `name_en`
- `district`
- `candidate_text`

에 대해 문자열 매칭을 수행하고, 검색 결과 score를 `1.0`, percentage를 `100`으로 반환한다.

하지만 최신 모델 구조에서는 text encoder가 포함되어 있으므로, 자연어 검색은 다음 구조가 필요하다.

```text
사용자 query
→ tokenizer
→ text_encoder.onnx
→ text embedding
→ text index/catalog 또는 landmark prototype과 유사도 비교
→ Top-K 결과
```

2026-06-15 main 기준으로는 text encoder semantic score와 SQLite keyword score를 결합한 `semantic_text_fusion` 구현이 반영되어 있다.

### 5. [P1] Confidence policy가 이전 INT8 기준으로 하드코딩되어 있었음

이슈 작성 당시 threshold가 코드에 직접 박혀 있었다.

예:

- `rejectThreshold`
- `weakRejectThreshold`
- `matchThreshold`
- `matchFloor`
- `matchMargin`

최신 S3 FP16 모델 기준으로 score 분포가 달라질 수 있으므로, 새 모델 검증 결과를 바탕으로 threshold를 다시 조정해야 한다.

권장 방향:

- `confidence_policy.json` 또는 `manifest.json`에서 정책값을 읽기
- model version별 threshold를 분리
- Top-1 score, Top-2 margin, decision_status를 로그에 남기기

### 6. [P1] parent/sub-landmark 구조가 DB에 없었음

최신 모델은 세부 랜드마크 class를 사용한다.

예:

```text
deoksugung_daehanmun
deoksugung_junghwajeon
deoksugung_junghwamun
```

하지만 사용자에게는 `덕수궁`이라는 상위 장소 맥락도 필요하다.

따라서 DB 또는 catalog에 다음 필드가 필요하다.

```json
"parent_landmark_id": "deoksugung"
```

앱 표시 예:

```text
덕수궁 · 중화전
경복궁 · 근정전
창경궁 · 명정전
```

### 7. [P2] 모델 관련 로그가 디버깅에 부족했음

이슈 작성 당시 검색 로그는 top1과 decision 중심으로 남았다.

최신 모델 검증과 앱 디버깅을 위해서는 아래 정보가 추가로 필요하다.

- model version
- artifact precision
- backend
- Top-3 landmark id
- Top-3 raw score
- Top-1 / Top-2 margin
- decision_status
- reason_codes
- latency_ms

### 8. [P1] 스코어 의미와 계산 방식이 명확히 분리되어 있지 않았음

이슈 작성 당시 앱은 image embedding과 prototype embedding의 cosine similarity를 계산한 뒤, 이를 0~100으로 변환해 `percentage`로 보여줬다.

이 방향 자체는 ranking 용도로 사용할 수 있지만, 주의할 점이 있다.

- cosine similarity는 정답 확률이 아니다.
- `44%`처럼 보이는 값은 `정답일 확률 44%`가 아니라 `embedding 유사도 0.44를 사용자 표시용으로 변환한 값`이다.
- 텍스트 검색은 현재 SQL keyword match 결과를 score `1.0`, percentage `100`으로 반환하므로 모델 기반 점수와 의미가 섞인다.
- 최종 판단인 `matched`, `ambiguous`, `out_of_scope`, `low_quality`는 raw score 하나만으로 결정하면 안 되고 Top-1/Top-2 margin과 입력 품질을 함께 봐야 한다.

따라서 앱 내부에서는 아래 값을 분리해야 한다.

```text
raw_score
  모델 또는 검색 엔진이 직접 계산한 원본 점수.
  이미지 검색에서는 cosine similarity.
  텍스트 semantic search에서도 cosine similarity.

display_score
  사용자에게 보여주기 위한 점수.
  확률이 아니라 유사도 점수로 표시한다.

decision_status
  matched / ambiguous / out_of_scope / low_quality 판단 결과.
  raw_score, margin, quality check를 함께 사용한다.
```

권장 결과 객체:

```json
{
  "landmark_id": "gwanghwamun",
  "rank": 1,
  "raw_score": 0.4412,
  "display_score": 44,
  "margin": 0.26,
  "score_type": "cosine_similarity",
  "decision_status": "matched"
}
```

주의:

- UI에서는 `정답 확률`이라는 표현을 쓰지 않는다.
- `유사도`, `모델 점수`, `검색 점수`처럼 표현한다.
- decision은 display score가 아니라 raw score와 margin 기준으로 계산한다.

## 수정 제안

### 최소 수정 범위

#### P0: 최신 모델 실행을 위한 필수 작업

- [ ] 최신 모델 artifact 폴더 추가
- [ ] `manifest.json` 기반 모델 파일 로딩
- [ ] image encoder session 로딩
- [ ] Android asset copy 로직을 단일 `landmark_encoder.onnx` 기준에서 manifest 기반 다중 파일 처리로 변경
- [ ] `prototype_index.json`의 `embedding` 키 지원
- [ ] `landmark_info.json`을 23-class 기준으로 갱신
- [ ] 모든 Top-3 `landmark_id`가 앱 DB/catalog에서 상세 정보로 연결되는지 확인

#### P1: 검색 품질과 사용자 결과 해석을 위한 작업

- [ ] text encoder session 로딩 준비
- [ ] text search가 keyword search인지 semantic search인지 명확히 분리
- [ ] text keyword search의 score `100` 고정 반환을 제거하거나 `keyword_score`로 별도 분리
- [ ] score 필드를 `raw_score`, `display_score`, `score_type`, `margin`, `decision_status`로 분리
- [ ] UI에서 percentage를 확률처럼 표시하지 않고 유사도/모델 점수로 표시
- [ ] confidence policy를 최신 모델 기준으로 분리
- [ ] `parent_landmark_id` 지원

#### P2: 운영과 디버깅 품질을 위한 작업

- [ ] 검색 로그에 model version, backend, top3 score, margin, decision_status 기록
- [ ] preprocessing 값을 코드 하드코딩 대신 `preprocessing.json`에서 읽도록 변경
- [ ] model version, precision, class_count를 앱 시작 시 확인하고 mismatch를 감지

### 권장 스코어 계산 방식

#### 이미지 검색

이미지 검색 ranking은 image encoder가 만든 embedding과 landmark prototype embedding의 cosine similarity로 계산한다.

```text
image_embedding = image_encoder(image)
prototype_embedding = landmark별 대표 embedding

raw_score = cosine_similarity(image_embedding, prototype_embedding)
          = dot(L2(image_embedding), L2(prototype_embedding))
```

현재 앱의 dot product 기반 계산 방향은 맞다.

단, 최신 artifact 기준으로는 `prototype_index.json`의 `embedding` 값을 읽어야 한다.

```dart
final rawEmbedding = item['embedding'] ?? item['prototype'];
```

권장 반환:

```dart
{
  'landmark_id': id,
  'rank': rank,
  'raw_score': score,
  'display_score': displayScore,
  'score_type': 'cosine_similarity',
}
```

#### display_score

초기 구현에서는 raw cosine similarity를 0~100으로 변환한다.

```text
display_score = round(clamp(raw_score, 0.0, 1.0) * 100)
```

단, UI 문구는 반드시 확률이 아니라 유사도 기준으로 작성한다.

좋은 표현:

```text
유사도 44
모델 점수 0.44
검색 점수 44
```

피해야 할 표현:

```text
정답 확률 44%
확률 44%
```

#### decision_status

최종 판단은 Top-1 score만 보지 않고 Top-2와의 차이인 margin을 함께 사용한다.

```text
top1_score = top3[0].raw_score
top2_score = top3[1].raw_score
margin = top1_score - top2_score
```

권장 판단 흐름:

```text
if image_quality_bad:
    decision_status = low_quality

else if top1_score < reject_threshold:
    decision_status = out_of_scope

else if top1_score >= match_threshold:
    decision_status = matched

else if top1_score >= match_floor and margin >= match_margin:
    decision_status = matched

else:
    decision_status = ambiguous
```

예시:

```text
top1 0.44 / top2 0.10
→ 점수는 아주 높지 않아도 margin이 큼
→ matched 가능

top1 0.44 / top2 0.41 / top3 0.39
→ 후보들이 매우 붙어 있음
→ ambiguous

top1 0.22 / top2 0.20
→ 전체 점수도 낮고 margin도 작음
→ out_of_scope 또는 ambiguous
```

#### 텍스트 검색

이슈 작성 당시 텍스트 검색은 SQL `LIKE` 기반 keyword search였다.

이 경우 `score = 1.0`, `percentage = 100`을 반환하면 image/text score 의미가 섞인다.

수정 방향:

```text
keyword_score
  정확한 이름/별칭/부분 문자열 매칭 점수.

semantic_score
  text_encoder ONNX가 만든 text embedding과 text catalog/index embedding의 cosine similarity.

final_text_score
  keyword_score와 semantic_score를 결합한 최종 ranking 점수.
```

초기 semantic text search 권장식:

```text
semantic_score = cosine_similarity(query_embedding, catalog_text_embedding)
```

landmark별 catalog 문장이 여러 개라면 먼저 단순하게 가장 높은 값을 사용한다.

```text
landmark_score = max(catalog_text_scores_for_landmark)
```

keyword와 semantic을 함께 쓸 경우:

```text
final_text_score = 0.75 * semantic_score + 0.25 * keyword_score
```

keyword_score 예:

```text
exact official name match: 1.0
alias exact match: 0.95
partial match: 0.6
no keyword match: 0.0
```

구현 전에는 tokenizer와 text encoder ONNX 연결 전까지 텍스트 검색 결과를 아래처럼 명시하는 것이 안전했다.
2026-06-13 구현 반영 이후 최신 기준은 <code>semantic_text_fusion</code>이며,
구현 구조는 [`Semantic Text Search Design`](../operations/semantic-text-search-design.html)을 따른다.

```json
{
  "score_type": "keyword_match",
  "raw_score": null,
  "keyword_score": 0.95,
  "semantic_score": null
}
```

### 권장 asset 구조

```text
assets/
  mobile_artifacts_fp16/
    manifest.json
    preprocessing.json
    tokenizer.json
    classes.json
    labels_master.json
    prototype_index.json
    mobileclip2_s3_server_full_ce_hardneg_image_encoder_fp16_mixed.onnx
    mobileclip2_s3_server_full_ce_hardneg_image_encoder_fp16_mixed.onnx.data
    mobileclip2_s3_server_full_ce_hardneg_text_encoder_fp16_mixed.onnx
    mobileclip2_s3_server_full_ce_hardneg_text_encoder_fp16_mixed.onnx.data
  landmark_info.json
  hero_images/
```

### 권장 앱 내부 구조

```text
Flutter UI
  ↓
LandmarkSearchGateway
  ↓
ImageSearchService / TextSearchService / LandmarkDetailService
  ↓
OnnxModelBundle
  ├─ image_encoder session
  └─ text_encoder session
  ↓
Local DB + model artifacts + prototype/text index
```

## 완료 기준

### P0 완료 기준

- [ ] 앱에서 최신 image encoder ONNX가 정상 로드된다.
- [ ] 이미지 입력 시 23-class 기준 Top-3 결과가 반환된다.
- [ ] `prototype_index.json`의 `embedding` 기반 검색이 정상 동작한다.
- [ ] 모든 Top-3 결과의 `landmark_id`가 앱 DB/catalog에서 상세 정보로 연결된다.

### P1 완료 기준

- [ ] 앱에서 최신 text encoder ONNX가 정상 로드된다.
- [ ] 세부 랜드마크는 parent landmark와 함께 표시된다.
- [ ] confidence decision이 `matched`, `ambiguous`, `out_of_scope`, `low_quality`로 구분된다.
- [ ] 텍스트 검색은 현재 keyword search인지, text encoder 기반 semantic search인지 UI/코드상 명확히 구분된다.
- [ ] 이미지 검색 score는 cosine similarity 기반 `raw_score`로 계산된다.
- [ ] 사용자 표시 점수는 `display_score`로 분리된다.
- [ ] UI에서 score를 확률처럼 설명하지 않는다.
- [ ] 텍스트 검색의 keyword score와 semantic score가 분리된다.

### P2 완료 기준

- [ ] 검색 로그에 model version, backend, top3 score, margin, decision_status가 남는다.
- [ ] 앱 시작 시 manifest의 model version, precision, class_count를 확인할 수 있다.
- [ ] preprocessing 설정이 artifact와 코드 사이에서 불일치하지 않는다.

## 참고

최신 모델 artifact 기준:

```text
model_id: mobileclip2_s3_server_full_ce_hardneg
model_name: MobileCLIP2-S3
class_count: 23
embedding_dim: 512
precision: fp16 mixed
image input: image, float32, [batch, 3, 224, 224]
image output: embedding, [batch, 512]
text input: text_tokens, int64, [batch, 77]
text output: text_embedding, [batch, 512]
```

주의:

- text encoder는 문자열을 직접 입력받지 않는다.
- 앱에서 tokenizer 처리가 필요하다.
- tokenizer 구현 전까지는 텍스트 검색을 keyword search로 명확히 분리하는 것이 안전하다.

## 추천 labels

```text
model
on-device
flutter
integration
sprint2
```
