Field Log · Entry

Dense Retrieval 기초: Bi-Encoder·Contrastive Learning·DPR (4/10)

Query encoder와 passage encoder가 vector를 만들고 positive는 가깝게 negative는 멀게 학습한 뒤 dot product로 검색하는 dense retrieval

오늘의 결론

  • Dense retrieval은 query와 passage를 미리 비교 가능한 고정 길이 vector로 바꿉니다.
  • Bi-Encoder는 passage vector를 offline에 계산할 수 있어 큰 corpus의 1차 검색에 적합합니다.
  • contrastive learning은 positive를 가깝게 하는 것만큼 “어떤 negative를 멀게 할지”가 중요합니다.
  • prefix, pooling, normalization, similarity metric은 model card와 index가 공유하는 계약입니다.
  • ANN을 붙이기 전에 모든 vector를 비교하는 exact search로 model 품질을 검증합니다.

앞 글의 BM25는 query와 문서가 공통 term을 가질 때 강합니다. 그러나 다음 두 문장은 단어가 많이 겹치지 않아도 같은 의도를 가집니다.

질문: 계정에 다시 들어가려면 어떻게 해야 하나요?
문서: 사용자 인증 복구 절차와 비밀번호 재설정 방법

Dense retrieval은 이 둘을 가까운 vector로 표현하도록 encoder를 학습합니다.

Bi-Encoder의 contrastive training과 dense retrieval inference

그림 1. 학습에서는 query-positive-negative의 상대적 거리를 조정하고, 검색에서는 passage vector를 미리 저장해 query vector와 빠르게 비교한다.


Dense vector는 무엇을 압축하는가

BM25의 sparse 표현은 vocabulary 차원이 크고 대부분 0입니다. Dense encoder는 text를 보통 수백~수천 개의 실수로 압축합니다.

"사용자 인증 복구 절차"
→ Transformer encoder
→ [0.018, -0.241, 0.097, ..., 0.133]  # 예: 384차원

각 차원을 사람이 “인증”, “복구”라고 직접 해석할 수는 없습니다. 학습 데이터에서 관련 query-passage가 가까워지도록 전체 좌표계가 조정됩니다.

고정 길이 vector 하나는 빠른 검색을 가능하게 하지만 text의 모든 세부 정보를 보존하지는 않습니다. 숫자 한 자리, 부정 표현, 희귀 코드가 압축 과정에서 약해질 수 있습니다.

Bi-Encoder 구조

Bi-Encoder 또는 dual encoder는 query와 passage를 별도로 encoding합니다.

q = E_query("계정 다시 들어가기")
p = E_passage("사용자 인증 복구 절차")

score(q, p) = q · p

두 encoder는 다음 중 하나일 수 있습니다.

  • 같은 parameter를 공유하는 하나의 encoder
  • 구조는 같지만 parameter가 다른 query/passsage encoder
  • 같은 model에 서로 다른 query/passage instruction을 붙이는 방식

핵심은 query와 각 passage를 함께 Transformer에 넣지 않는다는 점입니다.

왜 1차 검색에 빠른가

passage는 corpus가 바뀔 때만 encoding합니다.

Offline
p₁ = E_passage(document 1) ─┐
p₂ = E_passage(document 2)  ├→ vector index
...                          │
pₙ = E_passage(document n) ─┘

Online
question → E_query → q → q와 저장된 p 비교 → top-k

질문마다 모든 query × passage 쌍을 Transformer에 넣는 Cross-Encoder와 다릅니다. 이 속도와 정밀도의 교환은 reranking 편에서 다시 봅니다.

DPR이 보여 준 dense passage retrieval

Dense Passage Retrieval(DPR)은 open-domain QA에서 question encoder와 passage encoder를 사용하고, dot product로 관련 passage를 검색했습니다.

sim(q, p) = E_Q(q)ᵀ E_P(p)

질문과 정답 passage pair로 학습하며 BM25에서 가져온 hard negative 등 여러 negative를 사용합니다. DPR의 중요한 메시지는 “pretrained BERT를 그대로 vector로 쓰면 된다”가 아니라, retrieval 목적과 negative로 encoder를 학습해야 한다는 것입니다.

Contrastive learning을 식으로 읽는다

query q에 positive passage p⁺ 하나와 negative p₁⁻ ... pₘ⁻가 있다고 합시다.

한 형태의 InfoNCE loss는 다음과 같습니다.

                 exp(sim(q, p⁺) / τ)
