본문으로 건너뛰기
AIPida

임베딩과 벡터 DB 완전 가이드

임베딩 생성부터 인덱싱·하이브리드 검색·리랭킹·운영까지, 실제로 따라 할 수 있는 RAG 검색 레이어 구축 실전

실전AI 개발·

RAG든 시맨틱 검색이든 추천이든, 핵심은 결국 하나다. 의미를 벡터로 바꾸고(임베딩), 그 벡터들 사이에서 가장 가까운 것을 빠르게 찾는 것(ANN 검색). 그런데 대부분의 프로덕션 장애는 모델 선택이 아니라 그 사이의 디테일에서 터진다. 정규화를 안 해서 거리 함수가 어긋나고, HNSW 파라미터를 기본값으로 두는 바람에 재현율이 60%에서 멈추고, 청킹을 잘못해서 정작 답이 있는 문단이 검색되지 않는다.

이 가이드는 "임베딩이란 무엇인가"라는 추상적 설명을 최소화하고, 검색 레이어를 직접 구축·운영하는 개발자가 마주치는 결정들을 순서대로 다룬다. 두 가지 대표 스택을 실제 코드로 비교한다.

  • pgvector — 이미 Postgres를 쓰고 있다면 별도 인프라 없이 벡터 검색을 붙일 수 있다. 트랜잭션·조인·기존 권한 모델을 그대로 쓴다.
  • Qdrant — 벡터 검색에 특화된 전용 엔진. 페이로드 필터링, 양자화, 분산 확장에서 강하다.

다룰 범위는 다음과 같다.

단계핵심 결정흔한 실패
임베딩 생성모델·차원·정규화거리 함수 불일치
청킹크기·오버랩·경계답이 잘려 검색 안 됨
인덱싱HNSW vs IVFFlat, m·efrecall 60%에서 정체
필터링pre vs post filter필터 후 결과 0건
하이브리드BM25 + 벡터, RRF키워드 쿼리 누락
리랭킹cross-encodertop-k는 맞는데 top-1 틀림
평가recall@k, MRR, nDCG"좋아 보인다"로 배포
운영재인덱싱·버전·비용모델 교체 시 전면 재색인

각 섹션은 개념 → 실제 설정/코드 → 흔한 함정 → 베스트 프랙티스 순서로 구성했다. 코드는 Python(임베딩·평가)과 SQL(pgvector)을 기준으로 하되, 다른 언어에서도 그대로 옮길 수 있는 패턴 위주로 작성했다.

1. 임베딩의 실체 — 차원, 거리, 그리고 정규화라는 함정

임베딩은 텍스트(또는 이미지·코드)를 고정 길이의 실수 벡터로 바꾼 것이다. 의미가 비슷하면 벡터 공간에서 가깝다. 여기까지는 누구나 안다. 문제는 "가깝다"를 어떤 거리로 정의하느냐, 그리고 그게 인덱스 설정과 어긋나면 검색이 조용히 망가진다는 점이다.

세 가지 거리 함수

거리의미언제
Cosine벡터 사이 각도텍스트 임베딩 대부분의 기본값
Inner Product (dot)내적, 크기까지 반영정규화된 벡터면 cosine과 순위 동일, 더 빠름
L2 (Euclidean)직선 거리크기 정보가 의미를 가질 때

핵심 사실 하나. 벡터를 L2 정규화(단위 길이로)하면 cosine 유사도와 inner product의 순위가 정확히 같아진다. 그래서 많은 프로덕션 파이프라인이 임베딩을 미리 정규화해서 저장하고, 인덱스는 더 빠른 inner product로 돌린다.

정규화 — 안 하면 조용히 틀린다

가장 흔한 사고: 모델이 정규화되지 않은 벡터를 뱉는데, 인덱스는 cosine으로 만들고, 어떤 코드 경로에서는 dot product로 비교한다. 그러면 같은 쿼리에 대해 경로마다 다른 순위가 나온다.

import numpy as np

def l2_normalize(v: np.ndarray) -> np.ndarray:
    norm = np.linalg.norm(v, axis=-1, keepdims=True)
    # 0 벡터 방어 — 빈 입력이 0 벡터로 나오면 0으로 나눔
    norm = np.where(norm == 0, 1e-12, norm)
    return v / norm

# OpenAI text-embedding-3-small 은 이미 정규화되어 나오지만,
# 오픈소스 모델(e5, bge 등)은 모델/설정마다 다르다 — 항상 확인하고 강제 정규화
emb = l2_normalize(raw_embedding)
assert abs(np.linalg.norm(emb) - 1.0) < 1e-5

차원과 비용

차원이 클수록 표현력은 좋지만 저장·검색 비용이 선형으로 증가한다. 1536차원 float32는 벡터당 6KB다. 100만 개면 6GB — 인덱스까지 메모리에 올린다고 생각하면 만만치 않다.

최근 모델은 Matryoshka 표현 학습(MRL) 을 지원한다. 1536차원으로 학습됐지만 앞쪽 256·512차원만 잘라 써도 품질 손실이 작다. OpenAI text-embedding-3-*dimensions 파라미터가 이 원리다.

from openai import OpenAI
client = OpenAI()

resp = client.embeddings.create(
    model="text-embedding-3-small",
    input=["검색할 문서 내용"],
    dimensions=512,  # 1536 -> 512로 축소, 저장/검색 비용 1/3
)
emb = resp.data[0].embedding  # 이미 정규화되어 반환됨

함정 정리

  • 함정 1: 모델이 정규화 벡터를 준다고 가정 → 오픈소스 모델은 대부분 안 준다. 강제 정규화하라.
  • 함정 2: 인덱스 거리(cosine)와 쿼리 거리(L2)가 불일치 → 순위가 미묘하게 틀어지고 recall이 떨어지는데 에러는 안 난다.
  • 함정 3: 빈 문자열·공백만 입력 → 0 벡터 또는 무의미 벡터. 입력 전 trim·길이 검증.

