Qdrant Cloud 추론을 사용한 하이브리드 검색

시간: 30분 난이도: 중급
클라우드 추론으로 하이브리드 의미 검색 엔진 만들기

이 튜토리얼에서는 Qdrant Cloud의 내장 추론(inference) 기능을 이용해 하이브리드 의미 검색 엔진을 만드는 과정을 살펴볼게요. 여기서 배울 내용은 다음과 같아요.

출처: Qdrant 공식문서

클라이언트 초기화

먼저 Qdrant Cloud 계정전용 유료 클러스터를 만든 뒤 Qdrant 클라이언트를 초기화해요. 클라우드 추론을 활성화하려면 cloud_inferenceTrue로 설정하면 됩니다.

from qdrant_client import QdrantClient

client = QdrantClient(
    "xyz-example.cloud-region.cloud-provider.cloud.qdrant.io",
    api_key="<paste-your-api-key-here>",
    cloud_inference=True,
    timeout=30,
)
import { QdrantClient } from "@qdrant/js-client-rest";

const client = new QdrantClient({
  url: 'https://xyz-example.qdrant.io:6333',
  apiKey: '<paste-your-api-key-here>',
});
use qdrant_client::Qdrant;

let client = Qdrant::from_url("https://xyz-example.qdrant.io:6334")
    .api_key("<paste-your-api-key-here>")
    .build()
    .unwrap();
import io.qdrant.client.QdrantClient;
import io.qdrant.client.QdrantGrpcClient;

QdrantClient client = new QdrantClient(
    QdrantGrpcClient.newBuilder("xyz-example.qdrant.io", 6334, true)
        .withApiKey("<paste-your-api-key-here>")
        .build()
);
using Qdrant.Client;

var client = new QdrantClient(
    host: "xyz-example.cloud-region.cloud-provider.cloud.qdrant.io",
    https: true,
    apiKey: "<paste-your-api-key-here>"
);
import "github.com/qdrant/go-client/qdrant"

client, err := qdrant.NewClient(&qdrant.Config{
    Host:   "xyz-example.cloud-region.cloud-provider.cloud.qdrant.io",
    Port:   6334,
    APIKey: "<paste-your-api-key-here>",
    UseTLS: true,
})

컬렉션 생성

Qdrant는 벡터와 관련 메타데이터를 컬렉션(collection)에 저장해요. 컬렉션은 생성할 때 벡터 파라미터를 설정해야 해요. 여기서는 희소 벡터에 BM25, 밀집 벡터에 all-minilm-l6-v2를 사용하는 컬렉션을 구성할 거예요.

BM25는 역문서 빈도(IDF, Inverse Document Frequency)를 사용해 여러 문서에 등장하는 흔한 용어의 가중치를 낮추고, 검색에 더 판별적인 희귀 용어의 중요도를 높여요. bm25_sparse_vector라는 이름의 희소 벡터 구성에서 이 옵션을 켜면 Qdrant가 IDF 계산을 처리해 줍니다.

from qdrant_client import QdrantClient, models

client.create_collection(
    collection_name="{collection_name}",
    vectors_config={
        "dense_vector": models.VectorParams(
            size=384,
            distance=models.Distance.COSINE,
        )
    },
    sparse_vectors_config={
        "bm25_sparse_vector": models.SparseVectorParams(
            modifier=models.Modifier.IDF  # Enable Inverse Document Frequency
        )
    },
)
client.createCollection("{collection_name}", {
  vectors: {
    dense_vector: { size: 384, distance: "Cosine" },
  },
  sparse_vectors: {
    bm25_sparse_vector: { modifier: "idf" }, // Enable Inverse Document Frequency
  },
});
use qdrant_client::qdrant::{
    CreateCollectionBuilder, Distance, Modifier, SparseVectorParamsBuilder,
    SparseVectorsConfigBuilder, VectorParamsBuilder, VectorsConfigBuilder,
};

let mut vector_config = VectorsConfigBuilder::default();
vector_config.add_named_vector_params(
    "dense_vector",
    VectorParamsBuilder::new(384, Distance::Cosine),
);
let mut sparse_vectors_config = SparseVectorsConfigBuilder::default();
sparse_vectors_config.add_named_vector_params(
    "bm25_sparse_vector",
    SparseVectorParamsBuilder::default().modifier(Modifier::Idf), // Enable Inverse Document Frequency
);
client
    .create_collection(
        CreateCollectionBuilder::new("{collection_name}")
            .vectors_config(vector_config)
            .sparse_vectors_config(sparse_vectors_config),
    )
    .await?;
