8.4 임베딩과 의미 검색 - 검색 단위, 벡터 검색, 키워드와의 순위 결합
대본을 시간 창으로 나눠 검색 단위를 만들고, 문자 n-gram 벡터로 벡터 검색의 원리와 한계를 실험하고, 키워드 검색과 순위 결합(RRF)하는 방법, 벡터 저장 크기와 검색 시간을 측정해 설명합니다.
#키워드 검색의 한계
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. 같은 이유 |
두 가지가 보입니다.
- 문자 기반 벡터는 글자가 겹쳐야 점수가 생깁니다. "날씨 예보"로 "구름이 많고 비가 온다"를 찾는 의미 검색은 이런 벡터로는 되지 않습니다. 이 부분이 밀집 임베딩(학습된 의미 공간의 벡터)이 해결하려는 문제이며, 이 글에서는 실행해 보지 못했습니다.
- 점수가 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에 열람이 되는 것을 확인했습니다. 규격과 도구 문서는 개정될 수 있으므로 적용하기 전에 최신 판을 확인하세요.