베스트 프랙티스

  • 임베딩은 저장 시점에 정규화하고, 인덱스는 inner product로 통일.
  • 차원은 품질·비용 트레이드오프를 실측한 뒤 결정. 무조건 큰 차원이 답이 아니다.
  • 모델·차원·정규화 여부를 메타데이터로 row에 함께 저장(embedding_model, embedding_dim). 나중에 모델 교체할 때 어떤 row가 구버전인지 구분하는 유일한 방법이다.

2. 청킹 — 검색 품질의 80%는 여기서 결정된다

RAG 품질 문제의 절대다수는 임베딩 모델이 아니라 청킹에서 온다. 답이 들어 있는 텍스트가 검색되지 않으면, 그 뒤의 모든 최적화는 무의미하다. "top-1 정확도가 낮다"는 보고의 상당수는 추적해보면 "정답 문단이 청크 경계에서 반으로 잘렸다"로 귀결된다.

청크 크기의 트레이드오프

작은 청크(100~200 토큰)큰 청크(800~1500 토큰)
정밀한 매칭, top-k에 더 정확히 적중문맥 풍부, LLM이 답하기 쉬움
문맥 부족 → LLM이 단편만 봄한 청크에 여러 주제 섞임 → 임베딩 희석
청크 수 많음 → 인덱스·비용 증가정작 필요한 한 문장이 노이즈에 묻힘

실무 출발점은 400~800 토큰 + 10~20% 오버랩이다. 단, 이건 출발점이지 정답이 아니다. 문서 종류(코드·법률·대화·논문)마다 최적이 다르므로 반드시 평가셋으로 검증한다.

순진한 고정 길이 청킹의 문제

# 안티패턴: 문자 수로 무작정 자르기 → 문장/단어 중간에서 끊김
def bad_chunk(text, size=1000):
    return [text[i:i+size] for i in range(0, len(text), size)]

이러면 "...환불 정책은 구매 후 7일" / "이내에 가능합니다..." 처럼 핵심 정보가 두 청크로 쪼개진다. 어느 쪽도 온전한 답을 못 담는다.

경계를 존중하는 재귀적 청킹

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=600,          # 토큰 기준이 더 정확하지만 문자 기준도 무방
    chunk_overlap=100,
    separators=["\n\n", "\n", ". ", ", ", " ", ""],  # 큰 경계부터 시도
    length_function=len,
)
chunks = splitter.split_text(document_text)

separators는 위에서부터 시도한다. 문단(\n\n)으로 못 나누면 문장(. ), 그것도 안 되면 단어 단위로 폴백한다. 의미 경계를 최대한 보존한다.

오버랩이 필요한 이유

오버랩은 경계에 걸친 정보 손실을 막는다. 청크 N의 마지막 문장이 청크 N+1의 첫 문장으로도 들어가면, 경계에 답이 있어도 적어도 한 청크는 온전한 문맥을 갖는다.

컨텍스트 보강 — 청크에 출처 정보 주입

순수 본문만 임베딩하면 "이 표가 무슨 문서의 무슨 절인지"가 사라진다. 청크 앞에 경량 헤더를 붙이면 검색 품질이 올라간다.

def enrich_chunk(chunk: str, doc_title: str, section: str) -> str:
    return f"문서: {doc_title}\n섹션: {section}\n\n{chunk}"

더 나아간 기법으로 Contextual Retrieval(각 청크에 LLM이 한두 문장 요약 맥락을 붙이는 방식)이 있다. 비용은 들지만 모호한 청크(대명사·표·코드 조각)의 검색 적중률을 눈에 띄게 올린다.

함정 정리

  • 함정 1: 문자 수로 자르기 → 문장·코드·표가 깨진다.
  • 함정 2: 오버랩 0 → 경계 정보 손실. 반대로 과도한 오버랩(>50%)은 중복 청크가 top-k를 다 차지.
  • 함정 3: 표·코드블록을 텍스트와 같은 splitter로 처리 → 구조 파괴. Markdown·HTML은 구조 인식 splitter를 쓴다.

베스트 프랙티스

  • 문서 타입별로 청킹 전략을 분리(산문 / 코드 / 표 / FAQ).
  • 청크에 doc_id, section, position을 메타데이터로 저장 → 검색 후 원문 복원·인접 청크 확장에 필수.
  • 청킹 변경은 재임베딩이 필요한 비싼 작업이다. 초기에 평가셋으로 충분히 실험하고 고정하라.

3. pgvector 시작하기 — 설치, 스키마, 첫 검색

이미 Postgres를 운영 중이라면 pgvector가 가장 마찰 없는 선택이다. 벡터 컬럼이 일반 컬럼처럼 동작하므로, WHERE로 권한·테넌트·상태를 필터링하고 JOIN으로 원문을 가져오는 것을 트랜잭션 한 번에 할 수 있다. 별도 시스템 간 동기화 문제가 사라진다.

설치와 확장 활성화

-- Supabase는 대시보드에서 활성화 가능, 셀프호스팅은 확장 빌드 후
CREATE EXTENSION IF NOT EXISTS vector;

스키마 설계

CREATE TABLE documents (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    doc_id      text NOT NULL,        -- 원본 문서 식별자
    chunk_index int  NOT NULL,        -- 문서 내 청크 순서
    content     text NOT NULL,        -- 청크 원문 (검색 결과 복원·하이브리드용)
    embedding   vector(512) NOT NULL, -- 모델 차원과 정확히 일치해야 함
    metadata    jsonb DEFAULT '{}',   -- tenant_id, section, source 등
    emb_model   text NOT NULL,        -- 'text-embedding-3-small@512' 같은 버전 태그
    created_at  timestamptz DEFAULT now()
);

-- 자주 거는 필터는 jsonb 안에 두지 말고 정규 컬럼으로 빼는 게 인덱싱에 유리
CREATE INDEX ON documents (doc_id);

vector(512)의 차원은 모델 출력과 정확히 일치해야 한다. 다르면 INSERT가 에러난다 — 이건 차라리 친절한 실패다.

임베딩 적재

pgvector 벡터는 텍스트로 '[0.1,0.2,...]' 형식이다. Python 클라이언트(pgvector-python)를 쓰면 자동 변환된다.

import psycopg
from pgvector.psycopg import register_vector

