홈 › 8장 영상 AI 기술 실무

8.4 임베딩과 의미 검색 - 검색 단위, 벡터 검색, 키워드와의 순위 결합

대본을 시간 창으로 나눠 검색 단위를 만들고, 문자 n-gram 벡터로 벡터 검색의 원리와 한계를 실험하고, 키워드 검색과 순위 결합(RRF)하는 방법, 벡터 저장 크기와 검색 시간을 측정해 설명합니다.

최종 수정 2026-10-28 · 읽는 시간 약 5분 · 운영 프리즘(Prism) · 편집 원칙

#키워드 검색의 한계

5장 5편에서 SQLite FTS5 trigram으로 한국어 부분 검색을 만들었습니다. 이 방식은 글자가 겹쳐야 찾습니다. "날씨 예보"로 검색했는데 대본에 "구름이 많고 오후 한때 비가 온다"만 있으면 글자가 하나도 겹치지 않아 찾지 못합니다. 이처럼 뜻은 같고 글자가 다른 말은 키워드 검색의 사각지대이고, 두 글자 검색어는 trigram MATCH로는 검색이 되지 않습니다(LIKE로 보완할 수 있지만 전체를 훑어 느립니다). 이를 보완하려고 문장을 의미를 담은 숫자 벡터로 바꿔 비교하는 방법이 임베딩 검색입니다.

#1단계: 검색 단위(chunk) 만들기

벡터로 만들 단위를 정해야 합니다. 대본의 문장 하나는 너무 짧아 문맥이 없고, 영상 전체는 너무 길어 어디인지 알 수 없습니다. 시간 창으로 겹치게 묶는 방식이 흔합니다. 이렇게 하면 결과를 곧바로 시간으로 연결할 수 있습니다.

def windows(segs, size=30.0, step=15.0):
    """segs: [(start, end, text)] -> 겹치는 시간 창으로 묶어 검색 단위(chunk)를 만든다."""
    total = max(e for _, e, _ in segs)
    out = []
    t = 0.0
    while t < total:
        sel = [s for s in segs if s[0] < t + size and s[1] > t]
        if sel:
            out.append((t, min(t + size, total), " ".join(s[2] for s in sel)))
        t += step
    return out

구간 5개(0~9, 9~18, 18~31, 31~46, 46~62초; 텍스트 a~e)로 30초 창을 15초씩 밀면 이렇게 묶입니다.

[(0, 30, 'a b c'), (15, 45, 'b c d'), (30, 60, 'c d e'), (45, 62, 'd e'), (60, 62, 'e')]
  • 창이 겹치므로 문장이 창 경계에서 잘려 검색에서 빠지는 일이 줄어듭니다. 대신 같은 내용이 여러 chunk에 들어가 색인이 커지고, 결과에서 같은 구간이 중복 노출되므로 시간이 겹치는 결과는 합쳐서 보여 줘야 합니다.
  • 마지막 e처럼 너무 짧은 꼬리 chunk는 앞 chunk에 합치는 규칙을 두면 좋습니다.

#2단계: 벡터와 코사인 유사도

문장을 벡터로 바꾸고, 질문도 같은 방식으로 벡터로 바꾼 뒤 방향이 얼마나 비슷한지(코사인 유사도) 로 순위를 매깁니다. 아래는 문자 2-gram을 단어로 보는 TF-IDF 벡터입니다. 밀집 임베딩과 같은 인터페이스(vec(text) → 정규화된 벡터 → 내적)를 갖게 만들었습니다.

import math
from collections import Counter
import numpy as np


def ngrams(text, n=2):
    t = text.replace(" ", "")
    return [t[i:i + n] for i in range(len(t) - n + 1)]


class CharVec:
    def __init__(self, docs):
        self.vocab = {g: i for i, g in enumerate(sorted({g for d in docs for g in ngrams(d)}))}
        df = Counter(g for d in docs for g in set(ngrams(d)))
        self.idf = np.zeros(len(self.vocab), dtype=np.float32)
        for g, i in self.vocab.items():
            self.idf[i] = math.log((1 + len(docs)) / (1 + df[g])) + 1

    def vec(self, text):
        v = np.zeros(len(self.vocab), dtype=np.float32)
        for g, c in Counter(ngrams(text)).items():
            if g in self.vocab:
                v[self.vocab[g]] = c * self.idf[self.vocab[g]]
        n = np.linalg.norm(v)
        return v / n if n else v

가상의 뉴스 문장 8개(광화문광장 축제 개막, 공예 장터, 세종대로 통제, 날씨, 체험 부스, 영상 상영회, 축구 결과, 서울역 표지판)를 색인하고 질문 5개를 던졌습니다.

질문 1위 2위 판정
도로 통제와 우회 서울역 방면 도로 표지판(0.236) 세종대로 일부 구간이 ...(0.168) 오답이 1위. 도로라는 글자가 겹친 표지판 문장이 올라옴
아이와 함께 갈 만한 행사 아이들이 체험 부스에서 ...(0.239) 점수 0.0 정답이지만 아이라는 글자가 겹친 덕
축구 결과 축구 경기에서 한빛이 ...(0.239) 점수 0.0 정답(글자가 겹침)
날씨 예보 점수 0.0 점수 0.0 전부 0. 날씨 문장(구름이 많고 ... 비가 온다)과 글자가 하나도 겹치지 않음
비 소식 점수 0.0 점수 0.0 전부 0. 같은 이유