L = -log ─────────────────────────────────
         exp(sim(q, p⁺) / τ) + Σ exp(sim(q, pⱼ⁻) / τ)
  • sim: dot product 또는 cosine similarity
  • τ: temperature, score 차이의 민감도 조절
  • 목표: positive의 softmax 확률을 높임

gradient는 positive score를 올리고 경쟁 negative score를 낮춥니다. positive만 독립적으로 가깝게 만드는 것이 아니라 후보 집합 안에서 상대 순위를 학습합니다.

Negative가 학습 문제를 결정한다

Random negative

corpus에서 무작위 passage를 고릅니다. 대부분 query와 너무 달라 쉽게 구분됩니다.

q: 비밀번호 재설정
negative: GPU 메모리 최적화

초기 학습에는 도움이 되지만 실전의 혼동을 충분히 가르치지 못합니다.

In-batch negative

batch의 다른 positive passage를 현재 query의 negative로 재사용합니다.

batch = [(q₁,p₁⁺), (q₂,p₂⁺), (q₃,p₃⁺)]

q₁의 후보: p₁⁺, p₂⁻, p₃⁻
q₂의 후보: p₂⁺, p₁⁻, p₃⁻

추가 passage encoding 없이 negative 수를 늘릴 수 있습니다. 그러나 같은 의미의 passage가 다른 pair에 있으면 false negative가 됩니다.

Hard negative

BM25나 이전 dense model이 높게 올렸지만 정답은 아닌 passage입니다.

q: E104 연결 시간 초과 해결
positive: E104 timeout 재시도 설정
hard negative: E104 인증 토큰 만료 해결

표면 term은 비슷하지만 원인이 다른 문서를 구분하도록 학습합니다.

False negative

label에는 없지만 실제로 답을 지지하는 passage를 negative로 쓰면 model에 잘못된 신호를 줍니다.

gold positive: 비밀번호 재설정 안내 v3
unlabeled passage: 같은 내용을 설명한 FAQ

hard negative mining 뒤에는 중복·동일 source·LLM/human relevance 검수 정책이 필요합니다.

Dot product와 cosine similarity

두 vector q, p의 cosine similarity는 다음과 같습니다.

cos(q,p) = (q · p) / (||q|| ||p||)

vector를 L2 normalize하면 norm이 1이므로 다음이 같습니다.

normalize(q) · normalize(p) = cosine(q,p)

하지만 model이 vector norm 자체에 정보를 담도록 dot product로 학습했다면 검색 시 임의로 normalize하면 ranking이 달라질 수 있습니다. 반대로 cosine용 model에서 passage만 normalize하고 query는 하지 않는 것도 오류입니다.

model이 학습된 similarity와 model card 권장 설정을 그대로 따릅니다.

Pooling은 token vector를 한 vector로 만드는 규칙이다

Transformer는 token마다 hidden vector를 출력합니다.

h₁, h₂, h₃, ..., hₜ
              ↓ pooling
          sentence vector

대표 방식은 다음과 같습니다.

방식아이디어주의점
CLS pooling첫 special token vector 사용해당 방식으로 학습돼야 함
mean pooling유효 token vector 평균padding mask 필수
weighted pooling위치·학습 가중치 적용model 구현과 일치 필요
last token마지막 유효 token 사용decoder 계열에서 사용되기도 함

mean pooling에서 padding까지 평균하면 짧은 text가 padding 길이에 따라 달라집니다.

import torch

def mean_pool(last_hidden_state, attention_mask):
    mask = attention_mask.unsqueeze(-1).to(last_hidden_state.dtype)
    summed = (last_hidden_state * mask).sum(dim=1)
    counts = mask.sum(dim=1).clamp(min=1e-9)
    return summed / counts

library가 제공하는 encode()를 쓰면 model별 pooling 설정을 함께 불러오는 경우가 많습니다. 그래도 loaded configuration을 log에 남깁니다.

Query와 passage prefix는 의미 없는 장식이 아니다

E5 계열처럼 다음 형식을 요구하는 model이 있습니다.

query: 계정에 다시 들어가는 법
passage: 사용자 인증 복구 절차와 비밀번호 재설정 방법

다른 model은 instruction template을 사용하거나 prefix가 필요 없습니다. 잘못된 prefix를 쓰면 학습 때와 다른 입력 분포가 됩니다.

index를 만들 때 prefix 없이 passage를 encoding하고 query 때만 올바른 prefix를 붙이는 식의 불일치는 code상 오류 없이 성능을 떨어뜨립니다.

