재랭킹을 사용한 하이브리드 검색
재랭킹을 사용한 하이브리드 검색 (tutorials-basics-reranking-hybrid-search)
| 시간: 40분 | 난이도: 중급 |
|---|---|
| 하이브리드 검색 + 재랭킹으로 관련성 높은 검색 엔진 만들기 |
재랭킹(reranking)은 검색 정밀도를 높이는 강력한 기법이에요. 전체 말뭉치(corpus)에 비싼 모델을 돌리는 대신, 이미 더 빠른 방법으로 검색된 더 작은 후보 집합에만 그 모델을 적용하는 거죠. 이렇게 하면 지연 시간을 낮게 유지하면서도 가장 관련성 높은 결과를 표면화할 수 있어요.
재랭킹은 특히 하이브리드 검색과 잘 어울려요. 하이브리드 검색은 여러 검색 경로에 걸쳐 넓은 검색망을 펼쳐 재현율(recall)을 극대화하죠. 재랭킹은 더 깊은 관련성 신호로 하이브리드 검색 결과를 정렬할 수 있어요. 예를 들어 late interaction 모델은 쿼리와 문서를 모두 여러 벡터로 표현해서, 단일 임베딩이 포착할 수 있는 것보다 더 세밀한 항목 수준 비교를 가능하게 해요.
이 튜토리얼에서는 의미 검색용 밀집 임베딩, 키워드 검색용 희소 임베딩, 재랭킹용 late interaction 임베딩을 사용하는 하이브리드 검색 엔진을 만드는 방법을 배울 거예요. 결과적으로 서로 다른 임베딩 유형의 강점을 결합해 매우 관련성 높은 결과를 제공하는 강력한 검색 엔진이 만들어집니다.
임베딩 생성에는 Qdrant Cloud 추론을 사용할 거예요. 이 튜토리얼에서 사용하는 세 가지 임베딩 모델(밀집, 희소, late interaction)은 Qdrant Cloud에서 무료로 제공됩니다. 직접 임베딩 인프라를 관리하는 것을 선호한다면 같은 원리를 적용할 수 있지만, 코드 예시를 자신의 임베딩 서비스에 맞게 조정해야 해요.
출처: Qdrant 공식문서
개요 (Overview)
아키텍처를 하나씩 나눠서 살펴볼게요.
수집 단계 (Ingestion Stage)

먼저 공상과학 소설 책 정보가 담긴 CSV 파일을 수집해요. 각 행은 문서(document) 로, 한 권의 책에 해당하며 제목(title), 저자(author), 설명(description) 필드를 가져요. 각 책 설명은 세 가지 유형의 임베딩을 생성하기 위해 처리됩니다.
- 밀집 임베딩(Dense embeddings) — 텍스트 뒤의 더 깊은 의미를 포착해요.
- 희소 임베딩(Sparse embeddings) — 더 전통적인 키워드 기반 방식. 구체적으로 BM25라는 확률적 검색 모델을 사용해요. BM25는 용어가 얼마나 자주 등장하는지, 문서 길이, 그리고 그 용어가 전체 문서에서 얼마나 흔한지 고려해 주어진 쿼리에 대한 용어의 관련성에 따라 문서 순위를 매겨요. 키워드가 많은 검색에 완벽하죠.
- Late interaction 임베딩 — 쿼리와 문서 용어 사이의 미묘한 상호작용을 포착해요. ColBERT 모델을 사용하는데, 이 모델은 두 단계로 동작합니다. 먼저 BERT를 사용해 쿼리와 문서 모두에 대해 맥락화된 임베딩을 생성하고, 그 다음 late interaction을 수행해 관련성을 미세 조정하기 위해 그 임베딩을 효율적으로 매칭해요. late interaction 모델에 대해 더 알아보려면 Qdrant에서 다중 벡터 표현으로 재랭킹하기 튜토리얼과 Multi-Vector Search 코스를 참고하세요.
데이터는 모든 임베딩을 포함해 벡터 검색 엔진(vector search engine) 인 Qdrant에 저장돼요. 이를 통해 여러 관련성 계층을 기반으로 문서를 효율적으로 검색하고, 검색하며, 재랭킹할 수 있습니다.
검색 단계 (Retrieval Stage)

