검색 (Search)

가장 가까운 벡터를 찾는 검색은 많은 표상 학습(representational learning) 애플리케이션의 핵심이에요. 최신 신경망은 객체를 벡터로 변환하도록 학습되는데, 실제 세계에서 가까운 객체들이 벡터 공간에서도 가깝게 위치하도록 말이죠. 예를 들어 의미가 비슷한 텍스트, 시각적으로 유사한 그림, 같은 장르의 노래 같은 것들이 해당해요.

이렇게 해서 벡터 유사도가 동작해요.

Query API (쿼리 API)

v1.10.0부터 사용할 수 있어요.

Qdrant는 검색과 탐색 요청을 위한 단일 인터페이스인 Query API를 제공해요. Query API로 수행할 수 있는 쿼리 종류를 정리하면 다음과 같아요:

query 파라미터에 따라 Qdrant는 검색 전략을 다르게 선택할 수 있어요.

쿼리 유형 설명
최근접 이웃 검색 (Nearest Neighbors Search) 벡터 유사도 검색, k-NN이라고도 해요
ID로 검색 (Search By Id) 이미 저장된 벡터를 검색 — 임베딩 모델 추론을 건너뜀
추천 (Recommendations) 긍정·부정 예시를 제공해요
디스커버리 검색 (Discovery Search) 컨텍스트를 원샷 훈련 셋으로 삼아 검색을 안내해요
스크롤 (Scroll) 선택적 필터링과 함께 모든 포인트를 가져와요
그룹화 (Grouping) 특정 필드 기준으로 결과를 그룹화해요
Order By payload 키 기준으로 포인트를 정렬해요
하이브리드 검색 (Hybrid Search) 여러 쿼리를 결합해 더 나은 결과를 얻어요
멀티 스테이지 검색 (Multi-Stage Search) 대규모 임베딩을 위한 성능 최적화
랜덤 샘플링 (Random Sampling) 컬렉션에서 랜덤 포인트를 가져와요

최근접 이웃 검색 (Nearest Neighbors Search)

POST /collections/{collection_name}/points/query

{

    "query": [0.2, 0.1, 0.9, 0.7] // <--- Dense vector

}
client.query_points(
    collection_name="{collection_name}",
    query=[0.2, 0.1, 0.9, 0.7], # <--- Dense vector
)
import { QdrantClient } from "@qdrant/js-client-rest";

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

client.query("{collection_name}", {
    query: [0.2, 0.1, 0.9, 0.7], // <--- Dense vector
});
use qdrant_client::Qdrant;
use qdrant_client::qdrant::{Query, QueryPointsBuilder};

let client = Qdrant::from_url("http://localhost:6334").build()?;

client
    .query(
        QueryPointsBuilder::new("{collection_name}")
            .query(Query::new_nearest(vec![0.2, 0.1, 0.9, 0.7]))
    )
    .await?;
import static io.qdrant.client.QueryFactory.nearest;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Points.QueryPoints;
import java.util.List;