QUERY_PREFIX = "query: "
PASSAGE_PREFIX = "passage: "
MODEL_ID = "intfloat/multilingual-e5-small"

def format_query(text: str) -> str:
    return QUERY_PREFIX + text.strip()

def format_passage(text: str) -> str:
    return PASSAGE_PREFIX + text.strip()

model ID뿐 아니라 format version도 index manifest에 기록합니다.

Sentence Transformers로 exact baseline 만들기

작은 corpus에서 모든 passage와 직접 dot product를 계산합니다.

from dataclasses import dataclass
import numpy as np
from sentence_transformers import SentenceTransformer

MODEL_ID = "intfloat/multilingual-e5-small"
model = SentenceTransformer(MODEL_ID)

@dataclass(frozen=True)
class Passage:
    chunk_id: str
    text: str

@dataclass(frozen=True)
class DenseHit:
    passage: Passage
    score: float
    rank: int

passages = [
    Passage("p1", "사용자 인증 복구 절차와 비밀번호 재설정 방법"),
    Passage("p2", "GPU memory 사용량과 batch size 조정"),
    Passage("p3", "계정 잠금 해제와 복구 이메일 확인"),
]

passage_matrix = model.encode(
    [f"passage: {p.text}" for p in passages],
    batch_size=64,
    normalize_embeddings=True,
    convert_to_numpy=True,
).astype(np.float32)

def search(query: str, k: int = 2) -> list[DenseHit]:
    if not 1 <= k <= len(passages):
        raise ValueError("k is outside index size")

    query_vector = model.encode(
        [f"query: {query}"],
        normalize_embeddings=True,
        convert_to_numpy=True,
    )[0].astype(np.float32)

    scores = passage_matrix @ query_vector
    order = np.argsort(-scores)[:k]
    return [
        DenseHit(passages[index], float(scores[index]), rank)
        for rank, index in enumerate(order, start=1)
    ]

for hit in search("계정에 다시 들어가려면 어떻게 해야 하나요?"):
    print(hit)

이 baseline에는 ANN recall 손실이 없습니다. 정답을 못 찾으면 먼저 embedding, chunk, prefix, truncation을 봅니다.

Batch encoding과 index 산출물

production ingestion에서는 vector만 저장하지 않습니다.

passage matrix row 0 ↔ chunk_id p1
passage matrix row 1 ↔ chunk_id p2
passage matrix row 2 ↔ chunk_id p3

row mapping과 manifest를 원자적으로 versioning합니다.

{
  "index_version": "dense-2026-07-16-01",
  "model_id": "intfloat/multilingual-e5-small",
  "model_revision": "pinned-commit-sha",
  "pooling": "model-default",
  "normalize_embeddings": true,
  "similarity": "dot_on_unit_vectors",
  "query_format_version": "e5-query-v1",
  "passage_format_version": "e5-passage-v1",
  "dimension": 384,
  "dtype": "float32",
  "chunker_version": "structure-token-v3",
  "vector_count": 3
}

모델 이름만 같아도 remote revision이 바뀌면 vector가 달라질 수 있습니다. 가능한 한 revision을 pin합니다.

Vector 저장 비용 계산

vector N개, 차원 d, 숫자당 byte B라면 raw vector 크기는 다음과 같습니다.

memory = N × d × B

예를 들어 100만 개의 768차원 float32 vector는 다음과 같습니다.

1,000,000 × 768 × 4 bytes
= 3,072,000,000 bytes
≈ 3.07 GB (decimal)

여기에 ID mapping, metadata, graph edge, allocator와 replica 비용이 추가됩니다. float16, scalar quantization, product quantization은 저장량을 줄이지만 ranking 오차를 만들 수 있습니다.

긴 passage와 truncation

encoder의 최대 입력보다 긴 chunk는 조용히 잘릴 수 있습니다.

def token_length(text: str) -> int:
    return len(model.tokenizer(
        text,
        add_special_tokens=True,
        truncation=False,
    )["input_ids"])

ingestion에서 다음을 기록합니다.

  • 원래 token 수
  • 실제 model 입력 token 수
  • truncated 여부
  • 앞/뒤 어느 부분이 잘렸는지

정답이 chunk 뒤에 있는데 앞부분만 encoding됐다면 vector DB parameter로 고칠 수 없습니다.

Dense model을 고르는 기준

leaderboard 평균 하나만 보고 선택하지 않습니다.

