하이브리드 검색 (Hybrid Search)
하이브리드 검색 (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)을 만들어 실행할 수 있게 해 줘요.
하이브리드 검색 (Hybrid Search)
같은 데이터를 여러 표현(representation)으로 저장했을 때 가장 흔한 문제가 있어요. 각 표현별로 검색한 결과를 하나의 결과로 합치는 일이죠.
예를 들어 텍스트 검색에서는 밀집(dense) 벡터와 희소(sparse) 벡터를 함께 쓰는 게 유용해요. 밀집 벡터는 의미(semantic) 이해를 담당하고, 희소 벡터는 정확한 단어 매칭을 담당하거든요. 이 둘의 장점을 모두 얻으려면 검색 결과를 합쳐야 해요.
Qdrant에서는 서로 다른 쿼리 결과를 합치는(fusing) 방법으로 두 가지를 제공합니다: rrf 와 dbsf.
상호 순위 융합 (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 섹션에 포함된 상세 코드 블록은 본 수집본에서 완전히 추출되지 않았습니다. 해당 섹션의 정확한 예시 코드가 필요하다면 공식 페이지에서 직접 확인해 주세요.