두 가지가 보입니다.

  1. 문자 기반 벡터는 글자가 겹쳐야 점수가 생깁니다. "날씨 예보"로 "구름이 많고 비가 온다"를 찾는 의미 검색은 이런 벡터로는 되지 않습니다. 이 부분이 밀집 임베딩(학습된 의미 공간의 벡터)이 해결하려는 문제이며, 이 글에서는 실행해 보지 못했습니다.
  2. 점수가 0.0인 문서도 순위 목록에는 올라옵니다. 벡터 검색은 항상 "가장 가까운 K개"를 돌려주므로, 하한 임계값(예: 유사도 0.3 미만은 결과에서 제외)을 두지 않으면 관련 없는 결과가 채워집니다. 밀집 임베딩에서도 똑같이 필요한 장치이며, 임계값은 자기 데이터로 정해야 합니다.

#3단계: 키워드 검색과 순위 합치기

키워드 검색(FTS5)은 정확한 단어·고유명사에 강하고, 벡터 검색은 말바꿈·의미에 강합니다. 둘의 순위를 RRF(Reciprocal Rank Fusion)로 합치는 것이 흔한 방법입니다. 점수 척도가 다른 두 검색기를 점수 대신 순위로 합치기 때문에 정규화가 필요 없습니다.

from collections import Counter


def rrf(rankings, k=60):
    """rankings: 각 검색기의 문서 id 순위 목록들. 점수 = sum(1 / (k + 순위))."""
    score = Counter()
    for r in rankings:
        for pos, d in enumerate(r, 1):
            score[d] += 1 / (k + pos)
    return [d for d, _ in score.most_common()]

FTS5 trigram 색인과 위 벡터 검색을 합쳐 실행한 결과입니다(문서 번호가 결과).

질문 FTS5 벡터 상위 3 RRF 상위 3
세종대로 [2] [2, 0, 1] [2, 0, 1]
상영회 [5] [5, 0, 2] [5, 0, 2]
체험 부스 [4] [4, 0, 2] [4, 0, 2]
도로 통제 [] (구절이 문서에 없음) [7, 2, 1] [7, 2, 1] (벡터 결과만 반영)
  • 단어가 그대로 있는 질문은 두 검색이 같은 문서를 1위로 지목해 RRF에서도 1위가 유지됩니다.
  • 도로 통제는 FTS5가 빈 결과를 내서 RRF가 벡터 순위에 그대로 의존했고, 앞서 본 오답 1위가 그대로 남았습니다. 순위 결합이 각 검색기의 오류를 마법처럼 고쳐 주지는 않습니다.
  • RRF 결과에도 0, 1번 같은 무관한 문서가 2~3위에 섞여 있습니다. 하한 임계값을 결합 전에 적용해야 합니다.
  • FTS5 trigram의 MATCH는 3글자 미만의 검색어를 찾지 못합니다(5장 5편). 같은 테이블에서 LIKE '%서울%'은 결과를 돌려주지만 전체를 훑는 방식이라 데이터가 커지면 느립니다. 2글자 질문은 LIKE나 벡터 검색이 받아야 합니다.

#저장 크기와 검색 시간

밀집 임베딩을 쓴다면 chunk마다 고정 길이 벡터(예: 1,024차원)를 저장합니다. 크기는 곱셈으로 미리 계산할 수 있습니다.

저장 형식 10만 chunk × 1,024차원
float32 409.6MB
float16 204.8MB
int8 102.4MB

10만 × 1,024 float32 행렬에서 질의 벡터 하나와 전부 비교(전수 내적)하고 상위 10개를 고르는 데 걸린 시간은 이 PC(CPU, NumPy 2.4.5)에서 중앙값 9.7ms(최소 9.1ms)였습니다. 메모리에 올려 둔 행렬과 질의 벡터의 내적, 상위 10개 선택만 잰 값이며 질의 임베딩 생성, DB 읽기, 권한 필터, 동시 요청은 포함하지 않았습니다(CPU 코어·스레드 수도 기록하지 않았습니다). 이 규모에서는 검색 계산 자체는 전수 비교로도 부담이 크지 않다는 뜻입니다. 1시간 영상 한 편이 30초 창·15초 간격이면 약 240 chunk이므로, 10만 chunk는 약 400시간 분량입니다. 그 이상(수백만 chunk)에서 근사 최근접 탐색 색인(ANN)을 검토하면 됩니다. 처음부터 전용 벡터 DB를 도입할 필요는 없습니다.

#정리

  • 검색 단위는 겹치는 시간 창으로 만들어 결과를 시간으로 연결합니다.
  • 문자 기반 벡터는 글자가 겹쳐야 점수가 나옵니다. 의미 검색에는 밀집 임베딩이 필요하지만 이 글에서는 실행하지 못했습니다.
  • 벡터 검색은 항상 K개를 돌려주므로 하한 임계값이 필요합니다.
  • 키워드와 벡터 순위는 RRF로 합칩니다. 10만 chunk(float32 약 410MB)의 메모리 내 전수 내적과 상위 10개 선택은 이 PC에서 약 10ms였습니다(질의 임베딩 생성, DB 읽기, 권한 필터, 동시 요청은 제외).

#참고 자료

링크는 2026-10-28에 열람이 되는 것을 확인했습니다. 규격과 도구 문서는 개정될 수 있으므로 적용하기 전에 최신 판을 확인하세요.