질문
언어한글·다국어 query와 passage를 지원하는가?
domain코드·법률·의료 등 내 corpus에 맞는가?
taskasymmetric query-passage retrieval로 평가됐는가?
length최대 token과 긴 문서 처리 방식은?
dimension저장·ANN 비용을 감당할 수 있는가?
license상용 배포와 redistribution이 가능한가?
servinglatency, batch throughput, GPU/CPU 요구량은?
contractprefix, pooling, normalization이 명확한가?

MTEB와 BEIR은 넓은 비교 출발점이지만 production query 분포를 대신하지 않습니다. 내 evaluation set의 exact identifier, paraphrase, multilingual, table, temporal 질문 bucket으로 검증합니다.

Dense retrieval의 대표 실패

1. 희귀 식별자 혼동

E104 timeout 문서 ↔ E104 auth 문서 ↔ E140 timeout 문서

의미가 비슷해 가까워질 수 있습니다. BM25 exact field와 hybrid가 보완합니다.

2. 숫자와 부정 차이

30일 보존 ↔ 365일 보존
지원함 ↔ 지원하지 않음

전체 문맥은 비슷하지만 답은 반대입니다. 1차 dense 후보 뒤 reranker와 evidence 검증이 필요할 수 있습니다.

3. Domain shift

일반 web 질문으로 학습한 model이 사내 약어와 ticket 문체를 모를 수 있습니다. domain query-passage pair, hard negative로 fine-tuning하거나 hybrid를 사용합니다.

4. False similarity

주제는 같지만 질문의 조건을 만족하지 않는 passage가 올라옵니다. top-k text를 직접 보고 어느 조건이 무시됐는지 분류합니다.

5. Pipeline contract 불일치

query만 normalize, 잘못된 prefix, 다른 model revision, passage truncation처럼 code는 실행되지만 ranking이 무너지는 오류입니다.

Dense trace에 남길 것

{
  "question_id": "q-account-17",
  "query_text": "계정에 다시 들어가는 법",
  "query_token_count": 9,
  "model_revision": "...",
  "normalize": true,
  "similarity": "dot",
  "hits": [
    {"chunk_id": "p1", "rank": 1, "score": 0.843},
    {"chunk_id": "p3", "rank": 2, "score": 0.791}
  ],
  "index_version": "dense-2026-07-16-01"
}

embedding vector 전체를 일반 log에 남기는 것은 비용과 개인정보 측면에서 신중해야 합니다. 필요한 경우 접근 통제된 debug artifact로 저장합니다.

BM25와 같은 평가셋으로 비교한다

다음 네 run을 구분합니다.

A. BM25
B. dense exact search
C. dense ANN search  ← 다음 글
D. hybrid fusion     ← 그다음 글

먼저 A와 B를 query bucket별로 비교합니다.

bucketBM25 예상Dense 예상확인할 것
exact code높음변동identifier 보존
paraphrase낮을 수 있음높음의미 대응
numeric constraint보통혼동 가능hard negative
multilingualanalyzer 의존model 의존언어별 Recall

Dense가 전체 평균에서 좋아도 exact code regression이 크면 바로 대체하지 않습니다.

Production 체크리스트

  • query/passage encoder 또는 prefix 계약을 확인했다.
  • pooling과 padding mask 처리가 model 설정과 같다.
  • normalization과 similarity metric이 학습 방식과 같다.
  • model revision과 dimension을 index manifest에 pin했다.
  • passage truncation 비율과 위치를 기록한다.
  • vector row와 chunk ID mapping이 원자적으로 versioning된다.
  • exact search baseline의 Recall@k가 있다.
  • exact identifier·paraphrase·numeric·multilingual bucket을 따로 본다.
  • hard negative와 false negative를 구분한다.
  • BM25 baseline을 삭제하지 않고 비교한다.

스스로 확인하기

  1. Bi-Encoder가 Cross-Encoder보다 corpus 1차 검색에 유리한 이유는 무엇인가?
  2. in-batch negative는 비용을 어떻게 줄이며 false negative는 어떻게 만들 수 있는가?
  3. L2-normalized vector에서 dot product와 cosine이 같은 이유는 무엇인가?
  4. query prefix 없이 만든 vector가 왜 code 오류 없이 품질을 떨어뜨릴 수 있는가?
  5. dense exact search가 실패했을 때 ANN parameter부터 바꾸면 안 되는 이유는 무엇인가?

다음 글에서는 exact 비교를 대규모 corpus로 확장하는 ANN Vector Index의 Flat·HNSW·IVF·PQ 선택법을 다룹니다.

참고자료