Files
joungmin 88bbf3ca25 docs(design): #356 영상-식당 관련도 LLM 평가 설계서 (Architect)
video_restaurants.relevance(strong/weak/incidental/unknown) 컬럼 + VideoRelevanceService.
findVideoLinks에 includeWeak 파라미터. 어드민 4개 API.
#322 식당 LLM 검증과 동일 패턴.

설계서: docs/design/356-video-relevance-llm/README.md (Approved)
Refs: #356 (Architect)
2026-06-15 19:24:19 +09:00
..

설계서: 영상-식당 관련도 LLM 평가로 약한 매칭 자동 숨김 (#356)

상태: Approved 작성: [AI] Architect · 최종수정: 2026-06-15 추적성 — Redmine: #356 · 유사 패턴: #322(식당 LLM 검증, 09-Done) · 부모 영역: #270(영상→식당 추출 파이프라인 현행화, 09-Done) · 구현 파일: backend-java/src/main/java/com/tasteby/service/VideoRelevanceService.java(신규), backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml, backend-java/src/main/java/com/tasteby/service/RestaurantService.java, backend-java/src/main/java/com/tasteby/controller/AdminVideoRelevanceController.java(신규), DB 마이그레이션 SQL · 테스트: 본 범위 밖 (테스트 인프라 #343 도입됨, 후속에서 점진 확장)

1. 목적 (Why)

식당 상세에 연결된 영상 중 식당과 본격적으로 관련 없는 약한 언급(비교 대상, 일반 토픽 중 잠깐 언급, 식당 입점 전 영상 등)이 노이즈로 표시. 실제 케이스 — 파이브가이즈 강남의 영상 7개 중 3건이 약한 매칭(쉐이크쉑 비교 / 미국 비만율 일반 토픽 / 한국 입점 전 미국 여행). LLM 평가로 약한 매칭 자동 숨김.

2. 범위 (Scope)

  • 포함
    • video_restaurants 테이블에 relevance, relevance_reason, relevance_evaluated_at 컬럼 추가.
    • VideoRelevanceService 신규 — LLM 판정 + DB 반영 (#322 패턴 모방).
    • PipelineService.processExtract 완료 후 verifyAsync(linkId) 호출 — 신규 등록 자동 평가.
    • GET /api/restaurants/{id}/videos: 기본 relevance = 'strong'만 응답. ?include_weak=true 시 모두 포함.
    • 어드민 API: 단건 재평가 / 일괄 백필 / 수동 토글.
  • 제외 (별도 후속)
    • 어드민 UI(검증 칼럼 / 토글) — #322의 RestaurantsPanel UI와 같은 패턴으로 별도 후속.
    • 프론트 사용자 옵션 UI("약한 매칭도 보기" 토글) — 별도 후속.
    • LLM 비용 모니터링/메트릭 — 별도.

3. 인수조건

  • video_restaurants 테이블에 relevance VARCHAR2(16) DEFAULT 'unknown', relevance_reason VARCHAR2(120), relevance_evaluated_at TIMESTAMP 컬럼 + idx_vr_relevance 인덱스.
  • 가능한 값: strong | weak | incidental | unknown (unknown = 미평가).
  • 신규 등록 시 60초 안에 relevance_evaluated_at 설정.
  • GET /api/restaurants/{id}/videos 기본 응답: relevance IN ('strong','unknown') (안전한 기본값 = 평가 실패 시 표시).
  • ?include_weak=true: 모두 포함 + relevance, relevance_reason 필드 동봉.
  • 어드민 API:
    • GET /api/admin/video-relevance/pending → 미평가(unknown) 카운트
    • POST /api/admin/video-relevance/all?batchSize=10 → 백필
    • POST /api/admin/video-relevance/{linkId}/evaluate → 단건 재평가
    • PATCH /api/admin/video-relevance/{linkId} → 수동 강제 토글 {relevance, reason}
  • LLM 호출 실패 시 unknown 유지 + 로그 (#322와 같은 안전 기본값).
  • 빌드/배포 회귀 없음.

4. 컨텍스트 & 제약

  • 기존 OciGenAiService.chat(prompt, maxTokens) 재사용.
  • LLM 비용: 영상-식당 페어당 1회 단발. 현재 1,244건 → 백필 시 약 1,244 호출.
  • video_restaurants는 한 영상에 여러 식당, 한 식당에 여러 영상이 m:n 관계.
  • 같은 페어는 relevance_evaluated_at이 NULL 아니면 재평가 안 함 (캐시).

5. 아키텍처 개요

PipelineService.processExtract (기존)
   │
   ▼
RestaurantService.linkVideoRestaurant (video_restaurants INSERT)
   │
   ▼
VideoRelevanceService.verifyAsync(linkId)  ← #356 신규
   │ (비동기)
   ▼
OciGenAiService.chat(prompt, 120)
   │
   ▼
parseRelevance → { relevance: strong|weak|incidental, reason: string }
   │
   ▼
RestaurantMapper.updateRelevance(linkId, relevance, reason)
   │
   ▼ (조회 시)
RestaurantMapper.findVideoLinks(restaurantId, includeWeak)
   ├ includeWeak=false (기본): WHERE relevance IN ('strong','unknown')
   └ includeWeak=true:  모두 + relevance/reason 필드 노출

6. 데이터 모델

DB 마이그레이션

ALTER TABLE video_restaurants ADD (
  relevance VARCHAR2(16) DEFAULT 'unknown' NOT NULL,
  relevance_reason VARCHAR2(120),
  relevance_evaluated_at TIMESTAMP
);
CREATE INDEX idx_vr_relevance ON video_restaurants(relevance);

응답 Map에 키 추가:

  • relevance: "strong" | "weak" | "incidental" | "unknown"
  • relevance_reason: string | null

LLM 응답 스키마

{
  "relevance": "strong" | "weak" | "incidental",
  "reason": "20자 이내"
}

7. 함수 명세

함수 책임 비고
VideoRelevanceService.verifyAsync(linkId) 비동기 트리거 #322RestaurantVerifyService.verifyAsync 유사
VideoRelevanceService.verify(linkId) 단건 검증 + DB 반영 LLM 실패 시 unknown 유지
VideoRelevanceService.verifyAll(batchSize) 백필 (식당당 200ms sleep)
VideoRelevanceService.buildPrompt(...) 프롬프트 생성 식당명·주소·음식·영상 제목·평가
VideoRelevanceService.parseRelevance(raw) LLM 응답 → DTO 파싱 실패 시 unknown 안전 기본값
RestaurantMapper.updateRelevance(linkId, rel, reason) DB 갱신
RestaurantMapper.findVideoLinks(restaurantId, includeWeak) 기존 SQL에 WHERE 조건 추가
AdminVideoRelevanceController 신규 4개 admin endpoint requireAdmin

8. 흐름

신규 등록 자동 평가

  1. PipelineService.processExtractlinkVideoRestaurant → linkId 획득.
  2. VideoRelevanceService.verifyAsync(linkId) 호출(@Async).
  3. 별도 스레드: 영상/식당/평가 데이터 조회 → buildPrompt → LLM → parse → DB.

백필

  1. 어드민 POST /api/admin/video-relevance/all 호출.
  2. WHERE relevance_evaluated_at IS NULL 인 link 10개씩 조회 → 순차 검증.
  3. 식당당 200ms sleep (LLM rate 보호).

프롬프트 예시

다음 YouTube 영상이 이 식당을 어떻게 다루는지 판정하라.

식당명: {restaurantName}
주소: {address}
음식: {foodsMentioned}

영상 제목: {videoTitle}
영상 채널: {channelName}
영상에 등장한 평가 내용: {evaluation}

응답 형식(JSON만, 다른 텍스트 없이):
{"relevance": "strong"|"weak"|"incidental", "reason": "20자 이내 한국어"}

가이드:
- strong: 영상 본편이 이 식당을 본격 다룸. 방문 리뷰, 메뉴 평가 등.
- weak: 영상에서 잠깐 언급, 비교 대상으로만 등장, 다른 식당의 일부로.
- incidental: 식당 입점 전 영상에서 단순 언급, 일반 토픽(미국 비만, 환율 등)에서 잠깐.
- 판단 모호 시 strong (보수적 — 사용자에게 표시 유지).

9. 엣지케이스

  • LLM 응답 비-JSON: parseRelevance → unknown 기본값.
  • LLM 호출 실패: unknown 유지 → 다음 백필 재시도.
  • 영상 데이터 누락(transcript 없음, evaluation 비어있음): 프롬프트에 "(미상)" 표기. LLM이 판정 어려우면 strong 보수적.
  • 동시성: 같은 linkId verifyAsync 두 번 호출 → idempotent.
  • 삭제된 영상: linkId 조회 결과 없으면 no-op.

10. 테스트 (수동)

  • 파이브가이즈 강남 케이스 백필 → 7건 중 3건이 weak/incidental로 마킹되는지 확인.
  • 공개 API /api/restaurants/{id}/videos → 약한 매칭 제외 확인.
  • ?include_weak=true → 모두 포함 확인.

11. 리스크 & 대안

  • 선택: #322 동일 패턴 + DB 마이그레이션.
  • 대안 A: 사용자가 직접 "약한 매칭도 보기" 토글 → 사용자 결정 부담.
  • 대안 B: 추출 단계에서 한 번에 판정 → 비용 ↓이지만 ExtractorService 비대.
  • 트레이드오프: 단발 LLM 평가는 비용 합리적. false positive는 어드민 수동 토글 + unknown 안전 기본값으로 보완.

12. 미해결 질문

  • 임계값(weak/incidental 둘 다 숨김 vs incidental만 숨김) — 현재는 둘 다 숨김.
  • 영상 자막 전체를 LLM에 보낼지 vs 평가 텍스트만 → 비용/정확도 트레이드오프. 현재는 evaluation만(짧음).
  • 사용자에게 "약한 매칭도 보기" UI → 별도 후속.
  • 어드민 UI — 별도 후속 (#322 패턴 모방).