conn = psycopg.connect("postgresql://...")
register_vector(conn)  # vector 타입 어댑터 등록 — 안 하면 문자열 처리

with conn.cursor() as cur:
    cur.execute(
        "INSERT INTO documents (doc_id, chunk_index, content, embedding, emb_model) "
        "VALUES (%s, %s, %s, %s, %s)",
        (doc_id, idx, chunk_text, emb_vector, "text-embedding-3-small@512"),
    )
conn.commit()

첫 유사도 검색

pgvector의 거리 연산자가 핵심이다.

연산자거리
<->L2
<#>음의 inner product (작을수록 가까움)
<=>cosine 거리 (1 - cosine 유사도)
-- 쿼리 임베딩과 가장 가까운 5개. cosine 거리 오름차순
SELECT id, content, 1 - (embedding <=> %(q)s) AS cosine_similarity
FROM documents
ORDER BY embedding <=> %(q)s   -- ORDER BY가 인덱스를 타려면 같은 연산자여야 함
LIMIT 5;

중요: ORDER BY에 쓰는 연산자가 인덱스를 만들 때 지정한 연산자 클래스와 같아야 인덱스를 탄다. cosine 인덱스인데 <->로 정렬하면 인덱스를 무시하고 풀스캔한다(느리지만 결과는 나온다 — 그래서 발견이 늦다).

함정 정리

  • 함정 1: register_vector 누락 → 벡터가 문자열로 들어가 INSERT 실패하거나 검색 오작동.
  • 함정 2: ORDER BY 연산자와 인덱스 연산자 클래스 불일치 → 인덱스 무시, 풀스캔. EXPLAIN ANALYZE로 확인 필수.
  • 함정 3: 차원 변경 시 컬럼 타입을 못 바꿈 → 모델 교체 시 새 컬럼/테이블 필요.

베스트 프랙티스

  • emb_model 버전 태그 컬럼을 처음부터 둔다. 모델 교체가 언젠가 반드시 온다.
  • 필터 자주 거는 키는 jsonb에서 정규 컬럼으로 승격.
  • 개발 단계에선 인덱스 없이 정확한 풀스캔으로 "정답"을 만들어두고, 인덱스 도입 후 recall을 그 정답과 비교한다(다음 섹션).

4. pgvector 인덱싱 — HNSW vs IVFFlat, 그리고 ef 튜닝

인덱스 없이도 검색은 된다. 다만 풀스캔이라 100만 row에서 수백 ms~초가 걸린다. 프로덕션은 ANN(근사 최근접) 인덱스가 필수다. pgvector는 두 종류를 제공한다.

HNSW vs IVFFlat

HNSWIVFFlat
구조다층 근접 그래프클러스터(리스트)로 분할
recall/속도일반적으로 더 좋음약간 낮음
빌드 시간/메모리느리고 무거움빠르고 가벼움
데이터 추가점진적 삽입 OK데이터 차오르면 재빌드 권장
빈 테이블에 생성가능데이터 있어야 클러스터링 의미

결론부터: 대부분 HNSW를 기본으로 쓴다. IVFFlat은 메모리가 빠듯하거나 빌드 속도가 중요할 때 고려한다.

HNSW 생성과 파라미터

-- cosine 정렬을 쓸 거면 vector_cosine_ops, dot이면 vector_ip_ops, L2면 vector_l2_ops
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
  • m: 각 노드의 최대 연결 수. 클수록 recall↑, 인덱스 크기·빌드 시간↑. 기본 16, 고품질 필요 시 24~48.
  • ef_construction: 빌드 시 탐색 후보 수. 클수록 그래프 품질↑(recall↑), 빌드 느려짐. 64~200.

검색 시 recall 다이얼 — hnsw.ef_search

빌드 파라미터는 고정이지만, 검색 시점에 recall/속도를 조절할 수 있다. 이게 가장 실용적인 튜닝 포인트다.

-- 세션/트랜잭션 단위로 설정. 기본 40
SET hnsw.ef_search = 100;  -- 높일수록 recall↑, 느려짐

SELECT id, content
FROM documents
ORDER BY embedding <=> %(q)s
LIMIT 10;

현장 튜닝 절차: ef_search를 40 → 100 → 200으로 올리며 recall@10과 p95 지연을 동시에 측정한다. recall이 목표(예: 0.95)에 도달하는 가장 낮은 값을 택한다. 무작정 200을 박으면 느려지기만 한다.

IVFFlat을 쓴다면 — lists와 probes

-- lists: 클러스터 수. 행 수 N에 대해 N/1000 (~100만이면 1000) 정도가 출발점
CREATE INDEX ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 1000);

SET ivfflat.probes = 10;  -- 검색 시 탐색할 클러스터 수. 많을수록 recall↑/느림

IVFFlat의 함정: 빈 테이블이나 데이터가 적을 때 인덱스를 만들면 클러스터가 엉망이 된다. 데이터를 충분히 적재한 뒤 인덱스를 생성하라.

빌드 성능 — maintenance_work_mem과 병렬

대량 인덱스 빌드는 메모리가 부족하면 디스크로 새며 극도로 느려진다.

SET maintenance_work_mem = '2GB';      -- 인덱스가 메모리에 들어가도록
SET max_parallel_maintenance_workers = 4;  -- 병렬 빌드
CREATE INDEX CONCURRENTLY ...;  -- 운영 중이면 테이블 잠금 회피

함정 정리

  • 함정 1: 인덱스 연산자 클래스(vector_cosine_ops)와 쿼리 연산자(<=>) 불일치 → 풀스캔. 항상 짝을 맞춘다.
  • 함정 2: ef_search 기본값(40)으로 두고 "recall이 낮다"고 결론 → 다이얼을 안 돌린 것.
  • 함정 3: IVFFlat을 빈 테이블에 생성 → 클러스터링 무의미, recall 폭락.
  • 함정 4: 인덱스 빌드 시 maintenance_work_mem 기본값 → 디스크 스필로 빌드가 몇 시간씩.

