pgvector와 OpenAI Embeddings를 활용한 교차 언어 비디오 검색 구축하기
요약
다국어 비디오 메타데이터 검색 시 발생하는 키워드 매칭의 한계를 해결하기 위해 pgvector와 OpenAI Embeddings를 결합한 하이브리드 검색 아키텍처를 제안합니다. 기존 SQLite FTS5의 키워드 검색 성능을 유지하면서, 의미론적 검색을 위해 Postgres를 사이드카로 활용하는 방식을 설명합니다.
핵심 포인트
- FTS5의 어휘적 한계인 언어 간 매칭 및 유의어 검색 문제 해결
- OpenAI text-embedding-3-small을 활용한 다국어 벡터 임베딩 적용
- SQLite FTS5와 Postgres pgvector를 결합한 하이브리드 랭킹 구조
- 의미론적 누락(semantic misses)을 줄여 검색 결과 품질 향상
TopVideoHub에서 우리는 아시아 태평양 지역의 트렌딩 비디오 메타데이터 — 일본어, 한국어, 번체 중국어, 태국어, 베트남어, 그리고 일반적인 영어 데이터 스트림(firehose) — 를 집계합니다. 수년 동안 우리의 검색은 커스텀 CJK 토크나이저(tokenizer)를 갖춘 SQLite FTS5 기반으로 운영되었으며, 키워드 조회(keyword lookups) 측면에서는 여전히 탁월합니다. 하지만 한 고객 지원 티켓이 결국 이 문제를 수면 위로 끌어올렸습니다. 한 사용자가 kitchen fail compilation이라고 검색했을 때, 우리 카탈로그에 料理 大失敗 まとめ라는 제목의 매우 인기 있는 클립이 있음에도 불구하고 아무런 결과도 나오지 않았습니다. 개념은 동일하지만, 어휘적 중첩(lexical overlap)이 전혀 없었습니다. FTS5는 의미가 아닌 토큰(token)을 매칭하며, 토크나이저(tokenizer)를 아무리 튜닝해도 kitchen fail과 料理 失敗 사이의 간극을 메울 수는 없습니다.
이것이 다국어 카탈로그에서 키워드 검색이 직면하는 벽입니다. 이 포스트는 제가 최종적으로 결정한 아키텍처를 소개합니다. SQLite FTS5는 본래 잘하는 기능을 유지하고, OpenAI 임베딩(embeddings)을 사용하여 의미론적(semantic)이고 교차 언어적인 검색(cross-lingual retrieval)을 수행하는 Postgres + TopVideoHub pgvector 사이드카(sidecar)를 결합하는 방식입니다. 저는 스키마(schema), 임베딩 파이프라인(embedding pipeline), PHP 8.4 쿼리 경로(query path), 그리고 실제로 배포된 하이브리드 랭킹(hybrid ranking)을 보여드릴 것입니다.
다국어 카탈로그에서 키워드 검색이 한계에 부딪히는 이유
FTS5는 어휘 엔진(lexical engine)입니다. 토큰의 역색인(inverted index)을 구축하고 BM25로 순위를 매깁니다. CJK 인식 토크나이저(CJK-aware tokenizer)를 사용하면 料理失敗를 잘 처리합니다. 연속된 문자를 검색 가능한 단위로 분할하기 때문입니다. 하지만 다음과 같은 일은 할 수 없습니다:
- 언어 간 매칭.
kitchen과料理는 서로 관련 없는 토큰입니다. - 유의어(synonyms) 또는 의역(paraphrases) 매칭.
funny cat은hilarious kitten을 찾아내지 못합니다. - 의도(intent) 이해.
videos to fall asleep to는 키워드가 아니라 분위기(vibe)입니다.
우리의 아시아 태평양 오디언스는 모국어와 영어를 혼합하여 검색하며, 종종 동일한 세션 내에서 이를 수행합니다. 결과가 없는 쿼리(zero-result queries)의 약 22%는 앞서 언급한 주방 예시와 같은 의미론적 누락(semantic misses)이었으며, 이는 우리가 소리 없이 놓치고 있었던 실제 수요였습니다.
임베딩 (Embeddings)은 이를 해결해 줍니다. 성능이 좋은 다국어 모델은 kitchen fail과 料理失敗를 벡터 공간 (vector space) 내의 인접한 지점으로 매핑하기 때문입니다. OpenAI의 text-embedding-3-small은 진정한 다국어 모델입니다. 저는 이 두 문구 사이의 코사인 유사도 (cosine similarity)를 측정했을 때 0.61이 나왔으며, 관련 없는 문구 쌍의 유사도는 약 0.08이었습니다. 이 정도의 신호라면 순위를 매기기에 충분하고도 남습니다.
SQLite를 교체하는 대신 Postgres 사이드카 (sidecar)를 사용하는 이유
우리의 기본 저장소는 SQLite입니다. SQLite는 빠르고, 파일 기반이며, LiteSpeed 및 우리의 배포 모델과 잘 작동하며, FTS5 키워드 검색 (keyword search)은 1밀리초 미만의 속도를 유지합니다. 저는 이를 통째로 들어내고 싶지 않았습니다. 벡터 검색 (Vector search)은 근본적으로 다른 워크로드 (고차원 ANN 인덱스, 주기적인 백그라운드 재임베딩 등)이므로, pgvector 확장이 설치된 별도의 Postgres 인스턴스로 실행합니다. SQLite는 비디오 메타데이터의 신뢰할 수 있는 원천 (source of truth)으로 남고, Postgres는 의미론적 검색 (semantic search)에 필요한 정보인 비디오 ID, 필터링을 위한 약간의 비정규화된 (denormalized) 메타데이터, 그리고 임베딩 값만을 보유합니다.
이렇게 하면 각 엔진이 각자 잘하는 일을 수행하게 되며, Postgres에 장애가 발생하더라도 사이트 전체가 다운되는 대신 검색 기능이 키워드 전용으로만 저하될 뿐입니다.
스키마 (Schema)와 pgvector 컬럼
text-embedding-3-small은 1536차원의 벡터를 반환합니다. 테이블은 의도적으로 가볍게 구성했습니다. 필터링을 위한 ID, 언어, 지역, 게시 타임스탬프, 임베딩한 원문 텍스트 (디버깅 및 재임베딩을 위해 유지), 그리고 벡터 자체로 구성됩니다.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE video_embeddings (
...
거리 연산자 (distance operator)에 대한 참고 사항입니다. pgvector는 <-> (L2), <#> (음의 내적, negative inner product), 그리고 <=> (코사인 거리, cosine distance)를 제공합니다. OpenAI 임베딩은 단위 길이로 정규화 (normalised)되어 있으므로 코사인 유사도와 내적의 순위는 동일합니다. 하지만 저는 명시적으로 <=>를 사용합니다. 코사인 거리는 예측 가능한 [0, 2] 범위 내에 위치하므로, 가공되지 않은 내적 값보다 임계값 (thresholds) 설정과 점수 혼합 (score blending)을 훨씬 더 쉽게 추론할 수 있기 때문입니다.
실제로 어떤 텍스트를 임베딩하는가?
이 결정은 모델 선택보다 더 중요했습니다. YouTube 스타일의 제목만으로는 정보가 너무 희소합니다. 저는 모델이 구조를 파악할 수 있도록 가벼운 레이블을 붙여, 의미를 담고 있는 필드들을 압축한 복합체(composite)를 임베딩합니다:
- 제목 (가장 강력한 신호)
- 채널 이름
- 상위 3~5개의 태그
- 설명(description)의 처음 약 300자
저는 조회수, 재생 시간 또는 ID는 의도적으로 임베딩하지 않습니다. 숫자형 메타데이터는 의미론적 공간(semantic space)을 오염시키기 때문입니다. 또한 공격적으로 텍스트를 잘라냅니다(truncate). text-embedding-3-small은 토큰당 비용이 발생하며, 비디오 메타데이터가 수백 토큰을 넘어가면 검색 품질의 이득은 점차 줄어드는 반면 비용은 선형적으로 증가하기 때문입니다.
임베딩 파이프라인 (The embedding pipeline)
이 Python 워커(worker)는 (재)임베딩이 필요한 행을 가져와 OpenAI를 배치(batch) 단위로 호출합니다. 임베딩 엔드포인트는 요청당 최대 2,048개의 입력을 수용할 수 있어 지연 시간(latency)과 오버헤드를 모두 대폭 줄여줍니다. 그 후 Postgres에 업서트(upsert)합니다. 이 과정은 멱등성(idempotent)을 보장합니다. 소스 텍스트의 콘텐츠 해시(content hash)를 사용하기 때문에, 변경되지 않은 텍스트를 다시 임베딩하는 데 비용을 지불할 필요가 없습니다.
import hashlib
import os
import openai
...
psycopg3는 Python의 list[float]를 pgvector의 텍스트 형식으로 직접 변환하므로 수동 직렬화(serialization)가 필요하지 않습니다. 만약 psycopg2를 사용 중이라면 pgvector.psycopg2 어댑터를 등록하거나, 직접 리스트를 '[0.1,0.2,...]' 형식으로 포맷팅해야 합니다.
우리는 트렌딩 비디오를 수집하는 것과 동일한 cron 작업에서 이를 실행합니다. 새로운 비디오는 한 시간 이내에 임베딩되며, 비용은 미미합니다 (이에 대해서는 아래에서 더 자세히 다룹니다).
PHP 8.4를 통한 쿼리 (Querying from PHP 8.4)
읽기 경로(read path)는 우리의 PHP 앱에 존재합니다. 쿼리 시점에 사용자의 검색 문자열을 한 번 임베딩한 다음, 메타데이터 사전 필터(pre-filter)와 함께 Postgres에 가장 가까운 이웃(nearest neighbours)을 요청합니다. 사전 필터는 매우 중요합니다. ANN(Approximate Nearest Neighbor) 검색 전에 지역(region)으로 범위를 제한하면 후보군(candidate set)의 관련성을 유지할 수 있고, 플래너(planner)가 부분 인덱스(partial index)를 사용할 수 있게 합니다.
<?php
declare(strict_types=1);
...
제가 겪었던 미묘한 문제 하나는 다음과 같습니다: ORDER BY embedding <=> :vec 구문이 실제로 HNSW 인덱스를 활성화한다는 점입니다. 만약 SQL에서 계산된 혼합 점수(blended score)와 같이 다른 표현식으로 정렬을 시도하면, Postgres는 순차 스캔(sequential scan)으로 전환하여 모든 행에 대해 거리를 다시 계산합니다. 따라서 ANN(근사 최근접 이웃) 정렬은 순수하게 유지하여 충분히 큰 후보군을 가져온 다음, 애플리케이션 코드에서 다시 순위를 매기십시오(re-rank). 이것이 바로 하이브리드 레이어(hybrid layer)가 수행하는 작업입니다.
하이브리드 검색: FTS5 키워드 점수와 벡터 거리의 결합
순수 벡터 검색(Pure vector search)은 키워드 검색과 유사한 실패 모드를 가집니다. 즉, 정확한 의도를 파악하기에는 너무 모호하다는 점입니다. 만약 누군가가 특정 채널 이름이나 정확한 제목을 검색한다면, BM25는 이를 정확히 잡아내지만 임베딩(embeddings)은 막연하게 관련된 영역으로 벗어나 버립니다. 해결책은 두 엔진을 모두 실행하고 순위를 융합하는 것입니다. 저는 상호 순위 융합(Reciprocal Rank Fusion, RRF)을 사용하는데, 이는 점수 정규화(score normalisation)가 필요 없기 때문입니다. RRF는 오직 순위 위치(rank position)에만 관심을 두므로, BM25 점수와 코사인 거리(cosine distance)를 마치 동일한 척도에 있는 것처럼 꾸미지 않고도 결합할 수 있습니다.
<?php
declare(strict_types=1);
...
쿼리당 흐름은 다음과 같습니다:
- FTS5 (SQLite)와 pgvector (Postgres)를 동시에 실행합니다. PHP에서는 로컬 FTS5 쿼리가 실행되는 동안 OpenAI 임베딩 생성과 Postgres 쿼리를 함께 시작하는 것을 의미합니다. 네트워크 왕복 시간(network round-trip)이 지배적이므로, 이들을 중첩시키면 대부분의 지연 시간(latency)을 숨길 수 있습니다.
- 각 엔진에서 상위 약 50개를 가져옵니다.
- RRF로 융합한 다음, 페이지 크기에 맞춰 자릅니다.
이를 통해 FTS5의 정확한 일치(exact-match) 정밀도와 임베딩의 교차 언어 재현율(cross-lingual recall)을 모두 얻을 수 있습니다. 이제 料理 大失敗 まとめ와 같은 '요리 대실패' 쿼리는 벡터 측을 통해 결과가 나타나며, 정확한 채널 이름을 검색할 때는 키워드 측을 통해 여전히 해당 채널이 첫 번째로 순위에 오릅니다.
재임베딩 백로그를 위한 Go 워커
Python 크론(cron)은 증분 임베딩 (incremental embedding)을 잘 처리하지만, 복합 텍스트 형식(composite text format)을 변경하거나 모델을 업그레이드할 때는 전체 카탈로그, 즉 수백만 개의 행을 다시 임베딩해야 합니다. 이러한 백필 (backfill) 작업을 위해 저는 작은 동시성 Go 워커 (concurrent Go worker)를 작성했습니다. 이 작업은 거의 전적으로 OpenAI API의 I/O 대기 상태에 머물기 때문에, Go의 고루틴 (goroutines)을 사용하면 속도 제한 (rate limit)을 깔끔하게 채울 수 있기 때문입니다.
package main
import (
...
이 방식을 사용하면 활성 카탈로그의 깔끔한 재임베딩이 20분 이내에 완료되며, CPU가 아닌 전적으로 속도 제한기 (rate limiter)에 의해 속도가 제한됩니다.
재현율(Recall) 대 지연 시간(Latency) 튜닝
읽기 경로(read path)에서 재현율과 지연 시간 사이의 트레이드오프 (tradeoff)를 조절하는 두 가지 노브 (knobs)가 있습니다.
ef_construction(인덱스 구축 시간, 기본값 64): 값이 높을수록 더 나은 그래프를 구축하지만 속도는 느려집니다. 저희에게는 64가 적당했습니다. 128로 설정했을 때는 구축 시간이 두 배로 늘어난 것에 비해 재현율 이득이 미미했습니다.hnsw.ef_search(쿼리 시간, 기본값 40): 이것은 실시간으로 튜닝하는 설정입니다.ef_search = 40일 때 저희의 p95는 약 8ms였으나, recall@20은 약 0.93 수준이었습니다. 이를 80으로 높이면 recall은 약 0.98로 상승하고 p95는 약 14ms가 되었습니다. 어차피 OpenAI 임베딩 호출 시간(약 120ms)이 Postgres 쿼리 시간을 압도하기 때문에, 저는 기꺼이 이 트레이드오프를 선택했습니다.
교훈: API 왕복 시간 (round-trip)이 실제 지연 시간 예산의 핵심일 때는 pgvector 쿼리를 미세 최적화 (micro-optimise)하지 마세요. 이것이 캐싱 (caching)이 중요한 이유입니다.
비용 및 캐싱
주의 깊게 살펴봐야 할 두 가지 비용은 OpenAI 청구 비용과 쿼리 지연 시간입니다.
- 임베딩 비용 (Embedding cost).
text-embedding-3-small은 매우 저렴합니다. 짧은 메타데이터 문자열 1,000개당 1센트의 아주 적은 비용이 발생합니다. 전체 활성 카탈로그를 임베딩하는 데는 몇 달러 정도가 소요됩니다. 쿼리 측 임베딩은 반복적인 비용이 발생하는 부분이며, 호출당 비용은 매우 작지만 규모가 커지면 합산되어 나타납니다. - 쿼리 임베딩 캐싱 (Cache the query embeddings). 검색 트래픽은 지프의 법칙 (Zipfian)을 따릅니다. 즉, 소수의 인기 있는 쿼리가 대부분을 차지합니다. 우리는 기존 캐시 레이어에
쿼리 문자열 -> 임베딩을 캐싱하며 (Cloudflare가 엣지에서 반복되는 전체 결과 응답을 흡수합니다), 이를 통해 API 비용과 대부분을 차지하는 핫 쿼리(hot queries)의 120ms 왕복 지연 시간(round-trip)을 모두 제거합니다. - 캐시 키 정규화 (Normalise the cache key). 해싱하기 전에 소문자 변환, 트리밍 (trim), 공백 압축, 그리고 유니코드 정규화 (Unicode-normalise, NFKC)를 수행해야 합니다. CJK(한중일) 입력은 일관되지 않은 너비 형식(전각 vs 반각)으로 들어오며, 정규화를 건너뛰면 캐시 적중률 (hit rate)이 급락합니다.
내가 다르게 했을 점
- 순수 벡터가 아닌 하이브리드 레이어 (hybrid layer)부터 시작하기. 나는 벡터 전용 방식을 먼저 출시했고, 정확한 일치 (exact-match) 쿼리에서 발생하는 성능 저하를 즉시 후회했습니다. RRF (Reciprocal Rank Fusion)를 첫날부터 도입했어야 했습니다.
- 처음부터 원문 텍스트를 저장하기. SQLite와 다시 조인(join)하지 않고도 다시 임베딩할 수 있었던 점이 모델 포맷 변경 시기에 큰 도움이 되었습니다.
- 단순히 양뿐만 아니라 검색 실패(misses)의 언어 분포를 관찰하기. 우리의 가장 큰 성과는 키워드 토크나이저 (tokenizer)가 가장 취약했던 태국어와 베트남어 쿼리에 집중되었습니다. 임베딩은 내가 인지조차 못 했던 토크나이저의 공백을 조용히 메워주었습니다.
시맨틱 검색 (Semantic search)은 TopVideoHub의 FTS5를 대체한 것이 아니라, FTS5가 구조적으로 채울 수 없는 구멍을 메워준 것입니다. 정밀도를 위한 키워드 검색, 의미를 위한 임베딩, 이 둘을 결합하기 위한 RRF, 그리고 이 중 어느 것도 SQLite 코어를 위협하지 않도록 하는 가벼운 Postgres 사이드카 (sidecar)를 활용했습니다. '주방 실패(kitchen-fail)' 티켓은 종료되었으며, 우리의 검색 결과 없음 (zero-result) 비율은 약 3분의 2가 감소했습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기