하이브리드 검색 (Hybrid Search)

이 문서는 Qdrant 공식 문서의 "Hybrid and Multi-Stage Queries" 페이지를 바탕으로 정리한 한국어 요약입니다. 참조: https://qdrant.tech/documentation/concepts/hybrid-queries/

Qdrant는 하나의 포인트에 여러 개의 이름 붙은 벡터(named vectors)를 넣을 수 있어요. 이 덕분에 여러 검색을 조합하거나 검색을 여러 단계로 나누어 수행하는 방식이 가능해지는데, Qdrant는 이를 위한 유연하고 범용적인 인터페이스인 Query API를 제공합니다. 그 조합을 가능하게 하는 핵심 파라미터가 바로 prefetch예요. prefetch는 검색 안에서 하위 요청(sub-request)을 만들어 실행할 수 있게 해 줘요.

같은 데이터를 여러 표현(representation)으로 저장했을 때 가장 흔한 문제가 있어요. 각 표현별로 검색한 결과를 하나의 결과로 합치는 일이죠.

예를 들어 텍스트 검색에서는 밀집(dense) 벡터와 희소(sparse) 벡터를 함께 쓰는 게 유용해요. 밀집 벡터는 의미(semantic) 이해를 담당하고, 희소 벡터는 정확한 단어 매칭을 담당하거든요. 이 둘의 장점을 모두 얻으려면 검색 결과를 합쳐야 해요.

Qdrant에서는 서로 다른 쿼리 결과를 합치는(fusing) 방법으로 두 가지를 제공합니다: rrfdbsf.

상호 순위 융합 (Reciprocal Rank Fusion, RRF)

RRF는 각 검색 결과의 순위(rank) 를 기반으로 점수를 합치는 방식이에요. 공식은 다음과 같습니다:

$$ score(d\in D) = \sum_{r_d\in R(d)} \frac{1}{k + \frac{r_d + 1}{w_r} - 1} $$

  • $D$: 모든 결과에 걸친 포인트들의 집합
  • $R(d)$: 특정 문서 $d$에 대한 순위들의 집합
  • $k$: 상수 (기본값 2)
  • $r$: 한 소스에서 나온 순서화된 결과 집합
  • $r_d$: 순위 $r$에서 문서 $d$의 순위
  • $w_r$: 순위 $r$의 가중치 (기본값 1)

Qdrant는 0부터 시작하는 순위(zero-based rank) 를 사용해요. 즉 최상위 결과는 $r_d = 0$ 입니다.

$w_r$의 기본값이 1이기 때문에, 별도 가중치를 지정하지 않으면 공식은 원래의 RRF 함수로 단순화됩니다:

$$ score(d\in D) = \sum_{r_d\in R(d)} \frac{1}{k + r_d} $$

아래는 희소 벡터와 밀집 벡터를 각각 저장한 서로 다른 named vector에 대해 두 개의 prefetch를 실행하고, 그 결과를 RRF로 합치는 쿼리 예시입니다.

{
    "prefetch": [
        {
            "query": { "indices": [1, 42], "values": [0.22, 0.8] },
            "using": "sparse",
            "limit": 20
        },
        {
            "query": [0.01, 0.45, 0.67, ...], // <-- dense vector
            "using": "dense",
            "limit": 20
        }
    ],
    "query": { "rrf": {} }, // <--- reciprocal rank fusion with defaults
    "limit": 10
}
from qdrant_client import QdrantClient, models

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

client.query_points(
    collection_name="{collection_name}",
    prefetch=[
        models.Prefetch(
            query=models.SparseVector(indices=[1, 42], values=[0.22, 0.8]),
            using="sparse",
            limit=20,
        ),
        models.Prefetch(
            query=[0.01, 0.45, 0.67],  # <-- dense vector
            using="dense",
            limit=20,
        ),
    ],
    query=models.RrfQuery(rrf=models.Rrf()),
)
import { QdrantClient } from "@qdrant/js-client-rest";

const client = new QdrantClient({ host: "localhost", port: 6333 });