베스트 프랙티스

  • HNSW를 기본으로, m=16 / ef_construction=64에서 시작해 평가셋으로 조정.
  • recall은 검색 시 ef_search로 운영 중 조절 — 재빌드 없이 다이얼.
  • 인덱스 도입 전후 recall을 풀스캔 정답과 비교해 손실을 정량화하라.

5. 메타데이터 필터링 — pre-filter vs post-filter의 함정

실제 검색은 "가장 비슷한 것"이 아니라 "이 사용자가 접근 가능한, 이 테넌트의, 최근 90일 문서 중 가장 비슷한 것"이다. 즉 필터 + 벡터 검색이 동시에 걸린다. 여기서 ANN 인덱스의 구조적 한계가 드러난다.

문제의 본질

ANN 인덱스(HNSW)는 "전체 공간에서 가까운 k개"를 찾도록 설계됐다. 그런데 필터를 걸면 그 k개 중 상당수가 필터에서 탈락할 수 있다.

  • Post-filter(사후 필터): ANN으로 k개 뽑은 뒤 WHERE로 거른다 → 필터가 빡세면 결과가 0건이 될 수 있다. 예: top-10을 뽑았는데 전부 다른 테넌트.
  • Pre-filter(사전 필터): 필터 통과 집합 안에서만 ANN → 정확하지만, 순진하게 구현하면 인덱스를 못 쓰고 느려진다.

pgvector의 동작

pgvector에서 WHERE를 같이 쓰면 플래너가 상황에 따라 전략을 고른다.

SET hnsw.ef_search = 100;

SELECT id, content
FROM documents
WHERE tenant_id = %(tenant)s          -- 필터
  AND created_at > now() - interval '90 days'
ORDER BY embedding <=> %(q)s
LIMIT 10;

필터 선택도가 높으면(통과 행이 적으면) Postgres는 일반 인덱스로 후보를 좁힌 뒤 정렬하는 게 유리할 수 있다. 필터가 느슨하면 HNSW를 탄다. EXPLAIN ANALYZE로 실제 실행 계획을 반드시 확인하라 — "인덱스를 탈 거야"라는 가정이 가장 위험하다.

pgvector는 HNSW 필터링 시 충분한 결과를 못 채우면 내부적으로 후보를 더 탐색해 0건 문제를 완화하지만, 필터가 극단적으로 선택적이면 여전히 느려질 수 있다. 이럴 땐 부분 인덱스가 답이다.

-- 특정 테넌트/상태에 전용 HNSW 인덱스 — 핫 파티션에 효과적
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WHERE status = 'published';

Qdrant의 접근 — payload 인덱스와 filterable HNSW

Qdrant는 필터링을 1급 시민으로 다룬다. payload 필드에 인덱스를 만들고, HNSW 그래프 자체가 필터를 인지하도록 설계됐다(필터 조건을 만족하는 노드들 사이 추가 링크를 유지).

from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue

client = QdrantClient(url="http://localhost:6333")

# payload 인덱스 생성 — 필터 성능에 필수
client.create_payload_index(
    collection_name="docs",
    field_name="tenant_id",
    field_schema="keyword",
)

hits = client.query_points(
    collection_name="docs",
    query=query_vector,
    query_filter=Filter(
        must=[FieldCondition(key="tenant_id", match=MatchValue(value="acme"))]
    ),
    limit=10,
    with_payload=True,
).points

Qdrant는 필터 선택도에 따라 자동으로 전략(인덱스 prefilter vs HNSW 내 필터)을 고른다. payload 인덱스를 안 만들면 필터가 풀스캔이 되니 자주 거는 필터 필드는 반드시 payload 인덱스를 만든다.

함정 정리

  • 함정 1: 애플리케이션 코드에서 top-k 뽑은 뒤 메모리에서 필터링 → 빡센 필터에서 결과 0건. DB 레벨에서 필터하라.
  • 함정 2: 필터 필드에 인덱스 없음 → 필터 부분이 풀스캔.
  • 함정 3: "인덱스를 타겠지" 가정 → EXPLAIN ANALYZE 없이 배포. 실제론 풀스캔인 경우 빈번.

베스트 프랙티스

  • 자주 거는 필터는 인덱스(pgvector: 정규 컬럼+B-tree 또는 부분 인덱스 / Qdrant: payload index).
  • 매우 선택적인 핫 필터(테넌트·상태)는 부분 인덱스/별도 컬렉션으로 분리.
  • 필터+벡터 쿼리는 반드시 실제 데이터 규모로 EXPLAIN ANALYZE. 합성 소량 데이터의 계획은 운영과 다르다.

6. Qdrant 실전 — 컬렉션, 양자화, 그리고 메모리 절감

전용 벡터 엔진이 필요한 시점이 온다. 벡터가 수천만 개를 넘거나, 페이로드 필터링이 복잡하거나, 메모리 비용이 부담될 때다. Qdrant는 이 영역에서 강하다.

컬렉션 생성

from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance

client = QdrantClient(url="http://localhost:6333")

client.create_collection(
    collection_name="docs",
    vectors_config=VectorParams(
        size=512,                 # 임베딩 차원과 일치
        distance=Distance.COSINE, # COSINE | DOT | EUCLID
    ),
)

적재 — 배치 upsert

from qdrant_client.models import PointStruct

points = [
    PointStruct(
        id=i,
        vector=emb,
        payload={"doc_id": d["doc_id"], "tenant_id": d["tenant"], "content": d["text"]},
    )
    for i, (emb, d) in enumerate(zip(embeddings, docs))
]
# 큰 데이터는 청크로 나눠 upsert (한 번에 수백~수천 단위)
client.upsert(collection_name="docs", points=points, wait=True)

wait=True는 적재 완료를 기다린다(테스트·소량). 대량 적재는 wait=False로 던지고 인덱싱이 따라오게 한다.

양자화 — 메모리를 1/4~1/32로

Qdrant의 강력한 기능은 벡터 양자화다. float32(4바이트/차원)를 줄여 메모리에 더 많이 올린다.

방식압축품질 영향비고
Scalar(int8)4배작음가장 무난한 기본 선택
Binary32배큼(고차원·특정 모델에서 양호)1536차원급 + rescoring 조합
Product(PQ)가변중간설정 복잡
from qdrant_client.models import ScalarQuantization, ScalarQuantizationConfig, ScalarType