import io.qdrant.client.grpc.Collections.CreateCollection;
import io.qdrant.client.grpc.Collections.Distance;
import io.qdrant.client.grpc.Collections.Modifier;
import io.qdrant.client.grpc.Collections.SparseVectorConfig;
import io.qdrant.client.grpc.Collections.SparseVectorParams;
import io.qdrant.client.grpc.Collections.VectorParams;
import io.qdrant.client.grpc.Collections.VectorParamsMap;
import io.qdrant.client.grpc.Collections.VectorsConfig;
import java.util.Map;

client.createCollectionAsync(
    CreateCollection.newBuilder()
        .setCollectionName("{collection_name}")
        .setVectorsConfig(
            VectorsConfig.newBuilder()
                .setParamsMap(
                    VectorParamsMap.newBuilder()
                        .putAllMap(Map.of(
                            "dense_vector",
                            VectorParams.newBuilder()
                                .setSize(384)
                                .setDistance(Distance.Cosine)
                                .build()
                        ))
                )
                .build()
        )
        .setSparseVectorsConfig(
            SparseVectorConfig.newBuilder()
                .putMap(
                    "bm25_sparse_vector",
                    SparseVectorParams.newBuilder()
                        .setModifier(Modifier.Idf)
                        .build()
                )
                .build()
        )
        .build()
).get();
await client.CreateCollectionAsync(
    collectionName: "{collection_name}",
    vectorsConfig: new VectorParamsMap {
        Map = {
            ["dense_vector"] = new VectorParams {
                Size = 384,
                Distance = Distance.Cosine,
            },
        }
    },
    sparseVectorsConfig: new SparseVectorConfig {
        Map = {
            ["bm25_sparse_vector"] = new() {
                Modifier = Modifier.Idf, // Enable Inverse Document Frequency
            }
        }
    }
);
client.CreateCollection(context.Background(), &qdrant.CreateCollection{
    CollectionName: "{collection_name}",
    VectorsConfig: qdrant.NewVectorsConfigMap(map[string]*qdrant.VectorParams{
        "dense_vector": {
            Size:     384,
            Distance: qdrant.Distance_Cosine,
        },
    }),
    SparseVectorsConfig: qdrant.NewSparseVectorsConfig(map[string]*qdrant.SparseVectorParams{
        "bm25_sparse_vector": {
            Modifier: qdrant.Modifier_Idf.Enum(),
        },
    }),
})

데이터 추가

이제 샘플 문서와 관련 메타데이터, 그리고 각 문서의 포인트 ID를 추가할 수 있어요. 여기서는 miriad/miriad-4.4M 데이터셋의 샘플을 사용합니다.

qa_id paper_id question year venue specialty passage_text
38_77498699_0_1 77498699 What are the clinical features of relapsing polychondritis? 2006 Internet Journal of Otorhinolaryngology Rheumatology A 45-year-old man presented with painful swelling…
38_77498699_0_2 77498699 What treatments are available for relapsing polychondritis? 2006 Internet Journal of Otorhinolaryngology Rheumatology Patient showed improvement after treatment with…
38_88124321_0_3 88124321 How is Takayasu arteritis diagnosed? 2015 Journal of Autoimmune Diseases Rheumatology A 32-year-old woman with fatigue and limb pain…

데이터셋 전체를 넣지는 않고, 데모를 위해 처음 100개 항목만 가져올게요.

from qdrant_client.http.models import PointStruct, Document
from datasets import load_dataset
import uuid

dense_model = "sentence-transformers/all-minilm-l6-v2"
bm25_model = "qdrant/bm25"

ds = load_dataset("miriad/miriad-4.4M", split="train[0:100]")
points = []
for idx, item in enumerate(ds):
    passage = item["passage_text"]
    point = PointStruct(
        id=uuid.uuid4().hex,  # use unique string ID
        payload=item,
        vector={
            "dense_vector": Document(text=passage, model=dense_model),
            "bm25_sparse_vector": Document(text=passage, model=bm25_model),
        },
    )
    points.append(point)

client.upload_points(
    collection_name="{collection_name}",
    points=points,
    batch_size=8,
)
import { randomUUID } from "crypto";

const denseModel = "sentence-transformers/all-minilm-l6-v2";
const bm25Model = "qdrant/bm25";

// NOTE: loadDataset is a user-defined function.
// Implement it to handle dataset loading as needed.
const dataset = loadDataset("miriad/miriad-4.4M", "train[0:100]");
const points = dataset.map((item) => {
  const passage = item.passage_text;
  return {
    id: randomUUID().toString(),
    vector: {
      dense_vector: { text: passage, model: denseModel },
      bm25_sparse_vector: { text: passage, model: bm25Model },
    },
  };
});
await client.upsert("{collection_name}", { points });
use qdrant_client::qdrant::{Document, NamedVectors, PointStruct, UpsertPointsBuilder};
use qdrant_client::Payload;
use uuid::Uuid;