사용자가 쿼리(query) 를 제출하면, 문서와 마찬가지로 각 임베딩 유형으로 변환됩니다. 밀집은 의미 검색, 희소는 키워드 검색, late interaction은 정밀한 재랭킹을 위한 것이죠.
다음으로 하이브리드 검색은 밀집과 희소 임베딩을 사용해 가장 관련성 높은 문서를 찾아요. 밀집 임베딩은 의미 검색에, 희소 임베딩은 키워드 검색에 사용됩니다. 그리고 그 결과 문서 집합은 late interaction 임베딩으로 재랭킹되어, 단순히 관련성 있는 것뿐 아니라 사용자 의도를 진짜로 충족시키는 문서에 우선순위를 둔 결과를 만들어냅니다.
구현 (Implementation)
Qdrant 클라이언트 설치 및 초기화
먼저 Qdrant 클라이언트를 설치해요.
qdrant-client
qdrant/js-client-rest
qdrant-client
io.qdrant:client
Qdrant.Client
github.com/qdrant/go-client
그 다음 클라이언트를 초기화합니다.
from qdrant_client import QdrantClient
client = QdrantClient(
url="https://xyz-example.eu-central.aws.cloud.qdrant.io:6333",
api_key="<your-api-key>",
cloud_inference=True,
)
const client = new QdrantClient({
url: QDRANT_URL,
apiKey: QDRANT_API_KEY,
});
let client = Qdrant::from_url(qdrant_url)
.api_key(qdrant_api_key)
.build()?;
QdrantClient client = new QdrantClient(
QdrantGrpcClient.newBuilder(QDRANT_URL, 6334, true)
.withApiKey(QDRANT_API_KEY)
.build()
);
var client = new QdrantClient(
host: QDRANT_URL,
https: true,
apiKey: QDRANT_API_KEY
);
client, err := qdrant.NewClient(&qdrant.Config{
Host: QDRANT_URL,
APIKey: QDRANT_API_KEY,
UseTLS: true,
})
모델 (Models)
다음으로 세 가지 임베딩 모델을 정의해요. 밀집 임베딩에는 384차원의 sentence-transformers/all-MiniLM-L6-v2 모델을, 희소 임베딩에는 qdrant/bm25 모델을, late interaction 임베딩에는 96차원의 answerdotai/answerai-colbert-small-v1 멀티벡터 모델을 사용할 거예요.
dense_embedding_model = "sentence-transformers/all-MiniLM-L6-v2"
sparse_embedding_model = "qdrant/bm25"
late_interaction_embedding_model = "answerdotai/answerai-colbert-small-v1"
const denseEmbeddingModel = "sentence-transformers/all-MiniLM-L6-v2";
const sparseEmbeddingModel = "qdrant/bm25";
const lateInteractionEmbeddingModel = "answerdotai/answerai-colbert-small-v1";
let dense_embedding_model = "sentence-transformers/all-MiniLM-L6-v2";
let sparse_embedding_model = "qdrant/bm25";
let late_interaction_embedding_model = "answerdotai/answerai-colbert-small-v1";
String denseEmbeddingModel = "sentence-transformers/all-MiniLM-L6-v2";
String sparseEmbeddingModel = "qdrant/bm25";
String lateInteractionEmbeddingModel = "answerdotai/answerai-colbert-small-v1";
string denseEmbeddingModel = "sentence-transformers/all-MiniLM-L6-v2";
string sparseEmbeddingModel = "qdrant/bm25";
string lateInteractionEmbeddingModel = "answerdotai/answerai-colbert-small-v1";
denseEmbeddingModel := "sentence-transformers/all-MiniLM-L6-v2"
sparseEmbeddingModel := "qdrant/bm25"
lateInteractionEmbeddingModel := "answerdotai/answerai-colbert-small-v1"
컬렉션 생성
hybrid-search라는 새 컬렉션을 만들어 세 가지 벡터 유형을 처리하도록 구성해요.
- 밀집 임베딩 (
dense) — 의미 비교를 위한 코사인 거리(cosine distance) 사용 - Late interaction 임베딩 (
multi) — 코사인 거리 사용, 최대 유사도(maximum similarity) 비교기를 사용하는 멀티벡터 구성.m=0구성을 통해 HNSW 인덱싱을 비활성화하는 점에 주목하세요. 이 임베딩들은 재랭킹에만 사용되고 ANN 검색에는 사용되지 않으므로 HNSW 인덱스가 필요 없어요. - 희소 임베딩 (
sparse) — IDF modifier를 사용하는 키워드 기반 검색용
from qdrant_client.models import Distance, VectorParams, models
collection_name = "hybrid-search"
if client.collection_exists(collection_name=collection_name):
client.delete_collection(collection_name=collection_name)
client.create_collection(
collection_name,
vectors_config={
"dense": models.VectorParams(
size=384,
distance=models.Distance.COSINE,
),
"multi": models.VectorParams(
size=96,
distance=models.Distance.COSINE,
multivector_config=models.MultiVectorConfig(
comparator=models.MultiVectorComparator.MAX_SIM,
),
hnsw_config=models.HnswConfigDiff(
m=0 # Disable HNSW for reranking
),
),
},
sparse_vectors_config={
"sparse": models.SparseVectorParams(
modifier=models.Modifier.IDF
)
},
)
const collectionName = "hybrid-search";
if (await client.collectionExists(collectionName)) {
await client.deleteCollection(collectionName);
}
await client.createCollection(collectionName, {
vectors: {
dense: { size: 384, distance: "Cosine" },
multi: {
size: 96,
distance: "Cosine",
multivector_config: { comparator: "max_sim" },
hnsw_config: { m: 0 }, // Disable HNSW for reranking
},
},
sparse_vectors: { sparse: { modifier: "idf" } },
});
let collection_name = "hybrid-search";
if client.collection_exists(collection_name).await? {
client.delete_collection(collection_name).await?;
}
let mut vectors = VectorsConfigBuilder::default();
vectors.add_named_vector_params(
"dense",
VectorParamsBuilder::new(384, Distance::Cosine),
);
vectors.add_named_vector_params(
"multi",
VectorParamsBuilder::new(96, Distance::Cosine)
.multivector_config(MultiVectorConfigBuilder::new(MultiVectorComparator::MaxSim))
.hnsw_config(HnswConfigDiffBuilder::default().m(0)), // Disable HNSW for reranking
);
let mut sparse = SparseVectorsConfigBuilder::default();
sparse.add_named_vector_params(
"sparse",
SparseVectorParamsBuilder::default().modifier(Modifier::Idf),
);
client
.create_collection(
CreateCollectionBuilder::new(collection_name)
.vectors_config(vectors)
.sparse_vectors_config(sparse),
)
.await?;
String collectionName = "hybrid-search";
if (client.collectionExistsAsync(collectionName).get()) {
client.deleteCollectionAsync(collectionName).get();
}
client.createCollectionAsync(
CreateCollection.newBuilder()
.setCollectionName(collectionName)
.setVectorsConfig(
VectorsConfig.newBuilder()
.setParamsMap(
VectorParamsMap.newBuilder()
.putMap("dense",
VectorParams.newBuilder()
.setSize(384)
.setDistance(Distance.Cosine)
.build())
.putMap("multi",
VectorParams.newBuilder()
.setSize(96)
.setDistance(Distance.Cosine)
.setMultivectorConfig(
MultiVectorConfig.newBuilder()
.setComparator(MultiVectorComparator.MaxSim)
.build())
.setHnswConfig(
HnswConfigDiff.newBuilder()
.setM(0) // Disable HNSW for reranking
.build())
.build())
.build())
.build())
.setSparseVectorsConfig(
SparseVectorConfig.newBuilder()
.putMap("sparse",
SparseVectorParams.newBuilder()
.setModifier(Modifier.Idf)
.build())
.build())
.build()
).get();
string collectionName = "hybrid-search";
if (await client.CollectionExistsAsync(collectionName))
await client.DeleteCollectionAsync(collectionName);
await client.CreateCollectionAsync(
collectionName: collectionName,
vectorsConfig: new VectorParamsMap {
Map = {
["dense"] = new VectorParams {
Size = 384,
Distance = Distance.Cosine,
},
["multi"] = new VectorParams {
Size = 96,
Distance = Distance.Cosine,
MultivectorConfig = new() { Comparator = MultiVectorComparator.MaxSim },
HnswConfig = new HnswConfigDiff { M = 0 }, // Disable HNSW for reranking
},
}
},
sparseVectorsConfig: new SparseVectorConfig {
Map = {
["sparse"] = new SparseVectorParams { Modifier = Modifier.Idf }
}
}
);
collectionName := "hybrid-search"
exists, err := client.CollectionExists(context.Background(), collectionName)
if exists {
client.DeleteCollection(context.Background(), collectionName)
}
client.CreateCollection(context.Background(), &qdrant.CreateCollection{
CollectionName: collectionName,
VectorsConfig: qdrant.NewVectorsConfigMap(map[string]*qdrant.VectorParams{
"dense": {
Size: 384, Distance: qdrant.Distance_Cosine,
},
"multi": {
Size: 96, Distance: qdrant.Distance_Cosine,
MultivectorConfig: &qdrant.MultiVectorConfig{Comparator: qdrant.MultiVectorComparator_MaxSim},
HnswConfig: &qdrant.HnswConfigDiff{M: qdrant.PtrOf(uint64(0))}, // Disable HNSW for reranking
},
}),
SparseVectorsConfig: qdrant.NewSparseVectorsConfig(map[string]*qdrant.SparseVectorParams{
"sparse": {Modifier: qdrant.Modifier_Idf.Enum()},
}),
})
데이터 수집 (Ingest Data)
이제 과학 소설 책 설명을 CSV에서 로드해 hybrid-search 컬렉션에 삽입할 수 있어요. 클라우드 추론(Cloud Inference) 덕분에 텍스트를 Document 객체로 감싸면 임베딩이 서버 측에서 계산됩니다.
from qdrant_client.models import Document, PointStruct
csv_url = 'https://raw.githubusercontent.com/qdrant/examples/refs/heads/master/sci-fi-books/top_100_scifi_books_full.csv'
points = (
PointStruct(
id=idx,
vector={
"dense": Document(text=row['Description'], model=dense_embedding_model),
"sparse": Document(text=row['Description'], model=sparse_embedding_model),
"multi": Document(text=row['Description'], model=late_interaction_embedding_model),
},
payload={
"title": row['Title'],
"author": row['Author'],
"description": row['Description'],
},
)
for idx, row in enumerate(parse_csv(csv_url))
)
client.upload_points(
collection_name=collection_name,
points=points,
batch_size=25,
)
const csvUrl = "https://raw.githubusercontent.com/qdrant/examples/refs/heads/master/sci-fi-books/top_100_scifi_books_full.csv";
const batchSize = 25;
let idx = 0;
let buffer: Schemas["PointStruct"][] = [];
for await (const { title, author, description } of parseCSV(csvUrl)) {
buffer.push({
id: idx++,
vector: {
dense: { text: description, model: denseEmbeddingModel },
sparse: { text: description, model: sparseEmbeddingModel },
multi: { text: description, model: lateInteractionEmbeddingModel },
},
payload: { title, author, description },
});
if (buffer.length >= batchSize) {
await client.upsert(collectionName, { points: buffer });
buffer = [];
}
}
if (buffer.length > 0) {
await client.upsert(collectionName, { points: buffer });
}
let csv_url = "https://raw.githubusercontent.com/qdrant/examples/refs/heads/master/sci-fi-books/top_100_scifi_books_full.csv";
let batch_size = 25;
let mut idx: u64 = 0;
let mut buffer: Vec<PointStruct> = Vec::new();
for row in parse_csv(csv_url)? {
let row = row?;
let title = row.title;
let author = row.author;
let description = row.description;
let vectors = NamedVectors::default()
.add_vector("dense", Document::new(&description, dense_embedding_model))
.add_vector("sparse", Document::new(&description, sparse_embedding_model))
.add_vector("multi", Document::new(&description, late_interaction_embedding_model));
buffer.push(PointStruct::new(
idx,
vectors,
[
("title", title.into()),
("author", author.into()),
("description", description.into()),
],
));
idx += 1;
if buffer.len() >= batch_size {
client
.upsert_points(UpsertPointsBuilder::new(collection_name, std::mem::take(&mut buffer,)))
.await?;
}
}
if !buffer.is_empty() {
client
.upsert_points(UpsertPointsBuilder::new(collection_name, buffer))
.await?;
}
String csvUrl = "https://raw.githubusercontent.com/qdrant/examples/refs/heads/master/sci-fi-books/top_100_scifi_books_full.csv";
int batchSize = 25;
long idx = 0;
List<PointStruct> buffer = new ArrayList<>();
try (var stream = parseCSV(csvUrl)) {
for (var row : (Iterable<CsvRow>) stream::iterator) {
String title = row.title;
String author = row.author;
String description = row.description;
buffer.add(
PointStruct.newBuilder()
.setId(io.qdrant.client.PointIdFactory.id(idx++))
.setVectors(namedVectors(Map.of(
"dense", vector(Document.newBuilder().setText(description).setModel(denseEmbeddingModel).build()),
"sparse", vector(Document.newBuilder().setText(description).setModel(sparseEmbeddingModel).build()),
"multi", vector(Document.newBuilder().setText(description).setModel(lateInteractionEmbeddingModel).build())
)))
.putAllPayload(Map.of(
"title", value(title),
"author", value(author),
"description", value(description)
))
.build()
);
if (buffer.size() >= batchSize) {
client.upsertAsync(collectionName, buffer).get();
buffer.clear();
}
}
}
if (!buffer.isEmpty()) {
client.upsertAsync(collectionName, buffer).get();
}
string csvUrl = "https://raw.githubusercontent.com/qdrant/examples/refs/heads/master/sci-fi-books/top_100_scifi_books_full.csv";
int batchSize = 25;
ulong idx = 0;
var buffer = new List<PointStruct>();
await foreach (var (title, author, description) in ParseCsv(csvUrl)) {
buffer.Add(new PointStruct {
Id = idx++,
Vectors = new Dictionary<string, Vector> {
["dense"] = new Document { Text = description, Model = denseEmbeddingModel },
["sparse"] = new Document { Text = description, Model = sparseEmbeddingModel },
["multi"] = new Document { Text = description, Model = lateInteractionEmbeddingModel },
},
Payload = {
["title"] = title,
["author"] = author,
["description"] = description
}
});
if (buffer.Count >= batchSize) {
await client.UpsertAsync(collectionName: collectionName, points: buffer);
buffer.Clear();
}
}
if (buffer.Count > 0)
await client.UpsertAsync(collectionName: collectionName, points: buffer);
csvUrl := "https://raw.githubusercontent.com/qdrant/examples/refs/heads/master/sci-fi-books/top_100_scifi_books_full.csv"
batchSize := 25
var idx uint64
var buffer []*qdrant.PointStruct
err = parseCSV(csvUrl, func(row CSVRow) {
title := row.Title
author := row.Author
description := row.Description
buffer = append(buffer, &qdrant.PointStruct{
Id: qdrant.NewIDNum(idx),
Vectors: qdrant.NewVectorsMap(map[string]*qdrant.Vector{
"dense": qdrant.NewVectorDocument(&qdrant.Document{Text: description, Model: denseEmbeddingModel}),
"sparse": qdrant.NewVectorDocument(&qdrant.Document{Text: description, Model: sparseEmbeddingModel}),
"multi": qdrant.NewVectorDocument(&qdrant.Document{Text: description, Model: lateInteractionEmbeddingModel}),
}),
Payload: qdrant.NewValueMap(map[string]any{
"title": title, "author": author, "description": description,
}),
})
idx++
if len(buffer) >= batchSize {
client.Upsert(context.Background(), &qdrant.UpsertPoints{CollectionName: collectionName, Points: buffer})
buffer = nil
}
})
if len(buffer) > 0 {
client.Upsert(context.Background(), &qdrant.UpsertPoints{CollectionName: collectionName, Points: buffer})
}
이 코드는 각 책에 대해 세 가지 벡터 유형과 제목·저자·설명을 담은 페이로드를 가진 포인트를 만들어요. 문서는 25개씩 배치로 Qdrant에 업로드되며, 클라우드 추론이 세 가지 임베딩을 모두 즉석에서 생성해 줍니다. 프로덕션에서는 최적의 배치 크기가 데이터와 클러스터에 따라 달라지므로, 최상의 성능을 위해 다양한 크기로 실험해 보는 것이 좋아요.
이 코드는 CSV 파일을 스트리밍하고 파싱하는 헬퍼 함수를 사용합니다.
Details
import csv
import urllib.request
def parse_csv(url):
with urllib.request.urlopen(url) as response:
reader = csv.DictReader(line.decode('utf-8') for line in response)
yield from reader
function parseCsvLine(line: string): string[] {
const fields: string[] = [];
let i = 0;
while (i < line.length) {
if (line[i] === '"') {
i++;
let field = "";
while (i < line.length) {
if (line[i] === '"' && line[i + 1] === '"') { field += '"'; i += 2; }
else if (line[i] === '"') { i++; break; }
else { field += line[i++]; }
}
fields.push(field);
if (line[i] === ",") i++;
} else {
const start = i;
while (i < line.length && line[i] !== ",") i++;
fields.push(line.slice(start, i));
if (i < line.length) i++;
}
}
return fields;
}
async function* parseCSV(url: string): AsyncGenerator<{ text: string; datetime: string }> {
const response = await fetch(url);
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let remainder = "";
let headers: string[] | null = null;
let textIdx = -1;
let datetimeIdx = -1;
while (true) {
const { done, value } = await reader.read();
const chunk = done ? "" : decoder.decode(value, { stream: true });
const lines = (remainder + chunk).split("\n");
remainder = done ? "" : lines.pop()!;
for (const line of lines) {
if (!line.trim()) continue;
if (headers === null) {
headers = line.split(",");
textIdx = headers.indexOf("text");
datetimeIdx = headers.indexOf("datetime");
continue;
}
const fields = parseCsvLine(line);
yield { text: fields[textIdx], datetime: fields[datetimeIdx] };
}
if (done) break;
}
}
struct CsvRow {
text: String,
datetime: String,
}
fn parse_csv(url: &str) -> anyhow::Result<impl Iterator<Item = anyhow::Result<CsvRow>>> {
let reader = ureq::get(url).call()?.into_body().into_reader();
let mut rdr = csv::Reader::from_reader(reader);
let headers = rdr.headers()?.clone();
let text_idx = headers.iter().position(|h| h == "text").unwrap();
let datetime_idx = headers.iter().position(|h| h == "datetime").unwrap();
let iter = rdr.into_records().map(move |result| {
let record = result?;
Ok(CsvRow {
text: record[text_idx].to_string(),
datetime: record[datetime_idx].to_string(),
})
});
Ok(iter)
}
static class CsvRow {
final String text;
final String datetime;
CsvRow(String text, String datetime) {
this.text = text;
this.datetime = datetime;
}
}
static Stream<CsvRow> parseCSV(String url) throws Exception {
Function<String, List<String>> parseCsvLine = line -> {
List<String> fields = new ArrayList<>();
boolean inQuotes = false;
var sb = new StringBuilder();
for (char c : line.toCharArray()) {
if (c == '"') { inQuotes = !inQuotes; }
else if (c == ',' && !inQuotes) { fields.add(sb.toString()); sb.setLength(0); }
else { sb.append(c); }
}
fields.add(sb.toString());
return fields;
};
var reader = new BufferedReader(new InputStreamReader(new URL(url).openStream()));
String headerLine = reader.readLine();
List<String> headers = List.of(headerLine.split(","));
int textIdx = headers.indexOf("text");
int datetimeIdx = headers.indexOf("datetime");
return reader.lines()
.map(line -> {
List<String> fields = parseCsvLine.apply(line);
return new CsvRow(fields.get(textIdx), fields.get(datetimeIdx));
})
.onClose(() -> { try { reader.close(); } catch (Exception ignored) {} });
}
async IAsyncEnumerable<(string text, string datetime)> ParseCsv(string url) {
using var httpClient = new HttpClient();
using var stream = await httpClient.GetStreamAsync(url);
using var parser = new TextFieldParser(new StreamReader(stream));
parser.TextFieldType = Microsoft.VisualBasic.FileIO.FieldType.Delimited;
parser.SetDelimiters(",");
string[]? headers = parser.ReadFields();
int textIdx = Array.IndexOf(headers!, "text");
int datetimeIdx = Array.IndexOf(headers!, "datetime");
while (!parser.EndOfData) {
var fields = parser.ReadFields()!;
yield return (fields[textIdx], fields[datetimeIdx]);
}
}
type CSVRow struct {
Text string
Datetime string
}
func parseCSV(url string, fn func(CSVRow)) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
csvReader := csv.NewReader(resp.Body)
headers, err := csvReader.Read()
if err != nil {
return err
}
textIdx, datetimeIdx := -1, -1
for i, h := range headers {
switch h {
case "text":
textIdx = i
case "datetime":
datetimeIdx = i
}
}
for {
row, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
return err
}
fn(CSVRow{Text: row[textIdx], Datetime: row[datetimeIdx]})
}
return nil
}
검색 (Retrieval)
결과를 결합하기 전에 밀집 검색과 희소 검색이 각각 어떻게 수행되는지 먼저 살펴볼게요.
검색 시 쿼리를 Document 객체로 감싸서 클라우드 추론이 적절한 임베딩을 서버 측에서 계산하도록 합니다.
밀집 검색(Dense retrieval) 은 의미를 포착해요.
import pprint
query = "time travel"
results = client.query_points(
collection_name,
query=models.Document(text=query, model=dense_embedding_model),
using="dense",
limit=10,
)
pprint.pp(results.points)
const query = "time travel";
const denseResults = await client.query(collectionName, {
query: { text: query, model: denseEmbeddingModel },
using: "dense",
limit: 10,
});
console.log(denseResults.points);
let query = "time travel";
let results = client
.query(
QueryPointsBuilder::new(collection_name)
.query(Query::new_nearest(Document::new(query, dense_embedding_model)))
.using("dense")
.limit(10),
)
.await?;
for result in results.result {
println!("{:?}", result);
}
String query = "time travel";
var results = client.queryAsync(
QueryPoints.newBuilder()
.setCollectionName(collectionName)
.setQuery(nearest(Document.newBuilder().setText(query).setModel(denseEmbeddingModel).build()))
.setUsing("dense")
.setLimit(10)
.build()
).get();
for (var result : results) {
System.out.println(result);
}
string query = "time travel";
var results = await client.QueryAsync(
collectionName: collectionName,
query: new Document { Text = query, Model = denseEmbeddingModel },
usingVector: "dense",
limit: 10
);
foreach (var result in results)
Console.WriteLine(result);
query := "time travel"
results, err := client.Query(context.Background(), &qdrant.QueryPoints{
CollectionName: collectionName,
Query: qdrant.NewQueryDocument(&qdrant.Document{Text: query, Model: denseEmbeddingModel}),
Using: qdrant.PtrOf("dense"),
Limit: qdrant.PtrOf(uint64(10)),
})
for _, result := range results {
fmt.Println(result)
}
상위 5개 결과를 살펴볼게요.
| 순위 | 제목 | 설명 |
|---|---|---|
| 1 | The Time Machine | A Victorian scientist travels far into the future to witness civilization's fate. |
| 2 | Slaughterhouse-Five | A nonlinear, time-tripping reflection on war and fate. |
| 3 | The Peripheral | Two timelines intersect through telepresence technology. |
| 4 | The Space Between Worlds | A multiverse traveler uncovers dangerous secrets across parallel Earths. |
| 5 | The Forever War | A soldier experiences extreme time dilation while fighting an interstellar war. |
이 책들은 모두 시간여행이라는 개념과 강한 의미적 연관을 가지고 있어요. 정확한 표현이 설명에 나타나지 않더라도 말이죠.
희소 검색(Sparse retrieval) 은 키워드 매칭에 초점을 맞춥니다.
results = client.query_points(
collection_name,
query=models.Document(text=query, model=sparse_embedding_model),
using="sparse",
limit=10,
)
pprint.pp(results.points)
const sparseResults = await client.query(collectionName, {
query: { text: query, model: sparseEmbeddingModel },
using: "sparse",
limit: 10,
});
console.log(sparseResults.points);
let results = client
.query(
QueryPointsBuilder::new(collection_name)
.query(Query::new_nearest(Document::new(query, sparse_embedding_model)))
.using("sparse")
.limit(10),
)
.await?;
for result in results.result {
println!("{:?}", result);
}
results = client.queryAsync(
QueryPoints.newBuilder()
.setCollectionName(collectionName)
.setQuery(nearest(Document.newBuilder().setText(query).setModel(sparseEmbeddingModel).build()))
.setUsing("sparse")
.setLimit(10)
.build()
).get();
for (var result : results) {
System.out.println(result);
}
results = await client.QueryAsync(
collectionName: collectionName,
query: new Document { Text = query, Model = sparseEmbeddingModel },
usingVector: "sparse",
limit: 10
);
foreach (var result in results)
Console.WriteLine(result);
results, err = client.Query(context.Background(), &qdrant.QueryPoints{
CollectionName: collectionName,
Query: qdrant.NewQueryDocument(&qdrant.Document{Text: query, Model: sparseEmbeddingModel}),
Using: qdrant.PtrOf("sparse"),
Limit: qdrant.PtrOf(uint64(10)),
})
for _, result := range results {
fmt.Println(result)
}
상위 5개 결과는 다음과 같습니다.
| 순위 | 제목 | 설명 |
|---|---|---|
| 1 | Station Eleven | A traveling symphony roams a post-pandemic North America. |
| 2 | Hyperion | Travelers share haunting tales on a pilgrimage to confront the mysterious Shrike. |
| 3 | The Space Between Worlds | A multiverse traveler uncovers dangerous secrets across parallel Earths. |
| 4 | The Time Machine | A Victorian scientist travels far into the future to witness civilization's fate. |
| 5 | Slaughterhouse-Five | A nonlinear, time-tripping reflection on war and fate. |
희소 BM25 모델은 형태소 어간 추출(stemming)로 키워드 매칭을 수행해요. 그래서 설명에 "time"과 "travel"이라는 단어의 변형이 포함된 책들을 반환하죠. 예를 들어 "Station Eleven"과 "Hyperion"은 "traveling"과 "travelers"를 언급하지만 주로 시간여행에 관한 책은 아닙니다.
하이브리드 검색은 밀집과 희소 결과를 프리페치(prefetch)한 다음, RRF(Reciprocal Rank Fusion)로 병합하는 방식으로 수행할 수 있어요.
prefetch = [
models.Prefetch(
query=models.Document(text=query, model=dense_embedding_model),
using="dense",
limit=20,
),
models.Prefetch(
query=models.Document(text=query, model=sparse_embedding_model),
using="sparse",
limit=20,
),
]
results = client.query_points(
collection_name,
prefetch=prefetch,
query=models.FusionQuery(fusion=models.Fusion.RRF),
with_payload=True,
limit=10,
)
pprint.pp(results.points)
const hybridResults = await client.query(collectionName, {
prefetch: [
{
query: { text: query, model: denseEmbeddingModel },
using: "dense",
limit: 20,
},
{
query: { text: query, model: sparseEmbeddingModel },
using: "sparse",
limit: 20,
},
],
query: { fusion: "rrf" },
with_payload: true,
limit: 10,
});
console.log(hybridResults.points);
let results = client
.query(
QueryPointsBuilder::new(collection_name)
.add_prefetch(
PrefetchQueryBuilder::default()
.query(Query::new_nearest(Document::new(query, dense_embedding_model)))
.using("dense")
.limit(20u64),
)
.add_prefetch(
PrefetchQueryBuilder::default()
.query(Query::new_nearest(Document::new(query, sparse_embedding_model)))
.using("sparse")
.limit(20u64),
)
.query(Query::new_fusion(Fusion::Rrf))
.with_payload(true)
.limit(10),
)
.await?;
for result in results.result {
println!("{:?}", result);
}
results = client.queryAsync(
QueryPoints.newBuilder()
.setCollectionName(collectionName)
.addPrefetch(
PrefetchQuery.newBuilder()
.setQuery(nearest(Document.newBuilder().setText(query).setModel(denseEmbeddingModel).build()))
.setUsing("dense")
.setLimit(20)
.build())
.addPrefetch(
PrefetchQuery.newBuilder()
.setQuery(nearest(Document.newBuilder().setText(query).setModel(sparseEmbeddingModel).build()))
.setUsing("sparse")
.setLimit(20)
.build())
.setQuery(Query.newBuilder().setFusion(Fusion.RRF).build())
.setWithPayload(enable(true))
.setLimit(10)
.build()
).get();
for (var result : results) {
System.out.println(result);
}
results = await client.QueryAsync(
collectionName: collectionName,
prefetch: new List<PrefetchQuery> {
new() {
Query = new Document { Text = query, Model = denseEmbeddingModel },
Using = "dense",
Limit = 20,
},
new() {
Query = new Document { Text = query, Model = sparseEmbeddingModel },
Using = "sparse",
Limit = 20,
},
},
query: Fusion.Rrf,
payloadSelector: true,
limit: 10
);
foreach (var result in results)
Console.WriteLine(result);
results, err = client.Query(context.Background(), &qdrant.QueryPoints{
CollectionName: collectionName,
Prefetch: []*qdrant.PrefetchQuery{
{
Query: qdrant.NewQueryDocument(&qdrant.Document{Text: query, Model: denseEmbeddingModel}),
Using: qdrant.PtrOf("dense"),
Limit: qdrant.PtrOf(uint64(20)),
},
{
Query: qdrant.NewQueryDocument(&qdrant.Document{Text: query, Model: sparseEmbeddingModel}),
Using: qdrant.PtrOf("sparse"),
Limit: qdrant.PtrOf(uint64(20)),
},
},
Query: qdrant.NewQueryFusion(qdrant.Fusion_RRF),
WithPayload: qdrant.NewWithPayload(true),
Limit: qdrant.PtrOf(uint64(10)),
})
for _, result := range results {
fmt.Println(result)
}
이것은 두 개의 하위 쿼리를 병렬로 실행해요. 하나는 의미를 위한 밀집 임베딩을, 다른 하나는 키워드 매칭을 위한 희소 BM25 임베딩을 사용하죠. 프리페치 단계에서 각 하위 쿼리(밀집, 희소)의 상위 20개 후보를 가져오고, RRF로 순위 목록을 단일 결과로 융합합니다.
결과는 시간여행에 의미적으로 관련된 책과 그 키워드를 포함한 책이 섞여 있어, 더 넓은 관련 문서 집합을 제공해요. 하지만 기본적으로 RRF는 두 신호를 동등하게 취급하고 쿼리와 문서 용어 사이의 미묘한 상호작용을 포착하지 않기 때문에, 순위가 최적이 아닐 수 있어요. 예를 들어 "Station Eleven"은 시간여행에 관한 책이 아님에도 키워드 매칭이 더 강해서 높게 순위가 매겨집니다.
| 순위 | 제목 | 설명 |
|---|---|---|
| 1 | The Time Machine | A Victorian scientist travels far into the future to witness civilization's fate. |
| 2 | Station Eleven | A traveling symphony roams a post-pandemic North America. |
| 3 | Slaughterhouse-Five | A nonlinear, time-tripping reflection on war and fate. |
| 4 | The Space Between Worlds | A multiverse traveler uncovers dangerous secrets across parallel Earths. |
| 5 | Hyperion | Travelers share haunting tales on a pilgrimage to confront the mysterious Shrike. |
재랭크 (Rerank)
하이브리드 검색 결과는 late interaction 임베딩을 사용해 최대 정밀도로 재랭킹할 수 있어요. RRF로 융합하는 대신, 최종 순위 신호로 ColBERT 멀티벡터를 사용합니다.
prefetch = [
models.Prefetch(
query=models.Document(text=query, model=dense_embedding_model),
using="dense",
limit=20,
),
models.Prefetch(
query=models.Document(text=query, model=sparse_embedding_model),
using="sparse",
limit=20,
),
]
results = client.query_points(
collection_name,
prefetch=prefetch,
query=models.Document(text=query, model=late_interaction_embedding_model),
using="multi",
with_payload=True,
limit=10,
)
pprint.pp(results.points)
const rerankedResults = await client.query(collectionName, {
prefetch: [
{
query: { text: query, model: denseEmbeddingModel },
using: "dense",
limit: 20,
},
{
query: { text: query, model: sparseEmbeddingModel },
using: "sparse",
limit: 20,
},
],
query: { text: query, model: lateInteractionEmbeddingModel },
using: "multi",
with_payload: true,
limit: 10,
});
console.log(rerankedResults.points);
let results = client
.query(
QueryPointsBuilder::new(collection_name)
.add_prefetch(
PrefetchQueryBuilder::default()
.query(Query::new_nearest(Document::new(query, dense_embedding_model)))
.using("dense")
.limit(20u64),
)
.add_prefetch(
PrefetchQueryBuilder::default()
.query(Query::new_nearest(Document::new(query, sparse_embedding_model)))
.using("sparse")
.limit(20u64),
)
.query(Query::new_nearest(Document::new(query, late_interaction_embedding_model)))
.using("multi")
.with_payload(true)
.limit(10),
)
.await?;
for result in results.result {
println!("{:?}", result);
}
results = client.queryAsync(
QueryPoints.newBuilder()
.setCollectionName(collectionName)
.addPrefetch(
PrefetchQuery.newBuilder()
.setQuery(nearest(Document.newBuilder().setText(query).setModel(denseEmbeddingModel).build()))
.setUsing("dense")
.setLimit(20)
.build())
.addPrefetch(
PrefetchQuery.newBuilder()
.setQuery(nearest(Document.newBuilder().setText(query).setModel(sparseEmbeddingModel).build()))
.setUsing("sparse")
.setLimit(20)
.build())
.setQuery(nearest(Document.newBuilder().setText(query).setModel(lateInteractionEmbeddingModel).build()))
.setUsing("multi")
.setWithPayload(enable(true))
.setLimit(10)
.build()
).get();
for (var result : results) {
System.out.println(result);
}
results = await client.QueryAsync(
collectionName: collectionName,
prefetch: new List<PrefetchQuery> {
new() {
Query = new Document { Text = query, Model = denseEmbeddingModel },
Using = "dense",
Limit = 20,
},
new() {
Query = new Document { Text = query, Model = sparseEmbeddingModel },
Using = "sparse",
Limit = 20,
},
},
query: new Document { Text = query, Model = lateInteractionEmbeddingModel },
usingVector: "multi",
payloadSelector: true,
limit: 10
);
foreach (var result in results)
Console.WriteLine(result);
results, err = client.Query(context.Background(), &qdrant.QueryPoints{
CollectionName: collectionName,
Prefetch: []*qdrant.PrefetchQuery{
{
Query: qdrant.NewQueryDocument(&qdrant.Document{Text: query, Model: denseEmbeddingModel}),
Using: qdrant.PtrOf("dense"),
Limit: qdrant.PtrOf(uint64(20)),
},
{
Query: qdrant.NewQueryDocument(&qdrant.Document{Text: query, Model: sparseEmbeddingModel}),
Using: qdrant.PtrOf("sparse"),
Limit: qdrant.PtrOf(uint64(20)),
},
},
Query: qdrant.NewQueryDocument(&qdrant.Document{Text: query, Model: lateInteractionEmbeddingModel}),
Using: qdrant.PtrOf("multi"),
WithPayload: qdrant.NewWithPayload(true),
Limit: qdrant.PtrOf(uint64(10)),
})
for _, result := range results {
fmt.Println(result)
}
프리페치 단계는 각 하위 쿼리(밀집, 희소)의 상위 20개 후보를 가져오고, ColBERT late interaction 모델이 결합된 후보들을 재랭킹해 가장 관련성 높은 결과를 표면화합니다.
결과 비교 (Compare results)
재랭킹 유무에 따른 하이브리드 검색의 상위 10개 결과를 비교해 볼게요. late interaction 임베딩에 따른 관련성에 따라 일부 문서의 순위가 어떻게 바뀌는지 주목하세요.
| 제목 | 설명 | 재랭킹 순위 | RRF 순위 | 순위 변화 |
|---|---|---|---|---|
| Slaughterhouse-Five | A nonlinear, time-tripping reflection on war and fate. | 1 | 3 | 올라감 |
| The Forever War | A soldier experiences extreme time dilation while fighting an interstellar war. | 2 | 8 | 올라감 |
| Kindred | A modern Black woman is pulled back in time to the antebellum South. | 3 | 7 | 올라감 |
| Spin | Earth is enclosed in a time-distorting barrier by unknown forces. | 4 | 6 | 올라감 |
| The Light Brigade | Soldiers are turned into light to fight a war across space-time. | 5 | 10 | 올라감 |
재랭킹 모범 사례 (Best Practices in Reranking)
late interaction 모델로 재랭킹하면 검색 결과의 관련성을 극적으로 개선할 수 있어요. 특히 하이브리드 검색과 결합할 때 그렇죠. 다음 모범 사례를 기억해 두세요.
- 지속적인 테스트와 모니터링: 하이브리드 검색 파이프라인을 정기적으로 평가해 과적합(overfitting)을 피하고, 성능을 유지하기 위해 시의적절하게 조정하세요.
- 관련성과 비용의 균형: 재랭킹은 계산 비용이 클 수 있고, late interaction 임베딩은 상당한 스토리지가 필요해요. 관련성과 비용 사이의 균형을 목표로 하세요. RRF 같은 단순한 융합 방식은 많은 사용 사례에서 효과적일 수 있는 반면, late interaction 모델은 정밀도가 중요한 쿼리에만 사용할 수 있어요.
결론 (Conclusion)
late interaction 모델로 재랭킹하는 것은 검색 결과의 관련성을 높이는 강력한 도구예요. 특히 하이브리드 검색 방식과 결합할 때 효과적이죠. 그 복잡성 때문에 일부 지연 시간이 추가될 수 있지만, 더 작고 사전 필터링된 결과 하위 집합에 적용하면 속도와 관련성 모두를 보장할 수 있어요.
더 알아보기 (Learn more)
- 하이브리드 쿼리 — RRF와 멀티 스테이지 쿼리
- Qdrant Cloud 추론 — 클라우드 기반 임베딩
- Multi-Vector Search 코스 — late interaction 모델 심화
- Qdrant 공식 문서 홈