client.create_collection(
    collection_name="docs",
    vectors_config=VectorParams(size=512, distance=Distance.COSINE),
    quantization_config=ScalarQuantization(
        scalar=ScalarQuantizationConfig(
            type=ScalarType.INT8,
            quantile=0.99,      # 이상치 잘라 양자화 범위 안정화
            always_ram=True,    # 양자화 벡터를 RAM에 상주 → 빠른 1차 검색
        )
    ),
)

Rescoring — 양자화 정확도 회복

양자화는 근사다. 그래서 Qdrant는 양자화 벡터로 후보를 넓게 뽑고, 원본 벡터로 재정렬(rescore) 하는 2단계를 지원한다. 메모리는 아끼면서 정확도는 유지한다.

from qdrant_client.models import QuantizationSearchParams, SearchParams

hits = client.query_points(
    collection_name="docs",
    query=query_vector,
    limit=10,
    search_params=SearchParams(
        quantization=QuantizationSearchParams(
            rescore=True,    # 원본 벡터로 재정렬
            oversampling=2.0 # 양자화로 2배 더 뽑은 뒤 rescore
        )
    ),
).points

HNSW 파라미터

Qdrant도 HNSW를 쓴다. 컬렉션 또는 검색 단위로 조절한다.

from qdrant_client.models import HnswConfigDiff

client.update_collection(
    collection_name="docs",
    hnsw_config=HnswConfigDiff(m=16, ef_construct=100),
)
# 검색 시 ef
hits = client.query_points(
    collection_name="docs", query=query_vector, limit=10,
    search_params=SearchParams(hnsw_ef=128),
).points

함정 정리

  • 함정 1: payload 인덱스 없이 필터 → 풀스캔. 자주 거는 필드는 반드시 인덱스.
  • 함정 2: 양자화만 켜고 rescore 안 함 → 정확도 손실을 그대로 떠안음. 고정밀이 필요하면 rescore+oversampling.
  • 함정 3: 단건 upsert 반복 → 처참한 적재 속도. 항상 배치.
  • 함정 4: id로 랜덤 UUID를 쓰면서 멱등성 무시 → 재적재 시 중복. 결정적 ID(doc_id+chunk_index 해시) 권장.

베스트 프랙티스

  • 메모리 부담되면 Scalar(int8) + rescore가 품질/비용의 스윗스팟.
  • 멱등 적재를 위해 ID는 콘텐츠 기반 결정적 값으로.
  • 검색 hnsw_ef로 recall 다이얼, 컬렉션 빌드 파라미터는 보수적으로 시작.

7. 하이브리드 검색 — 벡터만으로는 부족하다 (BM25 + RRF)

벡터 검색은 의미는 잘 잡지만 정확한 토큰 매칭에 약하다. 제품 코드 XK-9920, 사람 이름, 약어, 코드 심볼처럼 "의미"보다 "문자 그대로"가 중요한 쿼리에서 순수 벡터 검색은 종종 헛다리를 짚는다. 반대로 BM25 같은 키워드 검색은 동의어·패러프레이즈에 약하다. 둘을 합치는 하이브리드가 거의 항상 단독보다 낫다.

두 신호의 상보성

쿼리 유형벡터가 강함BM25가 강함
"환불은 어떻게 하나요"O (의미)
"error code 0x80070005"O (정확 매칭)
"노트북" vs "랩탑"O (동의어)
희귀 고유명사·SKUO

Reciprocal Rank Fusion (RRF) — 점수 스케일 문제의 해법

벡터 유사도(0~1)와 BM25 점수(무한 범위)는 스케일이 달라서 직접 더하면 안 된다. RRF는 점수가 아니라 순위만 사용해서 이 문제를 우회한다. 구현이 간단하고 강건하다.

RRF_score(d) = Σ  1 / (k + rank_i(d))      # k는 보통 60

각 검색 결과에서 문서 d의 순위(rank)를 가져와 역수를 합친다. 양쪽에서 상위권인 문서가 최종 상위권이 된다.

def rrf_fuse(rank_lists: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
    """rank_lists: 각 검색기의 결과 ID 리스트(순위순). 반환: (id, score) 내림차순"""
    scores: dict[str, float] = {}
    for ranked in rank_lists:
        for rank, doc_id in enumerate(ranked):  # rank는 0부터
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

vector_ids = [h.id for h in vector_hits]   # 벡터 검색 결과(순위순)
keyword_ids = [r.id for r in bm25_hits]    # 키워드 검색 결과(순위순)
fused = rrf_fuse([vector_ids, keyword_ids])[:10]

pgvector에서 하이브리드 — 한 DB로 끝내기

Postgres는 전문검색(tsvector + ts_rank)을 내장한다. 같은 테이블에서 벡터와 키워드를 둘 다 돌리고 RRF로 합친다.

-- 전문검색 컬럼/인덱스
ALTER TABLE documents ADD COLUMN content_tsv tsvector
    GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED;
CREATE INDEX ON documents USING gin (content_tsv);
WITH vector_search AS (
    SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> %(q)s) AS rank
    FROM documents ORDER BY embedding <=> %(q)s LIMIT 40
),
keyword_search AS (
    SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank(content_tsv, query) DESC) AS rank
    FROM documents, plainto_tsquery('simple', %(q_text)s) query
    WHERE content_tsv @@ query LIMIT 40
)
SELECT COALESCE(v.id, k.id) AS id,
       COALESCE(1.0/(60 + v.rank), 0) + COALESCE(1.0/(60 + k.rank), 0) AS rrf
FROM vector_search v FULL OUTER JOIN keyword_search k USING (id)
ORDER BY rrf DESC LIMIT 10;

한국어 전문검색은 simple config로는 형태소 분석이 안 된다. 제대로 하려면 형태소 분석기(예: mecab 기반 확장)나 별도 분석 파이프라인이 필요하다. 영어/혼합 텍스트는 simple로도 토큰 매칭 효과를 본다.

Qdrant의 하이브리드