let dense_model = "sentence-transformers/all-minilm-l6-v2";
let bm25_model = "qdrant/bm25";

// NOTE: load_dataset is a user-defined function.
// Implement it to handle dataset loading as needed.
let dataset: Vec<_> = load_dataset("miriad/miriad-4.4M", "train[0:100]");
let points: Vec<PointStruct> = dataset
    .iter()
    .map(|item| {
        let passage = item["passage_text"].as_str().unwrap();
        let vectors = NamedVectors::default()
            .add_vector("dense_vector", Document::new(passage, dense_model))
            .add_vector("bm25_sparse_vector", Document::new(passage, bm25_model));
        let payload = Payload::try_from(item.clone()).unwrap();
        PointStruct::new(Uuid::new_v4().to_string(), vectors, payload)
    })
    .collect();

client.upsert_points(UpsertPointsBuilder::new("{collection_name}", points)).await?;
import static io.qdrant.client.PointIdFactory.id;
import static io.qdrant.client.VectorFactory.vector;
import static io.qdrant.client.VectorsFactory.namedVectors;
import io.qdrant.client.grpc.Points.Document;
import io.qdrant.client.grpc.Points.PointStruct;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;

String denseModel = "sentence-transformers/all-minilm-l6-v2";
String bm25Model = "qdrant/bm25";

// NOTE: loadDataset is a user-defined function.
// Implement it to handle dataset loading as needed.
List<Map<String, String>> dataset = loadDataset("miriad/miriad-4.4M", "train[0:100]");
List<PointStruct> points = new ArrayList<>();
for (Map<String, String> item : dataset) {
    String passage = item.get("passage_text");
    PointStruct point = PointStruct.newBuilder()
        .setId(id(UUID.randomUUID()))
        .setVectors(namedVectors(Map.of(
            "dense_vector",
            vector(Document.newBuilder().setText(passage).setModel(denseModel).build()),
            "bm25_sparse_vector",
            vector(Document.newBuilder().setText(passage).setModel(bm25Model).build())
        )))
        .build();
    points.add(point);
}
client.upsertAsync("{collection_name}", points).get();
var denseModel = "sentence-transformers/all-minilm-l6-v2";
var bm25Model = "qdrant/bm25";

// NOTE: LoadDataset is a user-defined function.
// Implement it to handle dataset loading as needed.
var dataset = LoadDataset("miriad/miriad-4.4M", "train[0:100]");
var points = new List<PointStruct>();
foreach (var item in dataset) {
    var passage = item["passage_text"].ToString();
    var point = new PointStruct {
        Id = Guid.NewGuid(),
        Vectors = new Dictionary<string, Vector> {
            ["dense_vector"] = new Document { Text = passage, Model = denseModel },
            ["bm25_sparse_vector"] = new Document { Text = passage, Model = bm25Model }
        },
    };
    points.Add(point);
}
await client.UpsertAsync(collectionName: "{collectionName}", points: points);
denseModel := "sentence-transformers/all-minilm-l6-v2"
bm25Model := "qdrant/bm25"

// NOTE: loadDataset is a user-defined function.
// Implement it to handle dataset loading as needed.
dataset := loadDataset("miriad/miriad-4.4M", "train[0:100]")
points := make([]*qdrant.PointStruct, 0, 100)
for _, item := range dataset {
    passage := item["passage_text"]
    point := &qdrant.PointStruct{
        Id: qdrant.NewID(uuid.New().String()),
        Vectors: qdrant.NewVectorsMap(map[string]*qdrant.Vector{
            "dense_vector":         qdrant.NewVectorDocument(&qdrant.Document{Text: passage, Model: denseModel}),
            "bm25_sparse_vector":   qdrant.NewVectorDocument(&qdrant.Document{Text: passage, Model: bm25Model}),
        }),
    }
    points = append(points, point)
}
_, err = client.Upsert(context.Background(), &qdrant.UpsertPoints{
    CollectionName: "{collection_name}",
    Points:         points,
})

입력 쿼리 설정

샘플 쿼리를 만들어 볼게요.

query_text = "What is relapsing polychondritis?"
let query_text = "What is relapsing polychondritis?";
let query_text = "What is relapsing polychondritis?";
String queryText = "What is relapsing polychondritis?";
var queryText = "What is relapsing polychondritis?";
queryText := "What is relapsing polychondritis?"

벡터 검색 실행