client.query("{collection_name}", {
    prefetch: [
        {
            query: { indices: [1, 42], values: [0.22, 0.8] },
            using: "sparse",
            limit: 20,
        },
        {
            query: [0.01, 0.45, 0.67],
            using: "dense",
            limit: 20,
        },
    ],
    query: { rrf: {} },
    limit: 10,
});

RRF 상수 k 설정하기

Available as of v1.16.0

공식의 상수 $k$를 설정하려면 rrf 객체에 k 값을 넣으면 됩니다.

POST /collections/{collection_name}/points/query
{
    "prefetch": [
        // 2+ prefetches here
    ],
    "query": { "rrf": { "k": 60 } }, // <--- parameterized reciprocal rank fusion
    "limit": 10
}
from qdrant_client import QdrantClient, models

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

client.query_points(
    collection_name="{collection_name}",
    prefetch=[  # 2+ prefetches here
    ],
    query=models.RrfQuery(rrf=models.Rrf(k=60)),
)

가중치가 적용된 RRF (Weighted RRF)

Available as of v1.17.0

두 검색원(retriever)에 똑같은 가중치를 주면, 더 약한 검색원이 결과를 끌어내릴 수 있어요. 이런 경우 평가 집합(evaluation set)에서 성능이 좋은 검색원에 더 큰 가중치를 주는 방식으로 대응할 수 있습니다.

결과 집합이 겹치지 않는(non-overlapping) 경우, 이 가중치 덕분에 첫 번째 집합의 결과 3개마다 두 번째 집합의 결과가 1개씩 나오게 돼요.

가중치는 숫자 배열로 제공되며, 각 가중치는 정의된 순서대로 해당 prefetch에 적용됩니다. 가중치의 개수는 prefetch의 개수와 일치해야 해요.

POST /collections/{collection_name}/points/query
{
    "prefetch": [
        // Prefetches here
    ],
    "query": {
        "rrf": {
            "weights": [3.0, 1.0]
        }
    },
    "limit": 10
}
from qdrant_client import QdrantClient, models

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

client.query_points(
    collection_name="{collection_name}",
    prefetch=[  # 2+ prefetches here
    ],
    query=models.RrfQuery(rrf=models.Rrf(weights=[3.0, 1.0])),
)
import { QdrantClient } from "@qdrant/js-client-rest";

const client = new QdrantClient({ host: "localhost", port: 6333 });

client.query("{collection_name}", {
    prefetch: [  // Prefetches here
    ],
    query: { rrf: { weights: [3.0, 1.0] } },
    limit: 10,
});

가중치는 임의로 조정할 수 있는 값이 아니라 설정(구성) 결정에 가까워요. 가장 확실한 설정 방법은 자신의 데이터에서 실험해 보는 것입니다.

  • Choosing a Fusion Method notebook은 train/val 분할에 맞춰 쓸 수 있는 재사용 가능한 tune_rrf_weights 그리드 서치 헬퍼를 제공해요.
  • 평가 집합(eval set)이 없다면 가중치는 기본값 (1.0, 1.0)으로 두세요.

분포 기반 점수 융합 (Distribution-Based Score Fusion, DBSF)

Available as of v1.11.0

DBSF는 각 쿼리의 원래 점수를 유지하되, 결합 전에 그 분포를 정규화합니다.

각 검색원이 반환한 집합에 대해 평균 $\mu$와 표본 표준편차 $\sigma$를 계산하고, 3-sigma 극값을 경계점으로 삼아 모든 점수를 정규화합니다:

$$ \hat{s} = \frac{s - (\mu - 3\sigma)}{6\sigma} $$

정규화된 점수는 검색원들 사이에서 합산됩니다. 각 검색원이 같은 비교 범위에 기여하기 때문에, 점수 크기의 차이는 더 이상 문제가 되지 않아요.

dbsf상태를 갖지 않고(state-less), 정규화 한계를 지금까지 본 모든 점수 중에서가 아니라 이번 쿼리가 반환한 포인트들에서 계산합니다. 점수는 [0, 1]로 클리핑되지 않아요 — 3-sigma 범위 밖의 값은 리맵 뒤에도 범위 밖에 남습니다.