Qdrant는 sparse 벡터(BM25류)를 dense 벡터와 함께 한 컬렉션에 저장하고, Query APIprefetch + FusionQuery(RRF)로 서버 측에서 융합한다. 클라이언트에서 두 번 호출해 RRF를 직접 구현해도 결과는 동등하다.

함정 정리

  • 함정 1: 벡터 점수와 BM25 점수를 직접 가중합 → 스케일 불일치로 한쪽이 지배. RRF나 정규화된 점수를 써라.
  • 함정 2: 한국어/CJK에 영어 토크나이저 → 토큰화가 깨져 키워드 검색이 무력화.
  • 함정 3: 각 검색기의 LIMIT가 너무 작음 → 융합 전 후보 풀이 빈약. 보통 각 40~100 뽑아 융합 후 10으로 좁힌다.

베스트 프랙티스

  • 거의 모든 RAG는 하이브리드를 기본으로 시작하라. 추가 비용 대비 품질 이득이 크다.
  • RRF의 k=60은 강건한 기본값. 가중치를 주고 싶으면 검색기별 RRF에 계수를 곱한다.
  • 융합은 후보를 넉넉히(각 40~100) 뽑은 뒤 좁힌다.

8. 리랭킹 — top-k는 맞는데 top-1이 틀릴 때

검색기가 관련 문서를 top-20 안에는 넣는데, 정작 1등이 엉뚱한 경우가 많다. 임베딩은 쿼리와 문서를 각각 독립적으로 벡터화해 비교하는(bi-encoder) 방식이라, 미묘한 관련성 차이를 놓친다. 리랭커(cross-encoder) 는 쿼리와 문서를 함께 모델에 넣어 직접 관련도를 점수화한다. 훨씬 정확하지만 느리다 — 그래서 1차 검색으로 좁힌 소수에만 적용한다.

2단계 검색 파이프라인

쿼리 → [1차: 벡터/하이브리드 검색] → top-50 후보
      → [2차: cross-encoder 리랭킹]  → 재정렬된 top-5
      → LLM

bi-encoder는 빠르게 후보를 넓게 거르고(recall 확보), cross-encoder는 느리지만 정밀하게 순위를 바로잡는다(precision 확보). 역할 분담이 핵심이다.

로컬 cross-encoder 리랭킹

from sentence_transformers import CrossEncoder

# 다국어가 필요하면 다국어 리랭커 모델을 선택
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")

def rerank(query: str, candidates: list[dict], top_k: int = 5) -> list[dict]:
    pairs = [(query, c["content"]) for c in candidates]
    scores = reranker.predict(pairs)   # 각 (쿼리,문서) 쌍의 관련도 점수
    for c, s in zip(candidates, scores):
        c["rerank_score"] = float(s)
    return sorted(candidates, key=lambda c: c["rerank_score"], reverse=True)[:top_k]

candidates = hybrid_search(query, limit=50)  # 1차로 넉넉히
final = rerank(query, candidates, top_k=5)    # 2차로 좁힘

API 리랭커

자체 모델 운영이 부담이면 매니지드 리랭킹 API(예: Cohere Rerank, Jina Reranker 등)를 쓴다. 쿼리와 문서 리스트를 보내면 점수와 순위를 돌려준다. 인프라 없이 품질을 끌어올리는 가장 빠른 방법이다.

후보 수의 트레이드오프

1차 후보 수효과
너무 적음(top-5)정답이 1차에서 누락되면 리랭커가 살릴 수 없음
적당(top-30~50)recall 확보 + 리랭킹 비용 합리적
너무 많음(top-200)리랭킹 지연·비용 급증, 한계효용 감소

리랭커는 1차 검색이 정답을 후보에 넣었을 때만 도움이 된다. 1차 recall이 나쁘면 리랭킹으로 못 고친다. 그래서 1차는 하이브리드로 recall을 확보하는 게 중요하다.

함정 정리

  • 함정 1: 1차 후보가 너무 적어 정답 누락 → 리랭커가 살릴 대상이 애초에 없음.
  • 함정 2: 모든 결과에 리랭킹 → 지연 폭발. top-N 후보로 제한.
  • 함정 3: 영어 리랭커를 한국어에 사용 → 점수 신뢰도 저하. 다국어 모델을 쓴다.
  • 함정 4: 리랭킹을 동기 블로킹으로 → p95 지연 악화. 배치·타임아웃·폴백(리랭크 실패 시 1차 순위 사용)을 둔다.

베스트 프랙티스

  • 하이브리드(recall) → cross-encoder(precision) 2단계가 검색 품질의 사실상 표준.
  • 후보는 30~50에서 시작해 평가셋으로 조정.
  • 리랭킹 지연이 SLA를 위협하면 캐싱(동일 쿼리)·타임아웃·폴백을 설계.

9. 검색 품질 평가 — "좋아 보인다"로 배포하지 마라

이 가이드에서 가장 자주 건너뛰지만 가장 중요한 부분이다. 평가셋 없이 하는 튜닝은 도박이다. ef_search를 올렸을 때 진짜 좋아졌는지, 청킹을 바꿨을 때 회귀가 없는지, 모델을 교체할 가치가 있는지 — 숫자 없이는 알 수 없다.

평가셋 만들기

현실적인 출발점은 50~200개의 (쿼리, 정답 문서 ID) 쌍이다. 만드는 법:

  1. 실제 사용자 로그에서 대표 쿼리를 뽑는다(없으면 도메인 전문가가 작성).
  2. 각 쿼리에 대해 정답(relevant) 청크/문서 ID를 라벨링한다. LLM으로 초안 생성 후 사람이 검수하면 빠르다.
  3. 쿼리 유형을 다양하게: 사실 질의, 패러프레이즈, 키워드형, 모호한 질의, 답 없는 질의(negative).

핵심 지표

지표측정언제 본다
Recall@k정답이 top-k 안에 있는가RAG에서 가장 중요(LLM이 못 본 건 못 답함)
MRR첫 정답의 역순위 평균첫 정답이 얼마나 위에 오나
nDCG@k순위·등급 가중관련도 등급이 여러 단계일 때
Precision@ktop-k 중 관련 비율노이즈가 비싼 경우