여기서는 의미적으로 관련 있는 결과를 검색해 줄 질문을 물어볼 거예요. 최종 결과는 RRF(Reciprocal Rank Fusion)를 사용한 재랭킹으로 얻어집니다.

results = client.query_points(
    collection_name="{collection_name}",
    prefetch=[
        models.Prefetch(
            query=models.Document(text=query_text, model=dense_model),
            using="dense_vector",
            limit=5,
        ),
        models.Prefetch(
            query=models.Document(text=query_text, model=bm25_model),
            using="bm25_sparse_vector",
            limit=5,
        ),
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    limit=5,
    with_payload=True,
)
print(results.points)
const results = await client.query("{collection_name}", {
  prefetch: [
    {
      query: { text: queryText, model: denseModel },
      using: "dense_vector",
    },
    {
      query: { text: queryText, model: bm25Model },
      using: "bm25_sparse_vector",
    },
  ],
  query: { fusion: "rrf" },
});
use qdrant_client::qdrant::{
    Document, Fusion, PrefetchQueryBuilder, Query, QueryPointsBuilder,
};

let dense_prefetch = PrefetchQueryBuilder::default()
    .query(Query::new_nearest(Document::new(query_text, dense_model)))
    .using("dense_vector")
    .build();
let bm25_prefetch = PrefetchQueryBuilder::default()
    .query(Query::new_nearest(Document::new(query_text, bm25_model)))
    .using("bm25_sparse_vector")
    .build();
let query_request = QueryPointsBuilder::new("{collection_name}")
    .add_prefetch(dense_prefetch)
    .add_prefetch(bm25_prefetch)
    .query(Query::new_fusion(Fusion::Rrf))
    .with_payload(true)
    .build();
let results = client.query(query_request).await?;
import static io.qdrant.client.QueryFactory.fusion;
import static io.qdrant.client.QueryFactory.nearest;
import io.qdrant.client.grpc.Points.Document;
import io.qdrant.client.grpc.Points.Fusion;
import io.qdrant.client.grpc.Points.PrefetchQuery;
import io.qdrant.client.grpc.Points.QueryPoints;

PrefetchQuery densePrefetch = PrefetchQuery.newBuilder()
    .setQuery(nearest(Document.newBuilder().setText(queryText).setModel(denseModel).build()))
    .setUsing("dense_vector")
    .build();
PrefetchQuery bm25Prefetch = PrefetchQuery.newBuilder()
    .setQuery(nearest(Document.newBuilder().setText(queryText).setModel(bm25Model).build()))
    .setUsing("bm25_sparse_vector")
    .build();
QueryPoints request = QueryPoints.newBuilder()
    .setCollectionName("{collection_name}")
    .addPrefetch(densePrefetch)
    .addPrefetch(bm25Prefetch)
    .setQuery(fusion(Fusion.RRF))
    .build();
client.queryAsync(request).get();
await client.QueryAsync(
    collectionName: "{collection_name}",
    prefetch: new List<PrefetchQuery> {
        new() {
            Query = new Document { Text = queryText, Model = bm25Model },
            Using = "bm25_sparse_vector",
            Limit = 5
        },
        new() {
            Query = new Document { Text = queryText, Model = denseModel },
            Using = "dense_vector",
            Limit = 5
        }
    },
    query: Fusion.Rrf,
    limit: 5
);
prefetch := []*qdrant.PrefetchQuery{
    {
        Query: qdrant.NewQueryDocument(&qdrant.Document{Text: queryText, Model: bm25Model}),
        Using: qdrant.PtrOf("bm25_sparse_vector"),
    },
    {
        Query: qdrant.NewQueryDocument(&qdrant.Document{Text: queryText, Model: denseModel}),
        Using: qdrant.PtrOf("dense_vector"),
    },
}
client.Query(context.Background(), &qdrant.QueryPoints{
    CollectionName: "{collection_name}",
    Prefetch:       prefetch,
    Query:          qdrant.NewQueryFusion(qdrant.Fusion_RRF),
})

의미 검색 엔진은 관련성 순서로 가장 유사한 결과를 검색해 줍니다.

[ScoredPoint(id='9968a760-fbb5-4d91-8549-ffbaeb3ebdba', version=0, score=14.545895, payload={'text': "Relapsing Polychondritis is a rare..."}, vector=None, shard_key=None, order_value=None)]

다음 단계

하이브리드 검색 결과 품질은 더 비싸지만 더 높은 품질의 모델로 결과를 재랭킹하면 개선할 수 있어요. 자세한 내용은 재랭킹을 사용한 하이브리드 검색 튜토리얼에서 확인하세요.

더 알아보기 (Learn more)