QdrantClient client = new QdrantClient(QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

client.queryAsync(QueryPoints.newBuilder()
  .setCollectionName("{collectionName}")
  .setQuery(nearest(List.of(0.2f, 0.1f, 0.9f, 0.7f)))
  .build()).get();
using Qdrant.Client;

var client = new QdrantClient("localhost", 6334);

await client.QueryAsync(
 collectionName: "{collection_name}",
 query: new float[] { 0.2f, 0.1f, 0.9f, 0.7f }
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.Query(context.Background(), &qdrant.QueryPoints{
	CollectionName: "{collection_name}",
	Query:          qdrant.NewQuery(0.2, 0.1, 0.9, 0.7),
})

ID로 검색 (Search By Id)

POST /collections/{collection_name}/points/query

{

    "query": "43cf51e2-8777-4f52-bc74-c2cbde0c8b04" // <--- point id

}
client.query_points(
    collection_name="{collection_name}",
    query="43cf51e2-8777-4f52-bc74-c2cbde0c8b04", # <--- point id
)
import { QdrantClient } from "@qdrant/js-client-rest";

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

client.query("{collection_name}", {
    query: '43cf51e2-8777-4f52-bc74-c2cbde0c8b04', // <--- point id
});
use qdrant_client::Qdrant;
use qdrant_client::qdrant::{PointId, Query, QueryPointsBuilder};

let client = Qdrant::from_url("http://localhost:6334").build()?;

client
    .query(
        QueryPointsBuilder::new("{collection_name}")
            .query(Query::new_nearest(PointId::from("43cf51e2-8777-4f52-bc74-c2cbde0c8b04")))
    )
    .await?;
import static io.qdrant.client.QueryFactory.nearest;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Points.QueryPoints;
import java.util.UUID;

QdrantClient client = new QdrantClient(QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

client.queryAsync(QueryPoints.newBuilder()
  .setCollectionName("{collectionName}")
  .setQuery(nearest(UUID.fromString("43cf51e2-8777-4f52-bc74-c2cbde0c8b04")))
  .build()).get();
using Qdrant.Client;

var client = new QdrantClient("localhost", 6334);

await client.QueryAsync(
    collectionName: "{collection_name}",
    query: Guid.Parse("43cf51e2-8777-4f52-bc74-c2cbde0c8b04")
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.Query(context.Background(), &qdrant.QueryPoints{
	CollectionName: "{collection_name}",
	Query:          qdrant.NewQueryID(qdrant.NewID("43cf51e2-8777-4f52-bc74-c2cbde0c8b04")),
})

지표 (Metrics)

벡터끼리의 유사도를 추정하는 방법은 여러 가지가 있어요. Qdrant 용어로는 이런 방법들을 지표(metric)라고 불러요. 지표의 선택은 얻어진 벡터, 특히 신경망 인코더의 훈련 방식에 따라 달라져요.

Qdrant가 지원하는 가장 대중적인 지표 유형은 다음과 같아요:

유사도 학습 모델에서 가장 흔히 쓰는 지표는 코사인(cosine)이에요.

Qdrant는 이 지표를 두 단계로 계산해서 더 높은 검색 속도를 얻어요. 첫 번째 단계는 컬렉션에 벡터를 추가할 때 정규화(normalize)하는 거예요. 이 작업은 벡터마다 한 번만 일어나요.

두 번째 단계는 벡터를 비교하는 거예요. 이때 비교는 내적(dot product)과 동일해지는데, SIMD 덕분에 아주 빠른 연산이 돼요.

쿼리 구성에 따라 Qdrant는 검색 전략을 달리 선택할 수 있어요. 자세한 내용은 쿼리 계획(query planning) 섹션에서 확인할 수 있어요.

Search API

검색 쿼리 예시를 하나 살펴볼게요.

REST API — API 스키마 정의는 여기에서 확인할 수 있어요.

POST /collections/{collection_name}/points/query

{

    "query": [0.2, 0.1, 0.9, 0.79],

    "filter": {

        "must": [

            {

                "key": "city",

                "match": {

                    "value": "London"

                }

            }

        ]

    },

    "params": {

        "hnsw_ef": 128,

        "exact": false

    },

    "limit": 3

}
from qdrant_client import QdrantClient, models

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

client.query_points(
    collection_name="{collection_name}",
    query=[0.2, 0.1, 0.9, 0.7],
    query_filter=models.Filter(
        must=[
            models.FieldCondition(
                key="city",
                match=models.MatchValue(
                    value="London",
                ),
            )
        ]
    ),
    search_params=models.SearchParams(hnsw_ef=128, exact=False),
    limit=3,
)
import { QdrantClient } from "@qdrant/js-client-rest";

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

client.query("{collection_name}", {
    query: [0.2, 0.1, 0.9, 0.7],
    filter: {
        must: [
            {
                key: "city",
                match: {
                    value: "London",
                },
            },
        ],
    },
    params: {
        hnsw_ef: 128,
        exact: false,
    },
    limit: 3,
});
use qdrant_client::qdrant::{Condition, Filter, QueryPointsBuilder, SearchParamsBuilder};

client
    .query(
        QueryPointsBuilder::new("{collection_name}")
            .query(vec![0.2, 0.1, 0.9, 0.7])
            .limit(3)
            .filter(Filter::must([Condition::matches(
                "city",
                "London".to_string(),
            )]))
            .params(SearchParamsBuilder::default().hnsw_ef(128).exact(false)),
    )
    .await?;
import static io.qdrant.client.ConditionFactory.matchKeyword;
import static io.qdrant.client.QueryFactory.nearest;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Common.Filter;
import io.qdrant.client.grpc.Points.QueryPoints;
import io.qdrant.client.grpc.Points.SearchParams;
import java.util.List;

QdrantClient client =
    new QdrantClient(QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

client.queryAsync(QueryPoints.newBuilder()
        .setCollectionName("{collection_name}")
        .setQuery(nearest(0.2f, 0.1f, 0.9f, 0.7f))
        .setFilter(Filter.newBuilder().addMust(matchKeyword("city", "London")).build())
        .setParams(SearchParams.newBuilder().setExact(false).setHnswEf(128).build())
        .setLimit(3)
        .build()).get();
using Qdrant.Client;
using Qdrant.Client.Grpc;
using static Qdrant.Client.Grpc.Conditions;

var client = new QdrantClient("localhost", 6334);

await client.QueryAsync(
    collectionName: "{collection_name}",
    query: new float[] { 0.2f, 0.1f, 0.9f, 0.7f },
    filter: MatchKeyword("city", "London"),
    searchParams: new SearchParams { Exact = false, HnswEf = 128 },
    limit: 3
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.Query(context.Background(), &qdrant.QueryPoints{
	CollectionName: "{collection_name}",
	Query:          qdrant.NewQuery(0.2, 0.1, 0.9, 0.7),
	Filter: &qdrant.Filter{
		Must: []*qdrant.Condition{
			qdrant.NewMatch("city", "London"),
		},
	},
	Params: &qdrant.SearchParams{
		Exact:  qdrant.PtrOf(false),
		HnswEf: qdrant.PtrOf(uint64(128)),
	},
})

이 예시에서는 [0.2, 0.1, 0.9, 0.7] 벡터와 유사한 벡터를 찾고 있어요. limit 파라미터(또는 별칭인 top)는 가져오려는 가장 유사한 결과의 개수를 지정해요.

params 키 아래의 값들은 검색에 쓰는 커스텀 파라미터를 지정해요. 현재 가능한 값은 다음과 같아요:

  • hnsw_ef — HNSW 알고리즘의 ef 파라미터를 지정하는 값이에요.

  • exact — 근사 검색(ANN)을 사용하지 않는 옵션이에요. true로 설정하면 전체 스캔을 수행해 정확한 결과를 가져오기 때문에 검색이 오래 걸릴 수 있어요.

  • indexed_only — 아직 벡터 인덱스가 만들어지지 않은 세그먼트에서는 검색을 끄는 옵션이에요. 컬렉션이 업데이트되는 동안 검색 성능에 미치는 영향을 최소화하고 싶을 때 유용해요. 컬렉션이 아직 완전히 인덱싱되지 않았다면 부분적인 결과가 나올 수 있으니, 결과적 일관성(eventual consistency)이 허용되는 경우에만 쓰는 걸 고려하세요.

  • quantization — 양자화(quantization)와 관련된 파라미터예요. Searching with Quantization 가이드를 참고하세요.

  • acorn — ACORN 검색 알고리즘과 관련된 파라미터예요.

  • idf — 스파스 벡터의 IDF 통계를 어떤 모집단(population)에 대해 계산할지 지정해요. Per-Tenant IDF Statistics를 참고하세요.

filter 파라미터를 지정하면 필터 조건을 만족하는 포인트에 대해서만 검색을 수행해요. 가능한 필터와 동작 방식의 상세는 필터링(filtering) 섹션에서 확인할 수 있어요.

이 API의 응답 예시는 다음과 같아요.

{

  "result": [

    { "id": 10, "score": 0.81 },

    { "id": 14, "score": 0.75 },

    { "id": 11, "score": 0.73 }

  ],

  "status": "ok",

  "time": 0.001

}

결과에는 점수(score) 순으로 정렬된 포인트 id 목록이 담겨요.

기본적으로 이 결과에는 payload와 벡터 데이터가 포함되지 않아요. 결과에 포함시키는 방법은 아래 '결과의 payload와 벡터' 섹션을 참고하세요.

컬렉션이 여러 벡터로 생성된 경우, 검색에 사용할 벡터의 이름을 지정해 줘야 해요:

POST /collections/{collection_name}/points/query

{

    "query": [0.2, 0.1, 0.9, 0.7],

    "using": "image",

    "limit": 3

}
from qdrant_client import QdrantClient

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

client.query_points(
    collection_name="{collection_name}",
    query=[0.2, 0.1, 0.9, 0.7],
    using="image",
    limit=3,
)
import { QdrantClient } from "@qdrant/js-client-rest";

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

client.query("{collection_name}", {
  query: [0.2, 0.1, 0.9, 0.7],
  using: "image",
  limit: 3,
});
use qdrant_client::qdrant::QueryPointsBuilder;
use qdrant_client::Qdrant;

let client = Qdrant::from_url("http://localhost:6334").build()?;

client
    .query(
        QueryPointsBuilder::new("{collection_name}")
            .query(vec![0.2, 0.1, 0.9, 0.7])
            .limit(3)
            .using("image"),
    )
    .await?;
import static io.qdrant.client.QueryFactory.nearest;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Points.QueryPoints;
import java.util.List;

QdrantClient client =
    new QdrantClient(QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

client.queryAsync(QueryPoints.newBuilder()
        .setCollectionName("{collection_name}")
        .setQuery(nearest(0.2f, 0.1f, 0.9f, 0.7f))
        .setUsing("image")
        .setLimit(3)
        .build()).get();
using Qdrant.Client;

var client = new QdrantClient("localhost", 6334);

await client.QueryAsync(
	collectionName: "{collection_name}",
	query: new float[] { 0.2f, 0.1f, 0.9f, 0.7f },
	usingVector: "image",
	limit: 3
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.Query(context.Background(), &qdrant.QueryPoints{
	CollectionName: "{collection_name}",
	Query:          qdrant.NewQuery(0.2, 0.1, 0.9, 0.7),
	Using:          qdrant.PtrOf("image"),
})

검색은 같은 이름을 가진 벡터들 사이에서만 수행돼요.

컬렉션이 스파스 벡터로 생성된 경우, 검색에 사용할 스파스 벡터의 이름을 지정해 줘야 해요:

스파스 벡터에서도 payload 필터링과 search API의 다른 기능들을 그대로 사용할 수 있어요.

다만 밀집(dense) 벡터와 스파스(sparse) 벡터 검색에는 중요한 차이점이 있어요:

지표 스파스 쿼리 (Sparse Query) 밀집 쿼리 (Dense Query)
스코어링 지표 (Scoring Metric) 기본값은 Dot product, 별도 지정 불필요 Dot, Cosine 같은 지원 지표를 사용해요
검색 유형 (Search Type) Qdrant에서 항상 정확(exact) HNSW는 근사 NN
반환 동작 (Return Behaviour) 쿼리 벡터와 인덱스가 같은 0이 아닌 값을 가진 벡터만 반환 limit 만큼의 벡터를 반환

일반적으로 검색 속도는 쿼리 벡터에서 0이 아닌 값의 개수에 비례해요.

POST /collections/{collection_name}/points/query

{

    "query": {

        "indices": [1, 3, 5, 7],

        "values": [0.1, 0.2, 0.3, 0.4]

    },

    "using": "text"

}
from qdrant_client import QdrantClient, models

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

result = client.query_points(
    collection_name="{collection_name}",
    query=models.SparseVector(indices=[1, 3, 5, 7], values=[0.1, 0.2, 0.3, 0.4]),
    using="text",
).points
import { QdrantClient } from "@qdrant/js-client-rest";

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

client.query("{collection_name}", {
    query: {
        indices: [1, 3, 5, 7],
        values: [0.1, 0.2, 0.3, 0.4]
    },
    using: "text",
    limit: 3,
});
use qdrant_client::qdrant::QueryPointsBuilder;
use qdrant_client::Qdrant;

let client = Qdrant::from_url("http://localhost:6334").build()?;

client
    .query(
        QueryPointsBuilder::new("{collection_name}")
            .query(vec![(1, 0.2), (3, 0.1), (5, 0.9), (7, 0.7)])
            .limit(10)
            .using("text"),
    )
    .await?;
import static io.qdrant.client.QueryFactory.nearest;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Points.QueryPoints;
import java.util.List;

QdrantClient client =
  new QdrantClient(QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

client.queryAsync(
        QueryPoints.newBuilder()
                .setCollectionName("{collection_name}")
                .setUsing("text")
                .setQuery(nearest(List.of(0.1f, 0.2f, 0.3f, 0.4f), List.of(1, 3, 5, 7)))
                .setLimit(3)
                .build())
        .get();
using Qdrant.Client;

var client = new QdrantClient("localhost", 6334);

await client.QueryAsync(
  collectionName: "{collection_name}",
  query: new (float, uint)[] {(0.1f, 1), (0.2f, 3), (0.3f, 5), (0.4f, 7)},
  usingVector: "text",
  limit: 3
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.Query(context.Background(), &qdrant.QueryPoints{
	CollectionName: "{collection_name}",
	Query: qdrant.NewQuerySparse(
		[]uint32{1, 3, 5, 7},
		[]float32{0.1, 0.2, 0.3, 0.4}),
	Using: qdrant.PtrOf("text"),
})

점수로 결과 필터링하기 (Filtering Results by Score)

payload 필터링에 더해, 유사도 점수가 낮은 결과를 걸러내는 것도 유용할 수 있어요. 예를 들어 모델의 최소 수용 점수를 알고 있고, 그 기준보다 덜 유사한 결과는 원하지 않는 경우가 있죠. 이때 검색 쿼리의 score_threshold 파라미터를 사용하면 지정한 점수보다 낮은 모든 결과가 제외돼요.

결과의 payload와 벡터 (Payload and Vector in the Result)

기본적으로 조회(retrieval) 메서드는 payload나 벡터 같은 저장된 정보를 반환하지 않아요. with_vectorswith_payload 파라미터가 이 동작을 바꿔 줘요.

예시:

POST /collections/{collection_name}/points/query

{

    "query": [0.2, 0.1, 0.9, 0.7],

    "with_vectors": true,

    "with_payload": true

}
client.query_points(
    collection_name="{collection_name}",
    query=[0.2, 0.1, 0.9, 0.7],
    with_vectors=True,
    with_payload=True,
)
client.query("{collection_name}", {
  query: [0.2, 0.1, 0.9, 0.7],
  with_vector: true,
  with_payload: true,
});
use qdrant_client::qdrant::QueryPointsBuilder;
use qdrant_client::Qdrant;

let client = Qdrant::from_url("http://localhost:6334").build()?;

client
    .query(
        QueryPointsBuilder::new("{collection_name}")
            .query(vec![0.2, 0.1, 0.9, 0.7])
            .limit(3)
            .with_payload(true)
            .with_vectors(true),
    )
    .await?;
import static io.qdrant.client.QueryFactory.nearest;
import static io.qdrant.client.WithPayloadSelectorFactory.enable;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.WithVectorsSelectorFactory;
import io.qdrant.client.grpc.Points.QueryPoints;

QdrantClient client =
    new QdrantClient(QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

client.queryAsync(
        QueryPoints.newBuilder()
                .setCollectionName("{collection_name}")
                .setQuery(nearest(0.2f, 0.1f, 0.9f, 0.7f))
                .setWithPayload(enable(true))
                .setWithVectors(WithVectorsSelectorFactory.enable(true))
                .setLimit(3)
                .build())
        .get();
using Qdrant.Client;

var client = new QdrantClient("localhost", 6334);

await client.QueryAsync(
	collectionName: "{collection_name}",
	query: new float[] { 0.2f, 0.1f, 0.9f, 0.7f },
	payloadSelector: true,
	vectorsSelector: true,
	limit: 3
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.Query(context.Background(), &qdrant.QueryPoints{
	CollectionName: "{collection_name}",
	Query:          qdrant.NewQuery(0.2, 0.1, 0.9, 0.7),
	WithPayload:    qdrant.NewWithPayload(true),
	WithVectors:    qdrant.NewWithVectors(true),
})

with_payload를 사용하면 특정 payload 부분집합으로 범위를 좁히거나 필터링할 수 있어요. city, village, town처럼 포함할 항목 배열을 직접 지정할 수도 있죠:

POST /collections/{collection_name}/points/query

{

    "query": [0.2, 0.1, 0.9, 0.7],

    "with_payload": ["city", "village", "town"]

}
from qdrant_client import QdrantClient

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

client.query_points(
    collection_name="{collection_name}",
    query=[0.2, 0.1, 0.9, 0.7],
    with_payload=["city", "village", "town"],
)
import { QdrantClient } from "@qdrant/js-client-rest";

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

client.query("{collection_name}", {
  query: [0.2, 0.1, 0.9, 0.7],
  with_payload: ["city", "village", "town"],
});
use qdrant_client::qdrant::{with_payload_selector::SelectorOptions, QueryPointsBuilder};

client
    .query(
        QueryPointsBuilder::new("{collection_name}")
            .query(vec![0.2, 0.1, 0.9, 0.7])
            .limit(3)
            .with_payload(SelectorOptions::Include(
                vec![
                    "city".to_string(),
                    "village".to_string(),
                    "town".to_string(),
                ]
                .into(),
            ))
            .with_vectors(true),
    )
    .await?;
import static io.qdrant.client.QueryFactory.nearest;
import static io.qdrant.client.WithPayloadSelectorFactory.include;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Points.QueryPoints;
import java.util.List;

QdrantClient client =
    new QdrantClient(QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

client.queryAsync(
        QueryPoints.newBuilder()
                .setCollectionName("{collection_name}")
                .setQuery(nearest(0.2f, 0.1f, 0.9f, 0.7f))
                .setWithPayload(include(List.of("city", "village", "town")))
                .setLimit(3)
                .build())
        .get();
using Qdrant.Client;
using Qdrant.Client.Grpc;

var client = new QdrantClient("localhost", 6334);

await client.QueryAsync(
    collectionName: "{collection_name}",
    query: new float[] { 0.2f, 0.1f, 0.9f, 0.7f },
    payloadSelector: new WithPayloadSelector
    {
        Include = new PayloadIncludeSelector
        {
            Fields = { new string[] { "city", "village", "town" } }
        }
    },
    limit: 3
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.Query(context.Background(), &qdrant.QueryPoints{
	CollectionName: "{collection_name}",
	Query:          qdrant.NewQuery(0.2, 0.1, 0.9, 0.7),
	WithPayload:    qdrant.NewWithPayloadInclude("city", "village", "town"),
})

또는 includeexclude를 명시적으로 사용할 수도 있어요. 예를 들어 city를 제외하려면:

POST /collections/{collection_name}/points/query

{

    "query": [0.2, 0.1, 0.9, 0.7],

    "with_payload": {

      "exclude": ["city"]

    }

}
from qdrant_client import QdrantClient, models

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

client.query_points(
    collection_name="{collection_name}",
    query=[0.2, 0.1, 0.9, 0.7],
    with_payload=models.PayloadSelectorExclude(
        exclude=["city"],
    ),
)
import { QdrantClient } from "@qdrant/js-client-rest";

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

client.query("{collection_name}", {
  query: [0.2, 0.1, 0.9, 0.7],
  with_payload: {
    exclude: ["city"],
  },
});
use qdrant_client::qdrant::{with_payload_selector::SelectorOptions, QueryPointsBuilder};
use qdrant_client::Qdrant;

let client = Qdrant::from_url("http://localhost:6334").build()?;

client
    .query(
        QueryPointsBuilder::new("{collection_name}")
            .query(vec![0.2, 0.1, 0.9, 0.7])
            .limit(3)
            .with_payload(SelectorOptions::Exclude(vec!["city".to_string()].into()))
            .with_vectors(true),
    )
    .await?;
import static io.qdrant.client.QueryFactory.nearest;
import static io.qdrant.client.WithPayloadSelectorFactory.exclude;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Points.QueryPoints;
import java.util.List;

QdrantClient client =
    new QdrantClient(QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

client.queryAsync(
        QueryPoints.newBuilder()
                .setCollectionName("{collection_name}")
                .setQuery(nearest(0.2f, 0.1f, 0.9f, 0.7f))
                .setWithPayload(exclude(List.of("city")))
                .setLimit(3)
                .build())
        .get();
using Qdrant.Client;
using Qdrant.Client.Grpc;

var client = new QdrantClient("localhost", 6334);

await client.QueryAsync(
	collectionName: "{collection_name}",
	query: new float[] { 0.2f, 0.1f, 0.9f, 0.7f },
	payloadSelector: new WithPayloadSelector
	{
		Exclude = new PayloadExcludeSelector { Fields = { new string[] { "city" } } }
	},
	limit: 3
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.Query(context.Background(), &qdrant.QueryPoints{
	CollectionName: "{collection_name}",
	Query:          qdrant.NewQuery(0.2, 0.1, 0.9, 0.7),
	WithPayload:    qdrant.NewWithPayloadExclude("city"),
})

점 표기법(dot notation)으로 중첩 필드를 지정할 수도 있어요:

  • payload.nested_field — 중첩 필드를 대상으로 할 때

  • payload.nested_array[].sub_field — 배열 안의 중첩 필드를 투영(project)할 때

배열 요소를 인덱스로 접근하는 것은 현재 지원되지 않아요.

ACORN 검색 알고리즘 (ACORN Search Algorithm)

v1.16.0부터 사용할 수 있어요.

필터링된 벡터 검색에서는 필터링에 쓸 필드에 payload 인덱스를 만들어 두는 걸 권장해요. 검색 중에 Qdrant는 결합된 필터 가능 인덱스(filterable index)를 사용하죠. 그런데 여러 개의 엄격한 payload 필터를 조합하면 이 방식이 충분한 정확도를 내지 못할 수 있어요. 그런 경우에 ACORN 검색 알고리즘을 사용할 수 있어요.

일반 HNSW 검색 알고리즘의 확장판으로, ACORN: Performant and Predicate-Agnostic Search Over Vector Embeddings and Structured Data 논문에 기술된 ACORN-1 알고리즘에 기반해요. 그래프 탐색 중에 직접 이웃(first hop)만 살펴보는 게 아니라, 직접 이웃이 필터링되었을 때는 그 이웃의 이웃(second hop)까지 탐색해요. 이렇게 하면 검색 정확도가 높아지지만 성능은 조금 희생돼요.

다음과 같이 활성화할 수 있어요:

POST /collections/{collection_name}/points/query

{

    "query": [0.2, 0.1, 0.9, 0.7],

    "params": {

        "acorn": {

            "enable": true,

            "max_selectivity": 0.4

        }

    },

    "limit": 10

}
from qdrant_client import QdrantClient, models

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

client.query_points(
    collection_name="{collection_name}",
    query=[0.2, 0.1, 0.9, 0.7],
    search_params=models.SearchParams(
        acorn=models.AcornSearchParams(
            enable=True,
            max_selectivity=0.4,
        )
    ),
    limit=10,
)
import { QdrantClient } from "@qdrant/js-client-rest";

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

client.query("{collection_name}", {
    query: [0.2, 0.1, 0.9, 0.7],
    params: {
        acorn: {
            enable: true,
            max_selectivity: 0.4,
        },
    },
    limit: 10,
});
use qdrant_client::qdrant::{
    AcornSearchParamsBuilder, QueryPointsBuilder, SearchParamsBuilder,
};
use qdrant_client::Qdrant;

let client = Qdrant::from_url("http://localhost:6334").build()?;

client
    .query(
        QueryPointsBuilder::new("{collection_name}")
            .query(vec![0.2, 0.1, 0.9, 0.7])
            .limit(10)
            .params(
                SearchParamsBuilder::default().acorn(
                    AcornSearchParamsBuilder::new(true)
                        .max_selectivity(0.4),
                ),
            ),
    )
    .await?;
import static io.qdrant.client.QueryFactory.nearest;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Points.AcornSearchParams;
import io.qdrant.client.grpc.Points.QueryPoints;
import io.qdrant.client.grpc.Points.SearchParams;

QdrantClient client =
    new QdrantClient(QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

client.queryAsync(
        QueryPoints.newBuilder()
                .setCollectionName("{collection_name}")
                .setQuery(nearest(0.2f, 0.1f, 0.9f, 0.7f))
                .setParams(
                        SearchParams.newBuilder()
                                .setAcorn(
                                        AcornSearchParams.newBuilder()
                                                .setEnable(true)
                                                .setMaxSelectivity(0.4)
                                                .build())
                                .build())
                .setLimit(10)
                .build())
        .get();
using Qdrant.Client;
using Qdrant.Client.Grpc;

var client = new QdrantClient("localhost", 6334);

await client.QueryAsync(
	collectionName: "{collection_name}",
	query: new float[] { 0.2f, 0.1f, 0.9f, 0.7f },
	searchParams: new SearchParams
	{
		Acorn = new AcornSearchParams
		{
			Enable = true,
			MaxSelectivity = 0.4
		}
	},
	limit: 10
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.Query(context.Background(), &qdrant.QueryPoints{
	CollectionName: "{collection_name}",
	Query:          qdrant.NewQuery(0.2, 0.1, 0.9, 0.7),
	Params: &qdrant.SearchParams{
		Acorn: &qdrant.AcornSearchParams{
			Enable:          qdrant.PtrOf(true),
			MaxSelectivity:  qdrant.PtrOf(0.4),
		},
	},
})

ACORN은 기본적으로 비활성화되어 있어요. enable 플래그로 켜면, 예상 필터 선택도(selectivity)가 기준값 아래일 때 조건부로 활성화돼요. 선택적 파라미터인 max_selectivity가 이 기준값을 조절해요. 0.0이면 ACORN을 절대 사용하지 않고, 1.0이면 항상 사용해요. 기본값은 0.4예요. 선택도는 다음과 같이 추정돼요:

예상 필터 선택도 = 예상 필터 만족 포인트 수 / 전체 포인트 수

ACORN은 흔한 시나리오에서 약 2~10배 정도 느리지만, 제한적인(restrictive) 필터에서 리콜(recall)을 개선해요. 그래서 이 파라미터를 조정하는 건, 정확도 향상이 성능 비용을 정당화하는 지점을 고르는 작업이라고 볼 수 있어요.

Batch Search API (배치 검색 API)

배치 검색 API를 사용하면 여러 검색 요청을 하나의 요청으로 처리할 수 있어요.

의미는 단순해요. n개의 배치 검색 요청은 n개의 개별 검색 요청과 동일한 의미예요.

이 방식에는 여러 장점이 있어요. 먼저 네트워크 연결 수가 줄어드는데, 그 자체로도 큰 이점이 될 수 있어요.

더 중요한 건, 배치 요청이 쿼리 플래너(query planner)를 통해 효율적으로 처리된다는 점이에요. 같은 필터를 가진 요청을 감지해 최적화할 수 있죠.

중간 결과를 요청들끼리 공유할 수 있기 때문에, 단순하지 않은 필터에서는 지연 시간(latency)에 큰 영향을 줄 수 있어요.

사용 방법은 간단해요. 검색 요청들을 함께 묶어 주기만 하면 돼요. 물론 일반 검색 요청의 모든 속성을 그대로 사용할 수 있어요.

POST /collections/{collection_name}/points/query/batch

{

    "searches": [

        {

            "query": [0.2, 0.1, 0.9, 0.7],

            "filter": {

                "must": [

                    {

                        "key": "city",

                        "match": {

                            "value": "London"

                        }

                    }

                ]

            },

            "limit": 3

        },

        {

            "query": [0.5, 0.3, 0.2, 0.3],

            "filter": {

                "must": [

                    {

                        "key": "city",

                        "match": {

                            "value": "London"

                        }

                    }

                ]

            },

            "limit": 3

        }

    ]

}
from qdrant_client import QdrantClient, models

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

filter_ = models.Filter(
    must=[
        models.FieldCondition(
            key="city",
            match=models.MatchValue(
                value="London",
            ),
        )
    ]
)

search_queries = [
    models.QueryRequest(query=[0.2, 0.1, 0.9, 0.7], filter=filter_, limit=3),
    models.QueryRequest(query=[0.5, 0.3, 0.2, 0.3], filter=filter_, limit=3),
]

client.query_batch_points(collection_name="{collection_name}", requests=search_queries)
import { QdrantClient } from "@qdrant/js-client-rest";

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

const filter = {
    must: [
        {
            key: "city",
            match: {
                value: "London",
            },
        },
    ],
};

const searches = [
    {
        query: [0.2, 0.1, 0.9, 0.7],
        filter,
        limit: 3,
    },
    {
        query: [0.5, 0.3, 0.2, 0.3],
        filter,
        limit: 3,
    },
];

client.queryBatch("{collection_name}", {
    searches,
});
use qdrant_client::qdrant::{Condition, Filter, QueryBatchPointsBuilder, QueryPointsBuilder};
use qdrant_client::Qdrant;

let client = Qdrant::from_url("http://localhost:6334").build()?;

let filter = Filter::must([Condition::matches("city", "London".to_string())]);

let searches = vec![
    QueryPointsBuilder::new("{collection_name}")
        .query(vec![0.1, 0.2, 0.3, 0.4])
        .limit(3)
        .filter(filter.clone())
        .build(),
    QueryPointsBuilder::new("{collection_name}")
        .query(vec![0.5, 0.3, 0.2, 0.3])
        .limit(3)
        .filter(filter)
        .build(),
];

client
        .query_batch(QueryBatchPointsBuilder::new("{collection_name}", searches))
        .await?;
import static io.qdrant.client.ConditionFactory.matchKeyword;
import static io.qdrant.client.QueryFactory.nearest;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Common.Filter;
import io.qdrant.client.grpc.Points.QueryPoints;
import java.util.List;

QdrantClient client =
    new QdrantClient(QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

Filter filter = Filter.newBuilder().addMust(matchKeyword("city", "London")).build();

List<QueryPoints> searches = List.of(
        QueryPoints.newBuilder()
                .setQuery(nearest(0.2f, 0.1f, 0.9f, 0.7f))
                .setFilter(filter)
                .setLimit(3)
                .build(),
        QueryPoints.newBuilder()
                .setQuery(nearest(0.2f, 0.1f, 0.9f, 0.7f))
                .setFilter(filter)
                .setLimit(3)
                .build());

client.queryBatchAsync("{collection_name}", searches).get();
using Qdrant.Client;
using Qdrant.Client.Grpc;
using static Qdrant.Client.Grpc.Conditions;

var client = new QdrantClient("localhost", 6334);

var filter = MatchKeyword("city", "London");

var queries = new List<QueryPoints>
{
    new()
    {
        CollectionName = "{collection_name}",
        Query = new float[] { 0.2f, 0.1f, 0.9f, 0.7f },
        Filter = filter,
        Limit = 3
    },
    new()
    {
        CollectionName = "{collection_name}",
        Query = new float[] { 0.5f, 0.3f, 0.2f, 0.3f },
        Filter = filter,
        Limit = 3
    }
};

await client.QueryBatchAsync(collectionName: "{collection_name}", queries: queries);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

filter := qdrant.Filter{
	Must: []*qdrant.Condition{
		qdrant.NewMatch("city", "London"),
	},
}

client.QueryBatch(context.Background(), &qdrant.QueryBatchPoints{
	CollectionName: "{collection_name}",
	QueryPoints: []*qdrant.QueryPoints{
		{
			CollectionName: "{collection_name}",
			Query:          qdrant.NewQuery(0.2, 0.1, 0.9, 0.7),
			Filter:         &filter,
		},
		{
			CollectionName: "{collection_name}",
			Query:          qdrant.NewQuery(0.5, 0.3, 0.2, 0.3),
			Filter:         &filter,
		},
	},
})

이 API의 결과는 검색 요청마다 하나씩 배열을 포함해요.

{

  "result": [

    [

        { "id": 10, "score": 0.81 },

        { "id": 14, "score": 0.75 },

        { "id": 11, "score": 0.73 }

    ],

    [

        { "id": 1, "score": 0.92 },

        { "id": 3, "score": 0.89 },

        { "id": 9, "score": 0.75 }

    ]

  ],

  "status": "ok",

  "time": 0.001

}

Query by ID (ID로 쿼리하기)

벡터를 입력으로 사용해야 할 때마다, 그 대신 포인트 ID를 사용할 수 있어요.

POST /collections/{collection_name}/points/query

{

    "query": "43cf51e2-8777-4f52-bc74-c2cbde0c8b04" // <--- point id

}
client.query_points(
    collection_name="{collection_name}",
    query="43cf51e2-8777-4f52-bc74-c2cbde0c8b04", # <--- point id
)
import { QdrantClient } from "@qdrant/js-client-rest";

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

client.query("{collection_name}", {
    query: '43cf51e2-8777-4f52-bc74-c2cbde0c8b04', // <--- point id
});
use qdrant_client::Qdrant;
use qdrant_client::qdrant::{PointId, Query, QueryPointsBuilder};

let client = Qdrant::from_url("http://localhost:6334").build()?;

client
    .query(
        QueryPointsBuilder::new("{collection_name}")
            .query(Query::new_nearest(PointId::from("43cf51e2-8777-4f52-bc74-c2cbde0c8b04")))
    )
    .await?;
import static io.qdrant.client.QueryFactory.nearest;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Points.QueryPoints;
import java.util.UUID;

QdrantClient client = new QdrantClient(QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

client.queryAsync(QueryPoints.newBuilder()
  .setCollectionName("{collectionName}")
  .setQuery(nearest(UUID.fromString("43cf51e2-8777-4f52-bc74-c2cbde0c8b04")))
  .build()).get();
using Qdrant.Client;

var client = new QdrantClient("localhost", 6334);

await client.QueryAsync(
    collectionName: "{collection_name}",
    query: Guid.Parse("43cf51e2-8777-4f52-bc74-c2cbde0c8b04")
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.Query(context.Background(), &qdrant.QueryPoints{
	CollectionName: "{collection_name}",
	Query:          qdrant.NewQueryID(qdrant.NewID("43cf51e2-8777-4f52-bc74-c2cbde0c8b04")),
})

위 예시는 이 id를 가진 포인트의 기본 벡터를 가져와서 쿼리 벡터로 사용해요.

using 파라미터도 함께 지정하면, Qdrant는 그 이름을 가진 벡터를 사용해요.

lookup_from 파라미터를 설정하면 다른 컬렉션의 ID를 참조할 수도 있어요.

POST /collections/{collection_name}/points/query

{

    "query": "43cf51e2-8777-4f52-bc74-c2cbde0c8b04", // <--- point id

    "using": "512d-vector",

    "lookup_from": {

        "collection": "another_collection", // <--- other collection name

        "vector": "image-512" // <--- vector name in the other collection

    }

}
from qdrant_client import QdrantClient, models

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

client.query_points(
    collection_name="{collection_name}",
    query="43cf51e2-8777-4f52-bc74-c2cbde0c8b04",  # <--- point id
    using="512d-vector",
    lookup_from=models.LookupLocation(
        collection="another_collection",  # <--- other collection name
        vector="image-512",  # <--- vector name in the other collection
    )
)
import { QdrantClient } from "@qdrant/js-client-rest";

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

client.query("{collection_name}", {
    query: '43cf51e2-8777-4f52-bc74-c2cbde0c8b04', // <--- point id
    using: '512d-vector',
    lookup_from: {
        collection: 'another_collection', // <--- other collection name
        vector: 'image-512', // <--- vector name in the other collection
    }
});
use qdrant_client::Qdrant;
use qdrant_client::qdrant::{LookupLocationBuilder, Query, QueryPointsBuilder};

let client = Qdrant::from_url("http://localhost:6334").build()?;

client.query(
    QueryPointsBuilder::new("{collection_name}")
        .query(Query::new_nearest("43cf51e2-8777-4f52-bc74-c2cbde0c8b04"))
        .using("512d-vector")
        .lookup_from(
            LookupLocationBuilder::new("another_collection")
                .vector_name("image-512")
        )
).await?;
import static io.qdrant.client.QueryFactory.nearest;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Points.LookupLocation;
import io.qdrant.client.grpc.Points.QueryPoints;
import java.util.UUID;

QdrantClient client =
    new QdrantClient(QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

client
    .queryAsync(
        QueryPoints.newBuilder()
            .setCollectionName("{collection_name}")
            .setQuery(nearest(UUID.fromString("43cf51e2-8777-4f52-bc74-c2cbde0c8b04")))
            .setUsing("512d-vector")
            .setLookupFrom(
                LookupLocation.newBuilder()
                    .setCollectionName("another_collection")
                    .setVectorName("image-512")
                    .build())
            .build())
    .get();
using Qdrant.Client;

var client = new QdrantClient("localhost", 6334);

await client.QueryAsync(
  collectionName: "{collection_name}",
  query: Guid.Parse("43cf51e2-8777-4f52-bc74-c2cbde0c8b04"), // <--- point id
  usingVector: "512d-vector",
  lookupFrom: new() {
    CollectionName = "another_collection", // <--- other collection name
      VectorName = "image-512" // <--- vector name in the other collection
  }
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.Query(context.Background(), &qdrant.QueryPoints{
	CollectionName: "{collection_name}",
	Query:          qdrant.NewQueryID(qdrant.NewID("43cf51e2-8777-4f52-bc74-c2cbde0c8b04")),
	Using:          qdrant.PtrOf("512d-vector"),
	LookupFrom: &qdrant.LookupLocation{
		CollectionName: "another_collection",
		VectorName:     qdrant.PtrOf("image-512"),
	},
})

위의 경우 Qdrant는 another_collection 컬렉션에서 지정한 포인트 id의 "image-512" 벡터를 가져와요.

페이지네이션 (Pagination)

Search API와 추천(recommendation) API는 처음 몇 개의 결과를 건너뛰고, 지정한 offset부터의 결과만 반환하도록 할 수 있어요:

예시:

POST /collections/{collection_name}/points/query

{

    "query": [0.2, 0.1, 0.9, 0.7],

    "with_vectors": true,

    "with_payload": true,

    "limit": 10,

    "offset": 100

}
client.query_points(
    collection_name="{collection_name}",
    query=[0.2, 0.1, 0.9, 0.7],
    with_vectors=True,
    with_payload=True,
    limit=10,
    offset=100,
)
client.query("{collection_name}", {
  query: [0.2, 0.1, 0.9, 0.7],
  with_vector: true,
  with_payload: true,
  limit: 10,
  offset: 100,
});
use qdrant_client::qdrant::QueryPointsBuilder;
use qdrant_client::Qdrant;

client
    .query(
        QueryPointsBuilder::new("{collection_name}")
            .query(vec![0.2, 0.1, 0.9, 0.7])
            .with_payload(true)
            .with_vectors(true)
            .limit(10)
            .offset(100),
    )
    .await?;
import static io.qdrant.client.QueryFactory.nearest;
import static io.qdrant.client.WithPayloadSelectorFactory.enable;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.WithVectorsSelectorFactory;
import io.qdrant.client.grpc.Points.QueryPoints;
import java.util.List;

client.queryAsync(
        QueryPoints.newBuilder()
                .setCollectionName("{collection_name}")
                .setQuery(nearest(0.2f, 0.1f, 0.9f, 0.7f))
                .setWithPayload(enable(true))
                .setWithVectors(WithVectorsSelectorFactory.enable(true))
                .setLimit(10)
                .setOffset(100)
                .build())
        .get();
await client.QueryAsync(
    collectionName: "{collection_name}",
    query: new float[] { 0.2f, 0.1f, 0.9f, 0.7f },
    payloadSelector: true,
    vectorsSelector: true,
    limit: 10,
    offset: 100
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client.Query(context.Background(), &qdrant.QueryPoints{
	CollectionName: "{collection_name}",
	Query:          qdrant.NewQuery(0.2, 0.1, 0.9, 0.7),
	WithPayload:    qdrant.NewWithPayload(true),
	WithVectors:    qdrant.NewWithVectors(true),
	Limit:          qdrant.PtrOf(uint64(10)),
	Offset:         qdrant.PtrOf(uint64(100)),
})

이것은 페이지당 10개씩일 때 11번째 페이지를 가져오는 것과 같아요.

벡터 기반 조회, 특히 HNSW 인덱스는 페이지네이션을 염두에 두고 설계되지 않았어요. 내부적으로 처음 N개의 벡터를 먼저 조회하지 않고는 N번째로 가까운 벡터를 가져올 수 없거든요. 그래도 offset 파라미터를 쓰면 네트워크 트래픽과 스토리지 접근 횟수를 줄여 자원을 아낄 수 있어요. offset 파라미터를 사용하면 내부적으로는 offset + limit 개의 포인트를 조회하지만, 실제로 반환되는 포인트의 payload와 벡터에만 접근해요.

안정적인 정렬 (Stable Ordering)

HNSW 검색은 근사(approximate) 방식이라 요청 사이에 결과 순위가 조금씩 달라질 수 있어요. 그 결과 offset으로 페이지네이션하면 같은 포인트가 여러 페이지에 나오거나, 포인트가 통째로 건너뛰어질 수도 있어요.

이를 우회하는 방법은 몇 가지가 있어요:

클라이언트 측 페이지네이션 (Client-Side Pagination)

하나의 요청으로 큰 배치를 가져와서 클라이언트에서 페이지네이션하는 방식이에요. 예를 들어 상위 100개의 결과를 한 번에 가져와 사용자가 10개씩 볼 수 있게 하는 식이죠. 여러 번의 왕복(round-trip)을 피하고 중복도 없도록 보장해요.

대신 지연 시간이 늘어나고, 사용자가 실제로 필요한 것보다 더 많은 데이터를 반환한다는 단점이 있어요.

정확한 검색 (Exact Search)

exact 검색을 사용하면 HNSW를 우회해서 모든 벡터를 스캔하고, 안정적이고 결정적인 순서로 결과를 반환해요. 이렇게 하면 offset 기반 페이지네이션이 올바르게 동작하도록 보장할 수 있어요.

대신 지연 시간이 더 길어져서 작은 컬렉션에서만 실용적이에요.

POST /collections/{collection_name}/points/query

{

    "query": [0.2, 0.1, 0.9, 0.7],

    "params": {

        "exact": true

    },

    "limit": 10

}
from qdrant_client import QdrantClient, models

client.query_points(
    collection_name="{collection_name}",
    query=[0.2, 0.1, 0.9, 0.7],
    search_params=models.SearchParams(exact=True),
    limit=10,
)
client.query("{collection_name}", {
    query: [0.2, 0.1, 0.9, 0.7],
    params: {
        exact: true,
    },
    limit: 10,
});
use qdrant_client::qdrant::{QueryPointsBuilder, SearchParamsBuilder};
use qdrant_client::Qdrant;

client
    .query(
        QueryPointsBuilder::new("{collection_name}")
            .query(vec![0.2, 0.1, 0.9, 0.7])
            .limit(10)
            .params(SearchParamsBuilder::default().exact(true)),
    )
    .await?;
import static io.qdrant.client.QueryFactory.nearest;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Points.QueryPoints;
import io.qdrant.client.grpc.Points.SearchParams;

client.queryAsync(
        QueryPoints.newBuilder()
                .setCollectionName("{collection_name}")
                .setQuery(nearest(0.2f, 0.1f, 0.9f, 0.7f))
                .setParams(SearchParams.newBuilder().setExact(true).build())
                .setLimit(10)
                .build())
        .get();
using Qdrant.Client;
using Qdrant.Client.Grpc;

await client.QueryAsync(
	collectionName: "{collection_name}",
	query: new float[] { 0.2f, 0.1f, 0.9f, 0.7f },
	searchParams: new SearchParams { Exact = true },
	limit: 10
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client.Query(context.Background(), &qdrant.QueryPoints{
	CollectionName: "{collection_name}",
	Query:          qdrant.NewQuery(0.2, 0.1, 0.9, 0.7),
	Params: &qdrant.SearchParams{
		Exact: qdrant.PtrOf(true),
	},
})

본 ID 제외하기 (Exclude Seen IDs)

중복을 피하려면, 이후 페이지에서 이전 페이지들에서 모은 모든 포인트 ID를 담은 must_not: has_id 필터를 추가해요. 이렇게 하면 이전에 본 모든 포인트가 결과에서 제외돼요:

POST /collections/{collection_name}/points/query

{

    "query": [0.2, 0.1, 0.9, 0.7],

    "filter": {

        "must_not": [

            { "has_id": [83461, 19284, 57392, 44017, 91825] }

        ]

    },

    "limit": 5

}
from uuid import UUID

from qdrant_client import QdrantClient, models

seen_ids: list[int | str | UUID] = [83461, 19284, 57392, 44017, 91825]  # IDs returned on previous pages

client.query_points(
    collection_name="{collection_name}",
    query=[0.2, 0.1, 0.9, 0.7],
    query_filter=models.Filter(
        must_not=[
            models.HasIdCondition(has_id=seen_ids),
        ]
    ),
    limit=5,
)
const seenIds = [83461, 19284, 57392, 44017, 91825]; // IDs returned on previous pages

client.query("{collection_name}", {
  query: [0.2, 0.1, 0.9, 0.7],
  filter: {
    must_not: [
      {
        has_id: seenIds,
      },
    ],
  },
  limit: 5,
});
use qdrant_client::qdrant::{Condition, Filter, QueryPointsBuilder};
use qdrant_client::Qdrant;

let seen_ids = vec![83461u64, 19284, 57392, 44017, 91825]; // IDs returned on previous pages

client
    .query(
        QueryPointsBuilder::new("{collection_name}")
            .query(vec![0.2, 0.1, 0.9, 0.7])
            .filter(Filter::must_not([Condition::has_id(seen_ids)]))
            .limit(5),
    )
    .await?;
import static io.qdrant.client.ConditionFactory.hasId;
import static io.qdrant.client.PointIdFactory.id;
import static io.qdrant.client.QueryFactory.nearest;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Common.Filter;
import io.qdrant.client.grpc.Points.QueryPoints;
import java.util.List;

var seenIds = List.of(id(83461), id(19284), id(57392), id(44017), id(91825)); // IDs returned on previous pages

client.queryAsync(
        QueryPoints.newBuilder()
                .setCollectionName("{collection_name}")
                .setQuery(nearest(0.2f, 0.1f, 0.9f, 0.7f))
                .setFilter(
                        Filter.newBuilder()
                                .addMustNot(hasId(seenIds))
                                .build())
                .setLimit(5)
                .build())
        .get();
using Qdrant.Client;
using static Qdrant.Client.Grpc.Conditions;

ulong[] seenIds = [83461, 19284, 57392, 44017, 91825]; // IDs returned on previous pages

// The ! operator negates the condition (must not)
await client.QueryAsync(
    collectionName: "{collection_name}",
    query: new float[] { 0.2f, 0.1f, 0.9f, 0.7f },
    filter: !HasId(seenIds),
    limit: 5
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

seenIds := []uint64{83461, 19284, 57392, 44017, 91825} // IDs returned on previous pages

pointIds := make([]*qdrant.PointId, len(seenIds))
for i, id := range seenIds {
	pointIds[i] = qdrant.NewIDNum(id)
}

client.Query(context.Background(), &qdrant.QueryPoints{
	CollectionName: "{collection_name}",
	Query:          qdrant.NewQuery(0.2, 0.1, 0.9, 0.7),
	Filter: &qdrant.Filter{
		MustNot: []*qdrant.Condition{
			qdrant.NewHasID(pointIds...),
		},
	},
	Limit: qdrant.PtrOf(uint64(5)),
})

이 패턴을 매 페이지마다 반복하되, 결과 묶음이 나올 때마다 제외 목록을 확장해 나가면 돼요.

Grouping API (그룹화 API)

결과를 특정 필드 기준으로 그룹화할 수 있어요. 같은 항목에 대해 포인트가 여러 개 있을 때, 결과에서 같은 항목이 중복되는 걸 피하고 싶다면 유용해요.

예를 들어 큰 문서를 여러 청크(chunk)로 나누어 저장했고 문서 단위로 검색하거나 추천하고 싶다면, 문서 ID 기준으로 결과를 그룹화할 수 있어요.

다음과 같은 payload를 가진 포인트들이 있다고 해볼게요:

[

    {

        "id": 0,

        "payload": {

            "chunk_part": 0, 

            "document_id": "a"

        },

        "vector": [0.91]

    },

    {

        "id": 1,

        "payload": {

            "chunk_part": 1, 

            "document_id": ["a", "b"]

        },

        "vector": [0.8]

    },

    {

        "id": 2,

        "payload": {

            "chunk_part": 2, 

            "document_id": "a"

        },

        "vector": [0.2]

    },

    {

        "id": 3,

        "payload": {

            "chunk_part": 0, 

            "document_id": 123

        },

        "vector": [0.79]

    },

    {

        "id": 4,

        "payload": {

            "chunk_part": 1, 

            "document_id": 123

        },

        "vector": [0.75]

    },

    {

        "id": 5,

        "payload": {

            "chunk_part": 0, 

            "document_id": -10

        },

        "vector": [0.6]

    }

]

groups API를 사용하면 각 문서에 대해 최선의 N개 포인트를 얻을 수 있어요. 단, 포인트의 payload에 문서 ID가 들어 있어야 해요. 물론 포인트가 부족하거나 쿼리와의 거리가 커서 최선의 N개를 채우지 못하는 경우도 있어요. 어쨌든 group_sizelimit 파라미터처럼 최선을 다하는(best-effort) 파라미터예요.

검색 그룹 (Search Groups)

REST API(스키마):

POST /collections/{collection_name}/points/query/groups

{

    // Same as in the regular query API

    "query": [1.1],

    // Grouping parameters

    "group_by": "document_id",  // Path of the field to group by

    "limit": 4,                 // Max amount of groups

    "group_size": 2            // Max amount of points per group

}
client.query_points_groups(
    collection_name="{collection_name}",
    # Same as in the regular query_points() API
    query=[1.1],
    # Grouping parameters
    group_by="document_id",  # Path of the field to group by
    limit=4,  # Max amount of groups
    group_size=2,  # Max amount of points per group
)
client.queryGroups("{collection_name}", {
    query: [1.1],
    group_by: "document_id",
    limit: 4,
    group_size: 2,
});
use qdrant_client::qdrant::QueryPointGroupsBuilder;

client
    .query_groups(
        QueryPointGroupsBuilder::new("{collection_name}", "document_id")
            .query(vec![0.2, 0.1, 0.9, 0.7])
            .group_size(2u64)
            .with_payload(true)
            .with_vectors(true)
            .limit(4u64),
    )
    .await?;
import static io.qdrant.client.QueryFactory.nearest;

import io.qdrant.client.grpc.Points.QueryPointGroups;
import io.qdrant.client.grpc.Points.SearchPointGroups;
import java.util.List;

client.queryGroupsAsync(
        QueryPointGroups.newBuilder()
                .setCollectionName("{collection_name}")
                .setQuery(nearest(0.2f, 0.1f, 0.9f, 0.7f))
                .setGroupBy("document_id")
                .setLimit(4)
                .setGroupSize(2)
                .build())
        .get();
using Qdrant.Client;

var client = new QdrantClient("localhost", 6334);

await client.QueryGroupsAsync(
    collectionName: "{collection_name}",
    query: new float[] { 0.2f, 0.1f, 0.9f, 0.7f },
    groupBy: "document_id",
    limit: 4,
    groupSize: 2
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.QueryGroups(context.Background(), &qdrant.QueryPointGroups{
	CollectionName: "{collection_name}",
	Query:          qdrant.NewQuery(0.2, 0.1, 0.9, 0.7),
	GroupBy:        "document_id",
	GroupSize:      qdrant.PtrOf(uint64(2)),
})

groups 호출의 출력은 다음과 같아요:

{

    "result": {

        "groups": [

            {

                "id": "a",

                "hits": [

                    { "id": 0, "score": 0.91 },

                    { "id": 1, "score": 0.85 }

                ]

            },

            {

                "id": "b",

                "hits": [

                    { "id": 1, "score": 0.85 }

                ]

            },

            {

                "id": 123,

                "hits": [

                    { "id": 3, "score": 0.79 },

                    { "id": 4, "score": 0.75 }

                ]

            },

            {

                "id": -10,

                "hits": [

                    { "id": 5, "score": 0.6 }

                ]

            }

        ]

    },

    "status": "ok",

    "time": 0.001

}

그룹은 그룹 내 최상위 포인트의 점수 순으로 정렬돼요. 각 그룹 안에서도 포인트가 정렬돼 있어요.

포인트의 group_by 필드가 배열(예: "document_id": ["a", "b"])이면, 그 포인트는 여러 그룹에 포함될 수 있어요(예: document_id: "a"document_id: "b" 둘 다).

제약 사항:

  • group_by 파라미터에는 keyword와 정수(integer) payload 값만 지원돼요. 다른 타입의 payload 값은 무시돼요.

  • 현재 그룹을 사용할 때는 페이지네이션이 지원되지 않아서 offset 파라미터를 쓸 수 없어요.

그룹에서의 조회 (Lookup in Groups)

그룹의 포인트들이 제목, 요약, 전체 문서 벡터처럼 큰 필드를 공유할 때, 그 데이터를 매 포인트에 복사해 넣으면 스토리지가 부풀어 오르고, 공유 필드가 바뀔 때마다 모든 청크를 다시 써야 해요.

with_lookup이 이 문제를 해결해 줘요. 공유 데이터를 별도 컬렉션에 한 번만 저장해 두고, 쿼리 시점에 groups API로 각 그룹에 붙여 주면 돼요.

두 개의 컬렉션을 준비해요. chunks는 청크마다 하나의 포인트를 갖고 각자 자신의 벡터를 가져요. documents는 문서마다 하나의 포인트를 갖고 payload만 가져요. document_id에 payload 인덱스를 만들어 두면 group_by가 그 필드에서 동작할 수 있어요.

PUT /collections/chunks

{

    "vectors": {

        "size": 4,

        "distance": "Cosine"

    }

}

PUT /collections/chunks/index

{

    "field_name": "document_id",

    "field_schema": "integer"

}

PUT /collections/documents

{

    "vectors": {}

}
client.create_collection(
    collection_name="chunks",
    vectors_config=models.VectorParams(size=4, distance=models.Distance.COSINE),
)

client.create_payload_index(
    collection_name="chunks",
    field_name="document_id",
    field_schema=models.PayloadSchemaType.INTEGER,
)

client.create_collection(
    collection_name="documents",
    vectors_config={},  # no vectors, payload only
)
await client.createCollection("chunks", {
    vectors: { size: 4, distance: "Cosine" },
});

await client.createPayloadIndex("chunks", {
    field_name: "document_id",
    field_schema: "integer",
});

await client.createCollection("documents", {
    vectors: {}, // no vectors, payload only
});
use qdrant_client::Qdrant;
use qdrant_client::qdrant::{
    CreateCollectionBuilder, CreateFieldIndexCollectionBuilder, Distance, FieldType,
    VectorParamsBuilder, VectorsConfigBuilder,
};

client
    .create_collection(
        CreateCollectionBuilder::new("chunks")
            .vectors_config(VectorParamsBuilder::new(4, Distance::Cosine)),
    )
    .await?;

client
    .create_field_index(
        CreateFieldIndexCollectionBuilder::new("chunks", "document_id", FieldType::Integer)
            .wait(true),
    )
    .await?;

// No vectors, payload only.
client
    .create_collection(
        CreateCollectionBuilder::new("documents")
            .vectors_config(VectorsConfigBuilder::default()),
    )
    .await?;
import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Collections.CreateCollection;
import io.qdrant.client.grpc.Collections.Distance;
import io.qdrant.client.grpc.Collections.PayloadSchemaType;
import io.qdrant.client.grpc.Collections.VectorParams;
import io.qdrant.client.grpc.Collections.VectorParamsMap;
import io.qdrant.client.grpc.Collections.VectorsConfig;

QdrantClient client = new QdrantClient(
    QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

client.createCollectionAsync("chunks",
        VectorParams.newBuilder().setDistance(Distance.Cosine).setSize(4).build()).get();

client.createPayloadIndexAsync(
    "chunks",
    "document_id",
    PayloadSchemaType.Integer,
    null,
    true,
    null,
    null).get();

// No vectors, payload only.
client.createCollectionAsync(
    CreateCollection.newBuilder()
        .setCollectionName("documents")
        .setVectorsConfig(VectorsConfig.newBuilder()
            .setParamsMap(VectorParamsMap.newBuilder().build())
            .build())
        .build()).get();
using Qdrant.Client;
using Qdrant.Client.Grpc;

var client = new QdrantClient("localhost", 6334);

await client.CreateCollectionAsync(
	collectionName: "chunks",
	vectorsConfig: new VectorParams { Size = 4, Distance = Distance.Cosine }
);

await client.CreatePayloadIndexAsync(
	collectionName: "chunks",
	fieldName: "document_id",
	schemaType: PayloadSchemaType.Integer
);

// No vectors, payload only.
await client.CreateCollectionAsync(
	collectionName: "documents",
	vectorsConfig: new VectorParamsMap()
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.CreateCollection(context.Background(), &qdrant.CreateCollection{
	CollectionName: "chunks",
	VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{
		Size:     4,
		Distance: qdrant.Distance_Cosine,
	}),
})

client.CreateFieldIndex(context.Background(), &qdrant.CreateFieldIndexCollection{
	CollectionName: "chunks",
	FieldName:      "document_id",
	FieldType:      qdrant.FieldType_FieldTypeInteger.Enum(),
})

// No vectors, payload only.
client.CreateCollection(context.Background(), &qdrant.CreateCollection{
	CollectionName: "documents",
	VectorsConfig: qdrant.NewVectorsConfigMap(
		map[string]*qdrant.VectorParams{},
	),
})

두 컬렉션에 데이터를 채워 넣되, with_lookup을 쓰는 쿼리 전에 documents 컬렉션을 먼저 채워야 해요.

PUT /collections/documents/points

{

    "points": [

        {

            "id": 200,

            "vector": {},

            "payload": {"title": "Document A", "text": "This is document A"}

        },

        {

            "id": 201,

            "vector": {},

            "payload": {"title": "Document B", "text": "This is document B"}

        }

    ]

}

PUT /collections/chunks/points

{

    "points": [

        {

            "id": 0,

            "vector": [0.1, 0.2, 0.3, 0.4],

            "payload": {"document_id": 200}

        },

        {

            "id": 1,

            "vector": [0.5, 0.6, 0.7, 0.8],

            "payload": {"document_id": [200, 201]}

        }

    ]

}
client.upsert(
    collection_name="documents",
    points=[
        models.PointStruct(
            id=200,
            vector={},
            payload={"title": "Document A", "text": "This is document A"},
        ),
        models.PointStruct(
            id=201,
            vector={},
            payload={"title": "Document B", "text": "This is document B"},
        ),
    ],
)

client.upsert(
    collection_name="chunks",
    points=[
        models.PointStruct(
            id=0,
            vector=[0.1, 0.2, 0.3, 0.4],
            payload={"document_id": 200},
        ),
        models.PointStruct(
            id=1,
            vector=[0.5, 0.6, 0.7, 0.8],
            payload={"document_id": [200, 201]},
        ),
    ],
)
await client.upsert("documents", {
    points: [
        {
            id: 200,
            vector: {},
            payload: { title: "Document A", text: "This is document A" },
        },
        {
            id: 201,
            vector: {},
            payload: { title: "Document B", text: "This is document B" },
        },
    ],
});

await client.upsert("chunks", {
    points: [
        {
            id: 0,
            vector: [0.1, 0.2, 0.3, 0.4],
            payload: { document_id: 200 },
        },
        {
            id: 1,
            vector: [0.5, 0.6, 0.7, 0.8],
            payload: { document_id: [200, 201] },
        },
    ],
});
use std::collections::HashMap;

use qdrant_client::Qdrant;
use qdrant_client::qdrant::{PointStruct, UpsertPointsBuilder, Vector};

client
    .upsert_points(UpsertPointsBuilder::new(
        "documents",
        vec![
            PointStruct::new(
                200,
                HashMap::<String, Vector>::new(),
                [
                    ("title", "Document A".into()),
                    ("text", "This is document A".into()),
                ],
            ),
            PointStruct::new(
                201,
                HashMap::<String, Vector>::new(),
                [
                    ("title", "Document B".into()),
                    ("text", "This is document B".into()),
                ],
            ),
        ],
    ))
    .await?;

client
    .upsert_points(UpsertPointsBuilder::new(
        "chunks",
        vec![
            PointStruct::new(0, vec![0.1, 0.2, 0.3, 0.4], [("document_id", 200.into())]),
            PointStruct::new(1, vec![0.5, 0.6, 0.7, 0.8], [("document_id", vec![200, 201].into())]),
        ],
    ))
    .await?;
import static io.qdrant.client.PointIdFactory.id;
import static io.qdrant.client.ValueFactory.list;
import static io.qdrant.client.ValueFactory.value;
import static io.qdrant.client.VectorsFactory.namedVectors;
import static io.qdrant.client.VectorsFactory.vectors;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Points.PointStruct;
import java.util.List;
import java.util.Map;

QdrantClient client = new QdrantClient(
    QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

client.upsertAsync(
    "documents",
    List.of(
        PointStruct.newBuilder()
            .setId(id(200))
            .setVectors(namedVectors(Map.of()))
            .putAllPayload(Map.of(
                "title", value("Document A"),
                "text", value("This is document A")))
            .build(),
        PointStruct.newBuilder()
            .setId(id(201))
            .setVectors(namedVectors(Map.of()))
            .putAllPayload(Map.of(
                "title", value("Document B"),
                "text", value("This is document B")))
            .build())).get();

client.upsertAsync(
    "chunks",
    List.of(
        PointStruct.newBuilder()
            .setId(id(0))
            .setVectors(vectors(0.1f, 0.2f, 0.3f, 0.4f))
            .putAllPayload(Map.of("document_id", value(200)))
            .build(),
        PointStruct.newBuilder()
            .setId(id(1))
            .setVectors(vectors(0.5f, 0.6f, 0.7f, 0.8f))
            .putAllPayload(Map.of("document_id", list(List.of(value(200), value(201)))))
            .build())).get();
using Qdrant.Client;
using Qdrant.Client.Grpc;

var client = new QdrantClient("localhost", 6334);

await client.UpsertAsync(
	collectionName: "documents",
	points: new List<PointStruct>
	{
		new()
		{
			Id = 200,
			Vectors = new Dictionary<string, Vector>(),
			Payload =
			{
				["title"] = "Document A",
				["text"] = "This is document A",
			},
		},
		new()
		{
			Id = 201,
			Vectors = new Dictionary<string, Vector>(),
			Payload =
			{
				["title"] = "Document B",
				["text"] = "This is document B",
			},
		},
	}
);

await client.UpsertAsync(
	collectionName: "chunks",
	points: new List<PointStruct>
	{
		new()
		{
			Id = 0,
			Vectors = new float[] { 0.1f, 0.2f, 0.3f, 0.4f },
			Payload = { ["document_id"] = 200 },
		},
		new()
		{
			Id = 1,
			Vectors = new float[] { 0.5f, 0.6f, 0.7f, 0.8f },
			Payload = { ["document_id"] = new Value[] { 200L, 201L } },
		},
	}
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.Upsert(context.Background(), &qdrant.UpsertPoints{
	CollectionName: "documents",
	Points: []*qdrant.PointStruct{
		{
			Id: qdrant.NewIDNum(200),
			Vectors: qdrant.NewVectorsMap(map[string]*qdrant.Vector{}),
			Payload: qdrant.NewValueMap(map[string]any{
				"title": "Document A",
				"text":  "This is document A",
			}),
		},
		{
			Id: qdrant.NewIDNum(201),
			Vectors: qdrant.NewVectorsMap(map[string]*qdrant.Vector{}),
			Payload: qdrant.NewValueMap(map[string]any{
				"title": "Document B",
				"text":  "This is document B",
			}),
		},
	},
})

client.Upsert(context.Background(), &qdrant.UpsertPoints{
	CollectionName: "chunks",
	Points: []*qdrant.PointStruct{
		{
			Id:      qdrant.NewIDNum(0),
			Vectors: qdrant.NewVectors(0.1, 0.2, 0.3, 0.4),
			Payload: qdrant.NewValueMap(map[string]any{"document_id": 200}),
		},
		{
			Id:      qdrant.NewIDNum(1),
			Vectors: qdrant.NewVectors(0.5, 0.6, 0.7, 0.8),
			Payload: qdrant.NewValueMap(map[string]any{"document_id": []int{200, 201}}),
		},
	},
})

조회는 벡터 검색이 아니라 포인트 id 기준의 단순한 조인(join)이에요:

  • documents 컬렉션에는 각 청크의 group_by 값과 일치하는 id를 가진 포인트가 이미 있어야 해요.

  • id 타입도 일치해야 해요. 정수 포인트 id에 문자열 group_by 값을 쓰면 오류 없이 빈 조회 필드가 반환돼요.

  • documents에 일치하는 포인트가 없는 그룹 id는 빈 조회 필드를 받아요.

group_by="document_id"로 하고 with_lookupdocuments 컬렉션을 가리키도록 해서 chunks 컬렉션에 쿼리하면 돼요:

POST /collections/chunks/points/query/groups

{

    // Same as in the regular query API

    "query": [0.2, 0.1, 0.9, 0.7],

    // Grouping parameters

    "group_by": "document_id",

    "limit": 2,

    "group_size": 2,

    // Lookup parameters

    "with_lookup": {

        // Name of the collection to look up points in

        "collection": "documents",

        // Options for specifying what to bring from the payload 

        // of the looked up point, true by default

        "with_payload": ["title", "text"],

        // Options for specifying what to bring from the vector(s) 

        // of the looked up point, false by default

        "with_vectors": false

    }

}
client.query_points_groups(
    collection_name="chunks",
    # Same as in the regular search() API
    query=[0.2, 0.1, 0.9, 0.7],
    # Grouping parameters
    group_by="document_id",  # Path of the field to group by
    limit=2,  # Max amount of groups
    group_size=2,  # Max amount of points per group
    # Lookup parameters
    with_lookup=models.WithLookup(
        # Name of the collection to look up points in
        collection="documents",
        # Options for specifying what to bring from the payload
        # of the looked up point, True by default
        with_payload=["title", "text"],
        # Options for specifying what to bring from the vector(s)
        # of the looked up point, False by default
        with_vectors=False,
    ),
)
client.queryGroups("chunks", {
    query: [0.2, 0.1, 0.9, 0.7],
    group_by: "document_id",
    limit: 2,
    group_size: 2,
    with_lookup: {
        collection: "documents",
        with_payload: ["title", "text"],
        with_vectors: false,
    },
});
use qdrant_client::qdrant::{with_payload_selector::SelectorOptions, QueryPointGroupsBuilder, WithLookupBuilder};

client
    .query_groups(
        QueryPointGroupsBuilder::new("chunks", "document_id")
            .query(vec![0.2, 0.1, 0.9, 0.7])
            .limit(2u64)
            .group_size(2u64)
            .with_lookup(
                WithLookupBuilder::new("documents")
                    .with_payload(SelectorOptions::Include(
                        vec!["title".to_string(), "text".to_string()].into(),
                    ))
                    .with_vectors(false),
            ),
    )
    .await?;
import static io.qdrant.client.QueryFactory.nearest;
import static io.qdrant.client.WithPayloadSelectorFactory.include;
import static io.qdrant.client.WithVectorsSelectorFactory.enable;

import io.qdrant.client.grpc.Points.QueryPointGroups;
import io.qdrant.client.grpc.Points.WithLookup;
import java.util.List;

client.queryGroupsAsync(
        QueryPointGroups.newBuilder()
                .setCollectionName("chunks")
                .setQuery(nearest(0.2f, 0.1f, 0.9f, 0.7f))
                .setGroupBy("document_id")
                .setLimit(2)
                .setGroupSize(2)
                .setWithLookup(
                        WithLookup.newBuilder()
                                .setCollection("documents")
                                .setWithPayload(include(List.of("title", "text")))
                                .setWithVectors(enable(false))
                                .build())
                .build())
        .get();
using Qdrant.Client;
using Qdrant.Client.Grpc;

var client = new QdrantClient("localhost", 6334);

await client.QueryGroupsAsync(
    collectionName: "chunks",
    groupBy: "document_id",
    query: new float[] { 0.2f, 0.1f, 0.9f, 0.7f },
    limit: 2,
    groupSize: 2,
    withLookup: new WithLookup
    {
        Collection = "documents",
        WithPayload = new WithPayloadSelector
        {
            Include = new PayloadIncludeSelector { Fields = { new string[] { "title", "text" } } }
        },
        WithVectors = false
    }
);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.QueryGroups(context.Background(), &qdrant.QueryPointGroups{
	CollectionName: "chunks",
	Query:          qdrant.NewQuery(0.2, 0.1, 0.9, 0.7),
	GroupBy:        "document_id",
	Limit:          qdrant.PtrOf(uint64(2)),
	GroupSize:      qdrant.PtrOf(uint64(2)),
	WithLookup: &qdrant.WithLookup{
		Collection:  "documents",
		WithPayload: qdrant.NewWithPayloadInclude("title", "text"),
	},
})

with_lookup="documents"를 축약 형태로 전달할 수도 있어요. 이 경우 서버 기본값(with_payload=True, with_vectors=False)을 사용하므로 documents의 벡터는 반환되지 않아요. 그 벡터들이 필요하다면 명시적인 WithLookup(...) 형태를 사용하세요.

조회된 결과는 각 그룹의 lookup 아래에 나타나요. 아래 예시에서 chunk id 1은 document_id payload가 배열([200, 201])이라 두 그룹 모두에 나타나요. 배열이라 그 청크가 일치하는 모든 그룹에 들어가기 때문이죠.

{

    "result": {

        "groups": [

            {

                "id": 200,

                "hits": [

                    { "id": 0, "score": 0.91 },

                    { "id": 1, "score": 0.85 }

                ],

                "lookup": {

                    "id": 200,

                    "payload": {

                        "title": "Document A",

                        "text": "This is document A"

                    }

                }

            },

            {

                "id": 201,

                "hits": [

                    { "id": 1, "score": 0.85 }

                ],

                "lookup": {

                    "id": 201,

                    "payload": {

                        "title": "Document B",

                        "text": "This is document B"

                    }

                }

            }

        ]

    },

    "status": "ok",

    "time": 0.001

}

각각 약 24개의 청크를 가진 2만 개의 문서 컬렉션을 생각해 보면, 문서 수준의 데이터 약 3KB를 매 청크마다 복제하면 약 1.4GB까지 늘어나요. 그 필드들을 documents 컬렉션에 문서당 한 번만 저장하면 약 60MB로 줄어들어요. 공유 필드가 크거나 자주 바뀔 때 이 분리가 효과적인데, documents의 포인트 하나를 업데이트하면 쿼리 시점에 모든 청크의 그룹 아래에 반영되기 때문이에요.

랜덤 샘플링 (Random Sampling)

v1.11.0부터 사용할 수 있어요.

컬렉션에서 랜덤하게 샘플링한 포인트를 가져오는 게 유용한 경우가 있어요. 디버깅, 테스트, 또는 탐색을 위한 진입점을 제공할 때 유용하죠.

랜덤 샘플링 API는 Universal Query API의 일부로, 일반 검색 API와 같은 방식으로 사용할 수 있어요.

POST /collections/{collection_name}/points/query

{

    "query": {

        "sample": "random"

    }

}
from qdrant_client import QdrantClient, models

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

sampled = client.query_points(
    collection_name="{collection_name}",
    query=models.SampleQuery(sample=models.Sample.RANDOM)
)
import { QdrantClient } from "@qdrant/js-client-rest";

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

const sampled = await client.query("{collection_name}", {
  query: {
    sample: "random",
  },
});
use qdrant_client::Qdrant;
use qdrant_client::qdrant::{Query, QueryPointsBuilder, Sample};

let client = Qdrant::from_url("http://localhost:6334").build()?;

let sampled = client
    .query(
        QueryPointsBuilder::new("{collection_name}")
            .query(Query::new_sample(Sample::Random))
    )
    .await?;
import static io.qdrant.client.QueryFactory.sample;

import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;
import io.qdrant.client.grpc.Points.QueryPoints;
import io.qdrant.client.grpc.Points.Sample;

QdrantClient client =
    new QdrantClient(QdrantGrpcClient.newBuilder("localhost", 6334, false).build());

client
    .queryAsync(
        QueryPoints.newBuilder()
            .setCollectionName("{collection_name}")
            .setQuery(sample(Sample.Random))
            .build())
    .get();
using Qdrant.Client;
using Qdrant.Client.Grpc;

var client = new QdrantClient("localhost", 6334);

await client.QueryAsync(collectionName: "{collection_name}", query: Sample.Random);
import (
	"context"

	"github.com/qdrant/go-client/qdrant"
)

client, err := qdrant.NewClient(&qdrant.Config{
	Host: "localhost",
	Port: 6334,
})

client.QueryGroups(context.Background(), &qdrant.QueryPointGroups{
	CollectionName: "{collection_name}",
	Query:          qdrant.NewQuerySample(qdrant.Sample_Random),
})

쿼리 계획 (Query Planning)

검색에 사용된 필터에 따라 쿼리 실행 시나리오가 여러 가지로 나뉘어요. Qdrant는 사용 가능한 인덱스, 조건의 복잡도, 필터링 결과의 카디널리티(cardinality)에 따라 쿼리 실행 옵션 중 하나를 선택해요. 이 과정을 쿼리 계획(query planning)이라고 불러요.

전략 선택 과정은 휴리스틱(heuristics)에 크게 의존해서 릴리스마다 달라질 수 있어요. 다만 일반적인 원칙은 다음과 같아요:

  • 각 세그먼트(segment)에 대해 독립적으로 계획을 세워요(세그먼트에 대한 자세한 내용은 스토리지 문서 참고)

  • 포인트 수가 기준값 아래면 전체 스캔(full scan)을 선호해요

  • 전략을 선택하기 전에 필터링된 결과의 카디널리티를 추정해요

  • 카디널리티가 기준값 아래면 payload 인덱스로 포인트를 조회해요(인덱싱 문서 참고)

  • 카디널리티가 기준값보다 크면 필터 가능 벡터 인덱스(filterable vector index)를 사용해요

  • 선택도(비율)는 낮지만 카디널리티(개수)는 여전히 높을 때 ACORN을 사용해요

기준값은 설정 파일로 조정할 수 있고, 컬렉션마다 독립적으로도 조정할 수 있어요.