RAG에서는 Recall@k가 1순위다. 정답 청크가 검색되지 않으면 LLM은 환각하거나 모른다고 한다. k는 LLM에 넣는 컨텍스트 개수에 맞춘다(예: top-5를 넣으면 recall@5).

평가 코드

def recall_at_k(results: list[str], relevant: set[str], k: int) -> float:
    topk = set(results[:k])
    return len(topk & relevant) / len(relevant) if relevant else 0.0

def mrr(results: list[str], relevant: set[str]) -> float:
    for rank, doc_id in enumerate(results, start=1):
        if doc_id in relevant:
            return 1.0 / rank
    return 0.0

def evaluate(search_fn, dataset, k=5):
    recalls, mrrs = [], []
    for item in dataset:               # item: {"query": str, "relevant": set[str]}
        results = search_fn(item["query"])  # 검색 결과 ID 리스트(순위순)
        recalls.append(recall_at_k(results, item["relevant"], k))
        mrrs.append(mrr(results, item["relevant"]))
    return {"recall@k": sum(recalls)/len(recalls), "mrr": sum(mrrs)/len(mrrs)}

# 변경 전후를 같은 평가셋으로 비교
print("vector only :", evaluate(vector_search, dataset, k=5))
print("hybrid      :", evaluate(hybrid_search, dataset, k=5))
print("hybrid+rerank:", evaluate(hybrid_rerank_search, dataset, k=5))

이렇게 쓴다 — A/B 비교 루프

모든 변경(청킹·모델·ef_search·하이브리드·리랭킹)은 같은 평가셋에 대한 숫자 변화로 정당화한다. 표로 누적하면 회귀를 즉시 본다.

구성recall@5MRRp95 지연
vector only0.710.5818ms
hybrid (RRF)0.860.6931ms
hybrid + rerank0.860.81140ms

위 표의 메시지: 하이브리드는 recall을 올리고(0.71→0.86), 리랭킹은 recall은 그대로지만 MRR을 올린다(정답을 더 위로). 지연 비용도 함께 본다.

함정 정리

  • 함정 1: 눈으로 몇 개 보고 "좋아졌다" → 확증 편향. 정량 지표로.
  • 함정 2: recall만 보고 지연 무시 → SLA 위반. 항상 품질·지연을 함께 표로.
  • 함정 3: 평가셋이 너무 쉬움(쿼리=문서의 일부 문장 그대로) → 비현실적으로 높은 점수. 패러프레이즈·모호 쿼리를 섞어라.
  • 함정 4: 평가셋 누수(튜닝에 쓴 쿼리로 최종 평가) → 과적합. 가능하면 홀드아웃 분리.

베스트 프랙티스

  • 작게라도(50개) 평가셋을 지금 만들어라. 없는 것보다 무한히 낫다.
  • CI에 검색 평가를 넣어 회귀를 자동 감지(임계치 미달 시 실패).
  • 운영 로그에서 실패 쿼리를 주기적으로 평가셋에 편입 → 평가셋이 살아 움직이게.

10. pgvector냐 Qdrant냐 — 의사결정 프레임

"무엇을 쓸까"는 규모·팀·기존 스택에 달렸다. 둘 다 좋은 도구다. 잘못된 선택은 대개 "전용 엔진이 멋있어 보여서" 또는 "Postgres로 다 되니까"라는 이유 없는 관성에서 온다.

빠른 결정표

기준pgvector 유리Qdrant 유리
기존 스택이미 Postgres 운영 중벡터가 독립 도메인
데이터 규모~수백만 벡터수천만~수억
트랜잭션·조인벡터+관계 데이터 함께벡터 위주
메모리 비용여유 있음양자화로 절감 필요
운영 부담새 시스템 추가 싫음전담 운영 가능
필터 복잡도단순~중간복잡한 payload 필터
DB 한 곳 선호검색 전담 가능

pgvector를 택해야 할 때

  • 이미 Postgres가 있고, 벡터가 기존 관계형 데이터와 강하게 엮인다(사용자·권한·주문에 붙은 임베딩).
  • 트랜잭션 일관성이 중요: 문서를 지우면 벡터도 같은 트랜잭션에서 사라져야 한다.
  • 규모가 수백만 벡터 수준이고, 운영 시스템을 늘리고 싶지 않다.
  • 이중 쓰기 문제를 피하고 싶다 — 별도 벡터 DB를 두면 Postgres와 벡터 DB 사이 동기화(삭제·업데이트 누락)가 영원한 골칫거리가 된다.

Qdrant를 택해야 할 때

  • 벡터가 수천만~수억 규모로, Postgres 단일 인스턴스로는 메모리·성능이 빠듯하다.
  • 양자화로 메모리 비용을 크게 줄여야 한다.
  • 복잡한 payload 필터, 다중 벡터(이름드 벡터), sparse+dense 하이브리드를 1급으로 다뤄야 한다.
  • 수평 확장(샤딩·복제)이 로드맵에 있다.

흔한 안티패턴

  • 조기 최적화: 벡터 1만 개에 분산 Qdrant 클러스터. 그 규모는 pgvector는 물론 인메모리로도 충분하다.
  • 이중 쓰기 무시: Postgres에 원문, 별도 벡터 DB에 임베딩을 두면서 삭제 동기화를 설계 안 함 → 유령 검색 결과(원문은 지웠는데 벡터는 남음).
  • 벤더 종속 회피 강박으로 추상화 과다: 둘 다 추상화하느라 양쪽의 강점(pgvector의 SQL, Qdrant의 양자화)을 다 못 씀.

마이그레이션 현실

pgvector → Qdrant(또는 반대) 이전은 가능하지만, 임베딩은 그대로 재사용할 수 있다는 점이 핵심이다(같은 모델·차원·정규화라면). 옮기는 건 벡터와 메타데이터지 임베딩을 다시 만드는 게 아니다. 따라서 임베딩 생성과 저장소를 코드에서 분리해두면 전환 비용이 크게 준다.

