영상 QA 시스템을 만들면서 VLM 프레임 분석과 멀티모달 검색 개선하기
들어가며
클로드 등 AI를 사용하여 개발하는 것이 점점 당연시되어 가는 것을 보면서 AI 엔지니어링 학습에 대한 갈망이 생겼습니다. 마침 PI Lab AI 엔지니어링 부트캠프 설명회를 듣게 됐고, AI/ML 이론과 RAG·멀티모달을 직접 구현하며 커리어 전환까지 준비할 수 있다는 생각에 바로 수강을 결정했습니다.
이 글은 PI Lab 스프린트 중에서 영상 QA 시스템을 개선하며 겪은 시행착오를 정리합니다. 영상을 업로드하면 자연어로 장면·내용을 질문하고 답변하는 시스템인데, 파이프라인은 단순해 보입니다.
영상 → STT(음성 전사) + VLM(화면 분석) + OCR → 벡터 DB 저장질문 → 검색 → LLM 답변각 레이어를 개선하면서 시스템이 어떻게 발전했는지, 그 과정을 단계별로 살펴보겠습니다.
시작점: 음성 전사만으로는 화면 정보에 답할 수 없다
스프린트 시작 시점의 파이프라인은 STT(음성 전사)로만 영상을 인덱싱하고 있었습니다. 이 구조에서는 화면에만 존재하는 정보를 묻는 질문에 구조적으로 답할 수 없습니다. 강사가 읽어주지 않은 코드 변수명, 방송 자막의 고양이 품종, 인터뷰 자막의 기자 이름 — 음성으로 발화되지 않은 정보는 검색 대상에 존재하지도 않기 때문입니다.
테스트 영상은 두 개를 골랐습니다.
| 영상 | 특징 | 목적 |
|---|---|---|
| 화면 전환 거의 없음. 코드 에디터 고정 | 음성으로 안 읽어주는 코드 정보 테스트 | |
| 자막·로고·인터뷰·실내 샷이 빠르게 교차 | VLM·OCR·검색 레이어 효과 측정 |
1. “설명해라” vs “읽어라” — 프롬프트 한 줄의 차이
VLM을 처음 연결했을 때 출력이 검색에 도움이 안 됐습니다. 이유를 찾으니 간단했습니다. 프롬프트가 “이 화면을 설명해라”였습니다.
GPT-4o는 프롬프트대로 "왼쪽에 파이썬 코드 편집기, 오른쪽에 실행 화면이 보인다"라고 작성하였습니다. 정확한 묘사이긴 하지만 total = 0이나 average = total / len(score_list) 같은 화면에 실제로 적혀있는 글자는 한 자도 출력하지 않았습니다. 검색 인덱스에 없는 내용을 검색할 수는 없기 때문에 프롬프트를 바꿔보기로 하였습니다.
“화면에 보이는 모든 텍스트를 글자 그대로 옮겨 적어라. 코드는 ``` 펜스로 감싸 원문 보존. 숫자는 정확한 값으로. 텍스트가 없을 때에만 장면을 한 줄로 요약.”
이렇게 프롬프트를 수정하니 "total 변수의 초기값은 무엇인가?" — 기존 ❌ “확인되지 않습니다” → ✅ “total 변수의 초기값은 0입니다”. 로 화면 정보에 대한 질문이 성공했습니다. 같은 모델이지만 프롬프트 지시 방식이 출력 형태를 결정되는 것을 확인하였습니다.
2. 프레임 캡처 전략이 프롬프트만큼 중요하다
V1에서 프롬프트가 효과를 냈으니 같은 프롬프트로 V2에 적용했습니다. 그런데 큰 상단 로고나 반복 이름은 잘 잡아내는데, 장면 전환 시점에만 잠깐 뜨는 1회성 캡션을 놓쳤습니다. "코리아숏헤어", "이동학 기자" 같은 정보들입니다.
프레임이 샘플에 포함되지 않으면 VLM이 아무리 잘 읽어도 소용없습니다. 문제의 절반은 “해당 프레임이 샘플에 포함됐는가”였습니다.
고정 간격 → 장면 전환 기반으로 전환
ffmpeg 0.5fps 추출 (전체 프레임)→ 인접 프레임 간 grayscale pixel diff (cv2.absdiff + np.mean)→ 변화량 > threshold인 프레임만 선별V1(코딩 튜토리얼)에 적용하니 13장에서 9장으로 줄어들었습니다. 중복이 사라지면서 VLM 분석의 중복 내용도 함께 줄었습니다.
반면 V2(고양이 다큐)는 ffmpeg에서 210장이 추출됐고, 장면 전환 감지 필터링 후 83장(39.5%)이 선별됐습니다. 고정 15초 간격의 28장 대비 3배가 늘었지만 단순히 많아진 게 아니라 자막이 등장하는 장면 전환 지점을 정확히 잡아냈습니다.
그 결과, 이전에 15초 고정 간격으로 놓치던 "코리아숏헤어" 품종 캡션 질문이 처음으로 성공했습니다.
3. VLM이 못 잡는 자막 — OCR 엔진 병행
V2의 방송 자막(두꺼운 테두리, 반투명 배경, 디자인 폰트)은 VLM이 생성 모드로 해석하면서 오인식하거나 놓치는 경우가 있었습니다. 전용 OCR 엔진이 필요했습니다.
OCR 엔진 선택 여정
| 엔진 | 결과 | 이유 |
|---|---|---|
| EasyOCR | ❌ | "꽁꽁 얼어붙은 한강 위 고양이" → "끊공얼어분은한감위고양이" (conf 0.02) |
| PaddleOCR | ❌ 실행 불가 | macOS에서 CUDA 미지원 → CPU 강제 실행 → 17장째 메모리 과부하로 개발 머신 멈춤 |
| Google Cloud Vision | ✅ | 4.49초 / 82장. 14배 단축. 인식 정확 |
처음엔 전처리(업스케일 2배 + 적응형 이진화)로 EasyOCR 품질을 올리려 했습니다. 하지만 오히려 성능이 떨어졌습니다. 그 이유는 EasyOCR이 컬러 이미지로 학습된 딥러닝 모델이라 흑백 이진화가 학습 분포에서 멀어지게 만들었기 때문입니다. Google Cloud Vision모델로 변경하면서 OCR성능이 크게 개선된 것을 확인하며 모델 최적화보다 더 나은 모델 선택이 성능 개선에 더 도움이 될 수 있다는 것을 경험하였습니다.
VLM에 OCR을 주입하여 성능 개선
- OCR: 픽셀 수준 텍스트 감지 → 환각 없음, 원본 그대로
- VLM: 이미지를 “이해”해서 텍스트로 표현 → 맥락 해석, 환각 가능성 있음
VLM이 더 정확하게 출력되게 하기 위해 OCR로 정확한 텍스트를 먼저 추출하고, 그 결과를 VLM 프롬프트의 [화면 텍스트 (OCR)] 섹션에 주입해 VLM이 장면 맥락과 함께 해석하도록 했습니다. VLM이 OCR 결과를 보고 “이 텍스트가 이 장면에서 어떤 의미인지”를 자연어로 서술하는 구조가 됐습니다.
4. 멀티모달 검색: 모달리티가 모달리티를 밀어낸다
4-1. 단일 인덱스의 함정
transcript·vision·OCR을 하나의 인덱스에 넣고 top_k=5로 검색하니, OCR row가 많은 V2에서 OCR 결과가 top-5를 독점하고 transcript 결과가 완전히 밀려났습니다. 화면 정보가 많아질수록 오히려 음성 기반 답변이 사라지는 역설입니다.
해결은 모달리티별로 독립 검색 후 통합하는 것이었습니다. transcript, vision, OCR 각각 top-10을 뽑고, RRF(Reciprocal Rank Fusion — 각 리스트에서의 순위 역수를 합산해 통합 순위를 내는 기법)로 합쳐 각 소스가 최소한의 대표권을 확보하도록 했습니다.
4-2. “품종”과 “코리아숏헤어”가 벡터 공간에서 멀다
소스 불균형은 잡았는데도 "화면 자막에 표시된 고양이의 품종은?" 질문이 계속 실패했습니다.
로그를 뜯어보니 DB에는 "[03:08] 코리아숏헤어라는 품종" Vision 청크가 존재했습니다. 그런데 임베딩 유사도가 0.60으로 top-10 밖이었습니다. “품종”이라는 추상 카테고리와 “코리아숏헤어”라는 구체 고유명사 사이의 벡터 거리가 멀었던 것입니다. 검색에 진입조차 못 하면 아무리 좋은 답이 DB에 있어도 꺼낼 수 없습니다.
4-3. BM25 하이브리드 검색
벡터 유사도만으론 “2021년”과 “발견된 해”처럼 의미는 같지만 어휘가 다른 경우에도 한계가 있습니다. 반대로 숫자, 고유명사는 키워드 직접 매칭이 정확합니다.
벡터 유사도 점수와 PostgreSQL의 tsvector 기반 BM25 키워드 점수를 SQL 레벨에서 RRF로 통합했습니다. 한국어 형태소 분석(kiwipiepy)도 추가했습니다. “품종은” → “품종 은”으로 분리해 조사 변형에도 BM25가 매칭될 수 있도록 했습니다.
4-4. Cross-Encoder 리랭킹 — 의미 격차의 구조적 해결
BM25를 붙였어도 품종 질문 실패는 계속됐습니다. 근본 원인은 벡터 검색 자체의 recall 한계였습니다.
해결책은 후보를 더 넓게 모은 뒤 의미를 직접 비교하는 모델로 재선별하는 것이었습니다.
transcript/ocr hybrid 검색 (각 10개)vision 벡터 검색 (10개)↓concat (중복 id 제거, 최대 30개)↓Cross-Encoder가 (쿼리, 세그먼트) 쌍을 직접 평가→ rerank_score 산출 후 상위 15개 선별↓LLM 컨텍스트Cross-Encoder(BAAI/bge-reranker-v2-m3)는 쿼리와 문서를 한 번에 같이 입력받아 의미 관련성을 직접 산출합니다. 임베딩 유사도가 낮아도 실제로 관련 있으면 높은 점수를 줍니다.
이를 통해 "고양이 품종"에 대한 질문에 대한 답변이 코리아 숏헤어로 나오게 하는 것에 성공했습니다. 임베딩 top-10 밖에 있던 청크가 concat 풀에는 살아있었고, 리랭커가 의미 관련성을 인식해 상위로 끌어올려 개선하였습니다.
최종 결과
| 질문 | Before | After |
|---|---|---|
| 고양이의 품종은? | ❌ “자막에 표시되지 않음” | ✅ “코리아숏헤어” |
| 꽁꽁이가 발견된 해와 상황은? | ⚠️ “해(年)는 확인 불가” | ✅ “2021년 겨울, 얼어붙은 한강” |
| 공의 색깔은? | ❌ “회색 장난감” (VLM 오해석) | ✅ “녹색” |
| 고양이의 이름은? | ⚠️ “콩콩” (STT 전사 오류) | ✅ “꽁꽁” (OCR 교차 근거) |
| 프로그램 타이틀은? | OCR 인식됨, QA 미측정 | ✅ “고양이를 부탁해” |
- 같은 모델도 프롬프트에 따라 완전히 다른 출력을 냅니다 — “설명”과 “전사”는 단어 하나 차이지만 검색 품질이 결정됩니다
- 문제의 절반은 해당 정보가 샘플에 포함됐는가입니다 — 프레임 추출 전략이 VLM 품질만큼 중요합니다
- OCR API와 VLM은 대체가 아닌 보완입니다 — 정확한 텍스트 추출과 맥락 이해는 각자의 역할이 있습니다
- 검색은 “관련성”과 “균형”을 함께 잡아야 합니다 — 모달리티가 모달리티를 밀어내는 구조에 주의해야 합니다
- 넓은 후보 풀 + 의미 리랭커가 핵심입니다 — 벡터 검색(recall)으로 넓게 모으고 Cross-Encoder(precision)로 정밀 선별합니다
- 모델을 바꾸면 후처리 전체를 재검토해야 합니다 — 출력 스키마가 바뀌면 기존 필터가 조용히 결과를 다 버릴 수 있습니다
PI Lab에서 진행한 스프린트 결과물입니다. AI 엔지니어링 실전 경험을 쌓고 싶다면 https://paaa.ai/ 를 참고해 보세요.