만약 반환된 점수가 전부 동일하거나(또는 한 포인트만 반환된다면) DBSF는 0으로 나누는 대신 0.5를 출력합니다.

다음 두 가지 주의점이 있어요. 통계가 prefetch top-k(작은 표본) 에서 나온다는 점, 그리고 그 top-k에 단일 지배적 이상치(outlier)가 있으면 해당 쿼리의 정규화가 왜곡될 수 있다는 점입니다. 랭킹이 불안정하다면 prefetch limit을 늘려보세요.

POST /collections/{collection_name}/points/query
{
    "prefetch": [
        {
            "query": {
                "indices": [1, 42],   // <┐
                "values": [0.22, 0.8] // <┴─ sparse vector
            },
            "using": "sparse",
            "limit": 20
        },
        {
            "query": [0.01, 0.45, 0.67, ...], // <-- dense vector
            "using": "dense",
            "limit": 20
        }
    ],
    "query": { "fusion": "dbsf" }, // <--- distribution-based score fusion
    "limit": 10
}
from qdrant_client import QdrantClient, models

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

client.query_points(
    collection_name="{collection_name}",
    prefetch=[
        models.Prefetch(
            query=models.SparseVector(indices=[1, 42], values=[0.22, 0.8]),
            using="sparse",
            limit=20,
        ),
        models.Prefetch(
            query=[0.01, 0.45, 0.67],  # <-- dense vector
            using="dense",
            limit=20,
        ),
    ],
    query=models.FusionQuery(fusion=models.Fusion.DBSF),
)
import { QdrantClient } from "@qdrant/js-client-rest";

const client = new QdrantClient({ host: "localhost", port: 6333 });

client.query("{collection_name}", {
    prefetch: [
        {
            query: { indices: [1, 42], values: [0.22, 0.8] },
            using: "sparse",
            limit: 20,
        },
        {
            query: [0.01, 0.45, 0.67],
            using: "dense",
            limit: 20,
        },
    ],
    query: { fusion: "dbsf" },
    limit: 10,
});

융합 방식 고르기 (Choosing a Fusion Method)

특정 쿼리에서 원래 점수 크기(raw magnitude)가 더 큰 검색원이 결과를 지배할 수 있는데, RRF는 순위(rank) 를 사용해 이 문제를 우회하고, DBSF는 분포를 정규화해서 우회해요.

다단계 쿼리 (Multi-Stage Queries)

일반적으로 더 큰 벡터 표현일수록 검색 결과가 더 정확하지만, 계산 비용도 더 커지게 마련이에요.

이 비용 문제를 완화하는 잘 알려진 기법이 바로 검색을 두 단계로 나누는 것입니다.

  • 첫째, 더 작고 저렴한 표현을 사용해 후보(candidate) 목록을 크게 뽑아요.
  • 둘째, 더 크고 정확한 표현으로 그 후보들을 다시 점수화(re-score) 해요.

이 아이디어를 바탕으로 검색 구조를 만드는 방법은 여러 가지가 있습니다.

재점수화 예시 (Re-Scoring Examples)

(원문의 Re-Scoring Examples 예시 코드 참조)

공식 쿼리로 커스텀 점수화 (Custom Scoring with a Formula Query)

(원문의 Custom Scoring with a Formula Query 예시 코드 참조)

그룹핑 (Grouping)

참고: 하이브리드 검색 파이프라인에서 그룹핑을 사용하는 완전한 end-to-end 예시는 Multi-Representation Search 튜토리얼에서 확인할 수 있어요.


참고 (확인 필요): 원문 페이지의 Re-Scoring Examples와 Custom Scoring 섹션에 포함된 상세 코드 블록은 본 수집본에서 완전히 추출되지 않았습니다. 해당 섹션의 정확한 예시 코드가 필요하다면 공식 페이지에서 직접 확인해 주세요.