베스트 프랙티스

  • 작게 시작: 의심스러우면 pgvector. 마찰이 가장 적고 대부분의 규모를 감당한다.
  • 임베딩 생성 레이어를 저장소와 분리(인터페이스로). 나중에 엔진을 바꿔도 임베딩 파이프라인은 유지.
  • 이중 쓰기를 피하거나, 불가피하면 삭제·업데이트 동기화를 outbox 패턴으로 명시 설계.
  • 선택은 "멋"이 아니라 규모·필터 복잡도·기존 스택·운영 인력으로 한다.

11. 운영 — 재인덱싱, 모델 버저닝, 비용, 모니터링

검색 레이어는 한 번 만들고 끝이 아니다. 모델은 교체되고, 데이터는 늘고, 인덱스는 비대해진다. 운영 설계가 빠지면 6개월 뒤 "모델 바꾸려면 전면 재색인인데 다운타임이…" 같은 곤경에 빠진다.

모델 버저닝과 무중단 재임베딩

임베딩 모델을 바꾸면 기존 벡터와 새 벡터는 호환되지 않는다(다른 공간). 쿼리는 한 모델로만 임베딩되므로, 섞이면 검색이 망가진다. 안전한 전환:

  1. 새 모델용 별도 컬럼/컬렉션을 만든다(embedding_v2).
  2. 백그라운드 잡으로 전체를 새 모델로 재임베딩해 채운다(점진적, 운영 영향 최소).
  3. 100% 채워지고 평가셋에서 품질이 검증되면, 쿼리 경로를 v2로 전환.
  4. 구버전(embedding_v1) 제거.
-- 무중단 전환을 위한 병행 컬럼
ALTER TABLE documents ADD COLUMN embedding_v2 vector(1024);
-- 백필 완료 후 인덱스 생성, 검증, 쿼리 스위치, 이후 구컬럼 DROP

앞 섹션에서 emb_model 버전 태그를 row에 넣으라고 한 이유가 여기서 빛난다. 어떤 row가 아직 구모델인지 추적하는 유일한 수단이다.

데이터 변경과 인덱스 신선도

  • HNSW: 점진적 삽입/삭제를 지원하지만, 대량 삭제 후엔 그래프에 묘비(tombstone)가 쌓여 성능·정확도가 저하될 수 있다. 주기적 재인덱싱이나 REINDEX를 고려.
  • IVFFlat: 데이터 분포가 크게 바뀌면 클러스터가 낡는다. 대량 변경 후 재빌드.
  • pgvector에서 대량 업데이트 시 VACUUM으로 죽은 튜플 정리 — 안 하면 인덱스·테이블이 부풀어 느려진다.

비용 모델 — 어디서 돈이 새는가

비용 항목절감 레버
임베딩 API 호출차원 축소(MRL), 중복 청크 캐싱, 배치 호출
벡터 저장/메모리양자화(int8/binary), 차원 축소
검색 지연(컴퓨트)ef 튜닝, 후보 수 제한, 캐싱
리랭킹후보 수 제한, 동일 쿼리 캐싱

임베딩 비용의 숨은 누수: 변경되지 않은 문서를 매번 재임베딩하는 것. 콘텐츠 해시를 저장해두고 해시가 같으면 임베딩을 스킵하라.

import hashlib

def content_hash(text: str, model: str, dim: int) -> str:
    return hashlib.sha256(f"{model}:{dim}:{text}".encode()).hexdigest()

# 적재 전: 같은 해시가 이미 있으면 임베딩 API 호출을 건너뛴다
h = content_hash(chunk, "text-embedding-3-small", 512)
if not exists_in_db(h):
    emb = embed(chunk)
    store(h, chunk, emb)

모니터링 — 무엇을 볼 것인가

  • 검색 지연 p50/p95/p99: ef·후보 수 변경의 영향을 추적.
  • recall(샘플 평가셋 주기 실행): 데이터 증가·인덱스 노후로 조용히 떨어지는 recall을 잡는다.
  • 빈 결과율 / 저점수율: 갑자기 늘면 필터 버그·인덱스 손상·모델 불일치 신호.
  • 임베딩 API 에러·지연: 외부 의존성. 타임아웃·재시도·폴백 필요.
  • 인덱스 크기·메모리: 증가 추세로 재인덱싱·양자화 시점 판단.

함정 정리

  • 함정 1: 모델 교체를 in-place로 → 전환 중 구/신 벡터가 섞여 검색 붕괴. 병행 컬럼으로.
  • 함정 2: 대량 삭제/업데이트 후 VACUUM·재인덱싱 누락 → 점진적 성능 저하(원인 추적 어려움).
  • 함정 3: 변경 없는 콘텐츠 매번 재임베딩 → 비용·시간 낭비. 콘텐츠 해시로 스킵.
  • 함정 4: 이중 저장소(원문/벡터)에서 삭제 동기화 누락 → 원문 없는 유령 검색 결과.

베스트 프랙티스

  • emb_model 버전 태그 + 콘텐츠 해시를 처음부터 둔다(둘 다 나중에 추가하기 고통스럽다).
  • 모델 전환은 병행 컬럼 → 백필 → 검증 → 스위치 → 정리 5단계 무중단 절차로.
  • recall을 운영에서 주기적으로 측정해 "조용한 품질 저하"를 잡는다.
  • 비용 누수의 1순위는 불필요한 재임베딩 — 해시 캐싱으로 막는다.

마무리 체크리스트

검색 레이어를 프로덕션에 올리기 전 점검:

  • 임베딩 정규화 + 인덱스 거리 함수 일치 확인(EXPLAIN)
  • 청킹 전략을 평가셋으로 검증, 메타데이터(doc_id·section·position) 저장
  • HNSW 파라미터 + ef_search 다이얼을 recall/지연으로 튜닝
  • 필터 필드 인덱싱, 필터+벡터 쿼리 EXPLAIN ANALYZE
  • 하이브리드(BM25+벡터, RRF) 기본 적용
  • 리랭킹으로 top-1 품질 확보, 폴백 설계
  • 50개 이상 평가셋으로 recall@k·MRR 측정, CI에 회귀 가드
  • emb_model 버전 태그 + 콘텐츠 해시 캐싱
  • 모델 전환 무중단 절차, 삭제 동기화, 모니터링 대시보드