Qdrant(クドラント)は、ベクトル(文章や画像を数値の並びにしたもの)を保存し、近いものを速く探すためのデータベースです。 Python からは qdrant-client を使い、「コレクションを作る → 点(ポイント)を upsert する → query_points で検索する」の3段階で使います。最小のコードは次のとおりです。

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")      # サーバーに接続(手元で試すだけなら QdrantClient(":memory:"))
client.create_collection(
    collection_name="articles",
    vectors_config=models.VectorParams(size=256, distance=models.Distance.COSINE),
)
client.upsert(
    collection_name="articles",
    points=[models.PointStruct(id=1, vector=vec1, payload={"title": "...", "category": "db"})],
)
res = client.query_points(collection_name="articles", query=query_vec, limit=3)
for p in res.points:
    print(p.score, p.id, p.payload)
やりたいこと 書き方 見出し
起動する バイナリを実行・Docker の qdrant/qdrant Qdrant を起動する
表を作る create_collection(vectors_config=VectorParams(size, distance)) コレクションを作る
入れる・更新する upsert(points=[PointStruct(id, vector, payload)]) ポイントを入れる
近いものを探す query_points(query=ベクトル, limit=n) 検索する
条件で絞る query_filter=Filter(must=[FieldCondition(...)]) フィルタ
絞り込みを速くする create_payload_index(field_name, field_schema) ペイロードのインデックス
精度と速さを調整する SearchParams(hnsw_ef=...)・量子化 HNSW と量子化
キーワードも効かせる 疎ベクトル+prefetch+FusionQuery(RRF) ハイブリッド検索
全件を順に読む scroll(limit, offset) scroll・count

確認環境:Qdrant 1.19.2(GitHub リリースの Linux 用バイナリ qdrant-x86_64-unknown-linux-musl)・qdrant-client 1.19.1・numpy 2.4.6・Python 3.11.15(Linux)。サーバーに対して、この記事のコードをすべて実行しました。ローカルモード(":memory:")も同じコードで動かして違いを確かめています。検索の確かめには、埋め込みモデルの代わりに「文字の2文字ずつの組をハッシュして作った256次元の簡易ベクトル」を使いました。スコアの値はこの簡易ベクトルでの値です。仕様と既定値は Qdrant のドキュメント(qdrant.tech)で、pgvector は pgvector の README で確かめました(確認日 2026年10月13日)。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Qdrant入門|ベクトル検索のコレクション作成と検索(Python)」

Qdrant の用語

Qdrant では、RDB の「表」にあたるものをコレクション、「行」にあたるものをポイントと呼びます。 ポイントは「ID・ベクトル・ペイロード(JSON の付随データ)」の3つでできています。

Qdrant 意味 RDB でいうと
コレクション ベクトルの次元と距離が同じポイントの集まり テーブル
ポイント ID+ベクトル+ペイロード 行
ID 符号なし整数か UUID 主キー
ベクトル 数値の並び。名前付きで複数持てる ベクトル型の列
ペイロード 任意の JSON。フィルタに使う その他の列
ペイロードのインデックス フィルタを速くするための索引 通常のインデックス
HNSW 近いベクトルを速く探すためのグラフの索引 ベクトル用インデックス
セグメント コレクションの中を分けた保存単位 パーティションに近い

ベクトルや埋め込みの考え方は、関連記事「エンベディングとは?ベクトル検索とコサイン類似度を計算例で解説」で扱っています。

Qdrant を起動する

バイナリで起動する

GitHub のリリースに、Linux・macOS・Windows 用のバイナリがあります。Linux の x86_64 用は qdrant-x86_64-unknown-linux-musl.tar.gz(1.19.2 で約33MB、展開後の実行ファイルは約92MB)でした。

curl -LO https://github.com/qdrant/qdrant/releases/download/v1.19.2/qdrant-x86_64-unknown-linux-musl.tar.gz
tar xzf qdrant-x86_64-unknown-linux-musl.tar.gz
./qdrant --version          # qdrant 1.19.2

# 保存先とポートは環境変数で変えられる(QDRANT__ のあとに設定のキーを __ でつなぐ)
QDRANT__STORAGE__STORAGE_PATH=./storage \
QDRANT__SERVICE__HTTP_PORT=6333 \
QDRANT__SERVICE__GRPC_PORT=6334 \
QDRANT__TELEMETRY_DISABLED=true \
./qdrant

起動すると Qdrant HTTP listening on 6333・Qdrant gRPC listening on 6334 がログに出ました。設定ファイル(config/config.yaml)が無いと Config file not found の警告が出ますが、既定値で起動します。動作の確認は次のとおりです。

curl -s localhost:6333/
# {"title":"qdrant - vector search engine","version":"1.19.2","commit":"016542aa..."}

Docker で起動する

ドキュメントのクイックスタートでは、次のコマンドです。6333 が REST API と Web UI(/dashboard)、6334 が gRPC です。

docker run -p 6333:6333 -p 6334:6334 \
    -v "$(pwd)/qdrant_storage:/qdrant/storage:z" \
    qdrant/qdrant

ローカルモード(サーバーなし)

client = QdrantClient(":memory:")              # メモリだけ。終了すると消える
client = QdrantClient(path="./local_qdrant")   # ファイルに保存(中身は SQLite)

qdrant-client だけで動く簡易版です。後の「ローカルモードとサーバーの違い」で詳しく比べます。

Python クライアントを入れる

pip install qdrant-client

1.19.1 では、grpcio・httpx・numpy・portalocker・protobuf・pydantic も一緒に入りました。

コレクションを作る(距離と次元)

コレクションを作るときに、ベクトルの次元(size)と距離(distance)を決めます。 次元は使う埋め込みモデルの出力に合わせます(例:OpenAI の text-embedding-3-small は1536)。

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")
NAME = "articles"
if client.collection_exists(NAME):
    client.delete_collection(NAME)
client.create_collection(
    collection_name=NAME,
    vectors_config=models.VectorParams(size=256, distance=models.Distance.COSINE),
)

以前の記事でよく見る recreate_collection は 1.19.1 にもまだありますが、collection_exists → delete_collection → create_collection の順で書く方が、何が起きるか明確です。

距離の種類とスコアの向き

同じ4つの2次元ベクトル([1,0]・[3,0]・[0.7,0.7]・[0,1])に、[1, 0.1] で検索した結果です。

距離 指定 結果(ID, スコア)の順 スコアの向き
コサイン類似度 Distance.COSINE (2, 0.995)・(1, 0.995)・(3, 0.774)・(4, 0.1) 大きいほど近い
内積 Distance.DOT (2, 3.0)・(1, 1.0)・(3, 0.77)・(4, 0.1) 大きいほど近い
ユークリッド距離 Distance.EUCLID (1, 0.1)・(3, 0.671)・(4, 1.345)・(2, 2.002) 小さいほど近い
マンハッタン距離 Distance.MANHATTAN (1, 0.1)・(3, 0.9)・(4, 1.9)・(2, 2.1) 小さいほど近い
  • コサインでは、保存時にベクトルが長さ1にそろえられます。 [3, 0] を入れて with_vectors=True で読み出すと [1.0, 0.0] でした。そのため [1,0] と [3,0] は同じスコアです。
  • 内積は長さの影響を受けます。 [3,0] のスコアが 3.0 になりました。長さ1にそろえた埋め込み(多くの埋め込みモデルの出力)なら、内積とコサインは同じ順になります。
  • ユークリッドとマンハッタンは、スコアが「距離」なので小さい順に返ります。 score_threshold を使うときは向きに注意します。

名前付きベクトル(1つのポイントに複数のベクトル)

client.create_collection(
    collection_name="hybrid",
    vectors_config={"dense": models.VectorParams(size=256, distance=models.Distance.COSINE)},
    sparse_vectors_config={"sparse": models.SparseVectorParams(modifier=models.Modifier.IDF)},
)

辞書で名前を付けると、1つのポイントに「タイトルのベクトル」「本文のベクトル」「疎ベクトル」のように複数のベクトルを持てます。検索のときは using="dense" で使う名前を指定します(ハイブリッド検索の節)。

ポイントを入れる(upsert)

upsert は、同じ ID があれば置き換え、無ければ追加します。 10件の記事タイトルとペイロードを入れた例です。

DOCS = [
    (1, "PostgreSQLのインデックスでSQLを速くする方法", {"category": "db", "year": 2024, "price": 0, "tags": ["postgresql", "sql"]}),
    (3, "pgvectorでPostgreSQLにベクトル検索を追加する", {"category": "db", "year": 2025, "price": 1200, "tags": ["postgresql", "vector"]}),
    (4, "Qdrantでベクトル検索のコレクションを作る", {"category": "vector", "year": 2026, "price": 1500, "tags": ["qdrant", "vector"]}),
    # ...(全10件)
]
r = client.upsert(
    collection_name=NAME,
    points=[models.PointStruct(id=i, vector=embed(t), payload={"title": t, **p}) for i, t, p in DOCS],
    wait=True,
)
print(r)                                   # operation_id=1 status=<UpdateStatus.COMPLETED: 'completed'>
print(client.count(NAME, exact=True))      # count=10

embed() は文章をベクトルにする関数です(実際には埋め込みモデルの API や sentence-transformers などを呼びます)。wait=True にすると、書き込みが反映されてから戻ります。既定(wait を付けない)では受け付けた時点で戻るので、直後の検索に間に合わないことがあります。

ID とベクトルのエラー

入れたもの 結果(サーバーのエラー文)
id="doc-1" value doc-1 is not a valid point ID, valid values are either an unsigned integer or a UUID
id=-1 value -1 is not a valid point ID, ...(同じ文)
次元2のコレクションに3次元 Wrong input: Vector dimension error: expected dim: 2, got 3
id="5f6d3c9e-8a3b-4c1e-9f0a-2b7c1d4e5f60" 成功(UUID の文字列)

どれも UnexpectedResponse(HTTP 400)として返りました。文字列の ID を使いたいときは、uuid.uuid5(uuid.NAMESPACE_URL, "doc-1") のように文字列から UUID を作り、元の ID はペイロードに入れておくと、同じ文書を何度入れても同じ ID になります。

大量に入れる:upload_collection・upload_points

client.upload_collection(
    collection_name="bulk",
    vectors=X,                         # numpy の (件数, 次元) 配列
    ids=range(len(X)),
    payload=({"g": i % 10} for i in range(len(X))),
    batch_size=1000,
    wait=True,
)

128次元×5万件の upload は 7.5 秒でした(手元のサーバー、1プロセス)。upload_collection は内部で batch_size ごとに分けて送ります。

検索する(query_points)

検索は query_points です。 結果は res.points に、スコアの順で入ります。

res = client.query_points(collection_name=NAME, query=embed("ベクトル検索のやり方"), limit=3)
for p in res.points:
    print(round(p.score, 4), p.id, p.payload["title"])
0.4264 4 Qdrantでベクトル検索のコレクションを作る
0.315 3 pgvectorでPostgreSQLにベクトル検索を追加する
0.2566 9 ハイブリッド検索でキーワードとベクトルを組み合わせる

よく使う引数です。

引数 意味 例
query 検索するベクトル(ID を渡すとそのポイントに近いもの) query=vec・query=4
using 名前付きベクトルのどれを使うか using="dense"
limit・offset 件数と読み飛ばし limit=10, offset=20
query_filter ペイロードの条件 次の節
with_payload ペイロードを返すか。リストで項目を絞れる with_payload=["title"]
with_vectors ベクトルも返すか with_vectors=True
score_threshold スコアの下限(距離なら上限) score_threshold=0.3
search_params HNSW の hnsw_ef・exact など HNSW の節
prefetch 先に別の検索をして候補を作る ハイブリッド検索の節

client.search() は 1.19.1 では使えない

以前の記事では client.search(collection_name=..., query_vector=..., query_filter=...) の形がよく使われています。qdrant-client 1.19.1 で呼ぶと次のエラーでした。

AttributeError: 'QdrantClient' object has no attribute 'search'

1.19.1 の QdrantClient には search・recommend・discover が無く、query_points にまとまっています。書き換えは次のとおりです。

以前の書き方 1.19.1 の書き方
search(query_vector=v, query_filter=f, limit=3) query_points(query=v, query_filter=f, limit=3).points
search(query_vector=("dense", v)) query_points(query=v, using="dense")
recommend(positive=[4]) query_points(query=models.RecommendQuery(recommend=models.RecommendInput(positive=[4])))(手元では 9・3・6 が返った)
search_batch(requests=[...]) query_batch_points(requests=[...])

戻り値も違います。search はポイントのリストを返しましたが、query_points は QueryResponse を返し、ポイントは .points にあります。

ペイロードのフィルタ

query_filter に Filter(must=..., should=..., must_not=...) を渡すと、条件に合うポイントの中から近いものを探します。 must は AND、should は OR、must_not は NOT です。

res = client.query_points(
    collection_name=NAME,
    query=embed("ベクトル検索のやり方"),
    query_filter=models.Filter(must=[
        models.FieldCondition(key="category", match=models.MatchValue(value="db")),
        models.FieldCondition(key="year", range=models.Range(gte=2025)),
    ]),
    with_payload=["title", "year"],
    limit=3,
)
0.315 3 {'title': 'pgvectorでPostgreSQLにベクトル検索を追加する', 'year': 2025}
-0.0765 8 {'title': 'DynamoDBの単一テーブル設計とキーの決め方', 'year': 2026}

条件に合うのは2件だけだったので、limit=3 でも2件でした。フィルタは「検索してから絞る」のではなく、条件に合うものの中で探すので、件数が減りすぎることはありません。

条件の書き方一覧(実行結果つき)

同じ検索語で、条件だけを変えた結果(ID の順)です。

条件 書き方 結果の ID
一致 FieldCondition(key="category", match=MatchValue(value="vector")) 4, 9
どれか(OR) Filter(should=[db の条件, vector の条件]) 4, 3, 9, 2, 1, 8
どれか(IN) match=MatchAny(any=["db", "vector"]) 4, 3, 9, 2, 1, 8
否定 Filter(must_not=[db の条件]) 4, 9, 6, 5, 10, 7
含まない(NOT IN) match=MatchExcept(**{"except": ["db", "ai"]}) 4, 9, 10
配列に含む FieldCondition(key="tags", match=MatchValue(value="vector")) 4, 3, 9, 6
範囲 range=Range(gte=1000, lte=2000) 4, 3, 5, 7
ID で指定 HasIdCondition(has_id=[1, 3, 5]) 3, 5, 1
  • 配列のペイロード(tags)は、どれか1つが合えば一致します。 SQL の = ANY(tags) にあたります。
  • MatchExcept の引数名は Python の予約語 except なので、**{"except": [...]} の形で渡します。
  • ほかに、日時の範囲(DatetimeRange)、空か(IsEmptyCondition)、NULL か(IsNullCondition)、入れ子の配列(NestedCondition)、位置(GeoRadius など)があります。

全文の一致(MatchText)と日本語

MatchText は、text 型のペイロードのインデックスを作ったときに使う条件です。手元の結果は次のとおりでした。

状態 MatchText(text=...) 結果の ID
インデックスなし "Qdrant"・"検索" どちらも0件(エラーにはならない)
text インデックス(multilingual) "Qdrant"・"qdrant" 4(大文字小文字を区別しない)
同上 "ベクトル" 4, 6, 9
同上 "ベクトル検索" 4, 9(「ベクトル検索を」の3は含まれない)
同上 "検索" 0件
client.create_payload_index(
    NAME, field_name="title",
    field_schema=models.TextIndexParams(type="text", tokenizer=models.TokenizerType.MULTILINGUAL),
)

MatchText は「単語(トークン)」の一致なので、日本語の部分一致は、トークンの切れ目しだいで結果が変わりました。ドキュメントでは、multilingual のトークナイザーは日本語に vaporetto を使うとされています。日本語のキーワード検索を確実に効かせたいときは、後述の疎ベクトルによるハイブリッド検索の方が扱いやすいです。PostgreSQL での日本語の全文検索は、関連記事「PostgreSQL日本語全文検索|pg_trgmとpg_bigmの速度比較と選び方」で扱っています。

ペイロードのインデックス

フィルタに使う項目には、ペイロードのインデックスを作ります。 作らなくてもフィルタは動きますが、件数が増えると遅くなります。

client.create_payload_index(NAME, field_name="category", field_schema=models.PayloadSchemaType.KEYWORD, wait=True)
client.create_payload_index(NAME, field_name="year",     field_schema=models.PayloadSchemaType.INTEGER, wait=True)
print(client.get_collection(NAME).payload_schema)
型 使う値 使える条件の例
keyword 文字列(完全一致) MatchValue・MatchAny・MatchExcept
integer 整数 MatchValue・Range
float 小数 Range
bool 真偽 MatchValue
datetime 日時(RFC 3339) DatetimeRange
geo 緯度経度 GeoRadius・GeoBoundingBox
text 文章(トークンに分ける) MatchText
uuid UUID の文字列 MatchValue

インデックスの有無で速さを比べる

128次元×5万件(g に 0〜9 の値)で、g = 3(5,000件が該当)のフィルタ付き検索を50回行った平均です。

状態 1回の平均 厳密検索との一致(recall@10)
ペイロードのインデックスなし 25.37ms 0.952
g に integer のインデックスあり 4.39ms 0.952

インデックスを作ると、約6分の1の時間になりました。ドキュメントは「ペイロードのインデックスはデータを入れる前に作る」ことを勧めています。HNSW のグラフは、作った時点のペイロードのインデックスを使ってフィルタ向けの辺を足すためで、後から作ったときは HNSW の作り直しが必要とされています。

その他のインデックスの使い方です。

  • 集計(facet)にもインデックスが要ります。 インデックスなしで client.facet(NAME, key="category") を呼ぶと No appropriate index for faceting: category. Please create one to facet on this field. でした。作った後は db: 4・ai: 3・vector: 2・search: 1 と件数が返りました。
  • テナント(顧客ごと)で分ける項目は、KeywordIndexParams(type="keyword", is_tenant=True) にすると、テナントごとの検索に向いた保存のしかたになります(ドキュメント)。

scroll・count・更新・削除

# 件数(条件つき)
client.count(NAME, count_filter=models.Filter(must=[
    models.FieldCondition(key="category", match=models.MatchValue(value="db"))])).count   # 4

# 全件を順に読む(ベクトル検索ではない)
points, next_offset = client.scroll(NAME, limit=4, with_payload=["title"])
# points の ID は [1, 2, 3, 4]、next_offset は 5
points, next_offset = client.scroll(NAME, limit=4, offset=next_offset)
# [5, 6, 7, 8]、次は 9。最後まで読むと None

# ペイロードの一部を書き換える(ベクトルはそのまま)
client.set_payload(NAME, payload={"price": 900}, points=[4], wait=True)

# 条件で削除
client.delete(NAME, points_selector=models.FilterSelector(filter=models.Filter(must=[
    models.FieldCondition(key="category", match=models.MatchValue(value="search"))])), wait=True)
# 10件 → 9件

scroll は ID の順に返り、next_offset を次の呼び出しの offset に渡して続きを読みます。全件のエクスポートや、バッチでの再埋め込みに使います。

HNSW と量子化:速さと精度の調整

Qdrant は HNSW という近似のインデックスで検索します。 近似なので、すべてを比べる「厳密検索」と結果が少し違うことがあります。設定と実測をまとめます。

HNSW の既定値と「インデックスされていない件数」

info = client.get_collection("bulk")
print(info.points_count, info.indexed_vectors_count, info.segments_count)
print(info.config.hnsw_config)
print(info.config.optimizer_config.indexing_threshold)
50000 40000 2
m=16 ef_construct=100 full_scan_threshold=10000 max_indexing_threads=0 on_disk=False ...
10000
設定 既定値 意味(ドキュメント)
m 16 グラフの1点あたりの辺の数。大きいほど精度が上がり、容量が増える
ef_construct 100 作るときに見る近傍の数。大きいほど精度が上がり、作る時間が延びる
full_scan_threshold 10000(KB) 該当するデータがこれより小さいと、HNSW を使わず全件を比べる
indexing_threshold 10000(KB) これより小さいセグメントは HNSW を作らず、全件を比べる

5万件のうち indexed_vectors_count は 40000 でした。 5分待っても変わりませんでした。1万件(128次元×4バイト×1万=約5MB)のセグメントが indexing_threshold(10000KB)より小さく、全件比較のまま残っているためです。ドキュメントでは「1KB は256次元のベクトル1つ」と説明されています。10件の小さなコレクションでは indexed_vectors_count は 0 でした。indexed_vectors_count が points_count より少ないこと自体は異常ではありません。

検索時の精度と速さ(実測)

正規分布の乱数で作った128次元×5万件に、50個の乱数のベクトルで上位10件を検索し、exact=True(厳密検索)の結果とどれだけ一致したか(recall@10)を測りました。乱数のデータは近い点の区別がつきにくく、近似検索にとっては厳しい条件です。実際の埋め込みでは、この値より高く出ることが多いです。

検索の設定 1回の平均 recall@10
exact=True(厳密検索) 5.30ms 1.000(基準)
既定 3.79ms 0.608
SearchParams(hnsw_ef=128) 3.47ms 0.674
SearchParams(hnsw_ef=512) 3.78ms 0.914
int8 量子化・rescore なし 3.03ms 0.608
int8 量子化・rescore・oversampling=2.0 4.45ms 0.610
res = client.query_points(
    "bulk", query=q, limit=10,
    search_params=models.SearchParams(hnsw_ef=512),        # 検索時に見る候補の数を増やす
)
res = client.query_points("bulk", query=q, limit=10,
                          search_params=models.SearchParams(exact=True))   # 厳密検索(確認用)

この条件では、hnsw_ef を増やすのがいちばん recall に効きました。 5万件程度なら厳密検索でも 5ms 台です。件数が少ないうちは、近似の精度が気になるなら exact=True で比べ、hnsw_ef を決めると調整しやすくなります。

量子化:メモリを減らす

client.update_collection(
    "bulk",
    quantization_config=models.ScalarQuantization(
        scalar=models.ScalarQuantizationConfig(type=models.ScalarType.INT8, quantile=0.99, always_ram=True)
    ),
)
方式 縮み方(ドキュメント) 特徴
スカラー(int8) 約4分の1 誤差は多くの場合1%未満とされる
バイナリ 32分の1 次元が大きく、0を中心に分布するベクトル向け。rescore と使う
プロダクト 最大64分の1 精度の低下が大きく、スカラーより遅い

rescore(量子化で絞った候補を元のベクトルで計算し直す)は既定で有効です。oversampling=2.0 は「limit の2倍の候補を量子化で選び、元のベクトルで並べ直す」設定です。手元の乱数データでは、量子化の有無で recall はほぼ変わらず(HNSW の近似の方が支配的)、メモリを減らす目的の設定でした。always_ram=True は量子化したベクトルを RAM に置く設定で、元のベクトルをディスク(on_disk=True)に置く構成と組み合わせます。

5万件のコレクション(int8 量子化あり)の保存先は 80MB、10件のコレクションでも 15MB でした。書き込みのログ(WAL)などの領域が先に確保されるためです。

ハイブリッド検索:疎ベクトルと RRF

ハイブリッド検索は、意味の近さ(密ベクトル)と、キーワードの一致(疎ベクトル)の結果を合わせる検索です。 型番・製品名・エラーコードのように「その語を含むこと」が大事な検索で、密ベクトルだけの弱さを補います。Qdrant では、prefetch で2種類の検索をしてから、FusionQuery で順位を合わせます。

client.create_collection(
    "hybrid",
    vectors_config={"dense": models.VectorParams(size=256, distance=models.Distance.COSINE)},
    sparse_vectors_config={"sparse": models.SparseVectorParams(modifier=models.Modifier.IDF)},
)
client.upsert("hybrid", points=[
    models.PointStruct(
        id=i, payload={"title": t, **p},
        vector={"dense": embed(t), "sparse": models.SparseVector(indices=idx, values=val)},
    )
    for (i, t, p), (idx, val) in zip(DOCS, sparse_vectors)
], wait=True)

q = "BM25 キーワード検索"
res = client.query_points(
    "hybrid",
    prefetch=[
        models.Prefetch(query=embed(q), using="dense", limit=20),
        models.Prefetch(query=models.SparseVector(indices=q_idx, values=q_val), using="sparse", limit=20),
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    limit=5,
)

疎ベクトルは「語の番号と重み」の組です。この記事では、2文字ずつの組をハッシュした番号と出現回数を使いました。modifier=models.Modifier.IDF を付けると、多くの文書に出る語の重みを Qdrant 側で下げます(BM25 の IDF にあたる計算)。

方式 上位5件(ID, スコア)
密ベクトルだけ (9, 0.3651)・(10, 0.3464)・(8, 0.0725)・(4, 0.0674)・(3, 0.0598)
疎ベクトルだけ(IDF) (9, 10.1445)・(10, 6.6704)・(8, 1.4816)・(3, 0.6931)・(5, 0.6931)
RRF(既定) (9, 1.0)・(10, 0.6667)・(8, 0.5)・(3, 0.3667)・(4, 0.3667)
RRF(Rrf(k=60)) (9, 0.0333)・(10, 0.0328)・(8, 0.0323)・(3, 0.0315)・(4, 0.0315)
RRF(Rrf(weights=[1.0, 3.0])) (9, 1.25)・(10, 0.9333)・(8, 0.75)・(3, 0.5952)・(4, 0.575)
DBSF (9, 1.5906)・(10, 1.4267)・(8, 0.909)・(4, 0.8709)・(3, 0.8624)
  • RRF(Reciprocal Rank Fusion)は、それぞれの検索での順位から点数を作って足す方法です。スコアの大きさが違う検索どうしでも合わせられます。ドキュメントでは、k の既定値は 2 で、Rrf(k=60) のように変えられます(1.16.0 から)。weights で検索ごとの重みも付けられます(1.17.0 から)。重みの数は prefetch の数と同じにします。
  • DBSF(Distribution-Based Score Fusion)は、スコアを分布で正規化して足す方法です(1.11.0 から)。
  • prefetch の limit は、最終の limit(と offset)以上にします。 prefetch で落ちたポイントは、最後の結果に出てきません。

フィルタと組み合わせる

res = client.query_points(
    "hybrid", prefetch=prefetch, query=models.FusionQuery(fusion=models.Fusion.RRF), limit=5,
    query_filter=models.Filter(must=[models.FieldCondition(key="year", range=models.Range(gte=2025))]),
)
# (9, 1.0)・(8, 0.6667)・(4, 0.45)・(3, 0.45)・(5, 0.3095)

2023年の記事(ID 10)が外れ、2025年以降の記事だけになりました。

疎ベクトルで絞ってから密ベクトルで並べ直す

res = client.query_points(
    "hybrid",
    prefetch=models.Prefetch(query=models.SparseVector(indices=q_idx, values=q_val), using="sparse", limit=5),
    query=embed(q), using="dense", limit=3,
)
# (9, 0.3651)・(10, 0.3464)・(8, 0.0725)

「キーワードが合うものの中から、意味が近い順に並べる」2段階の検索です。prefetch は入れ子にもできます。

サーバー側で BM25 の疎ベクトルを作る

Qdrant 1.19.2 のサーバーでは、ベクトルの代わりに models.Document(text=..., model="qdrant/bm25") を渡すと、サーバー側で BM25 の疎ベクトルが作られました。

client.create_collection("hybrid", ...,
    sparse_vectors_config={"bm25": models.SparseVectorParams(modifier=models.Modifier.IDF)})
client.upsert("hybrid", points=[models.PointStruct(id=10, vector={
    "bm25": models.Document(text="Elasticsearch の 全文 検索 と BM25 の 仕組み", model="qdrant/bm25")})])
client.query_points("hybrid", query=models.Document(text="BM25 検索", model="qdrant/bm25"), using="bm25", limit=5)
入れた文 検索語 結果
日本語のタイトルをそのまま BM25 キーワード検索・elasticsearch など 0件(1文が1語として扱われ、疎ベクトルの要素は1つだった)
単語の間に空白を入れた文 BM25 検索 (10, 5.197)・(4, 1.897)・(9, 1.897)
同上 elasticsearch (10, 3.3)(大文字小文字は区別しない)

空白で区切らない日本語は、1文がまるごと1語になり、検索に当たりませんでした。 日本語で使うときは、形態素解析(MeCab・Sudachi など)で単語に分けて空白でつないでから渡すか、この記事の前半のように自分で疎ベクトルを作ります。空白で区切った文では「の」も1語として数えられていました(要素7つのうち「の」の重みだけが 1.89、ほかは 1.656)。

ローカルモードとサーバーの違い

QdrantClient(":memory:") は同じコードで動きますが、中身は Python で書かれた簡易版です。 同じ10件と検索語で、スコアと順位はサーバーと同じ(0.4264・0.315・0.2566)でした。違いは次のとおりです。

項目 ローカルモード(:memory:・path=) サーバー
起動 pip install だけ バイナリ・Docker・Qdrant Cloud
検索の方式 全件を比べる HNSW(近似)
5万件・128次元の検索 1回 113.7ms 3.79ms(厳密検索でも 5.30ms)
ペイロードのインデックス 効果なし(警告が出る。payload_schema は空) 効く
件数の目安 2万件を超えると警告 大量に入れられる
保存 path= で SQLite ファイル 独自の形式(WAL・セグメント)
複数プロセスからの利用 不可(2つ目は RuntimeError: Storage folder ... is already accessed by another instance of Qdrant client.) 可

ローカルモードで出た警告です。

UserWarning: Payload indexes have no effect in the local Qdrant. Please use server Qdrant if you need payload indexes.
UserWarning: Local mode is not recommended for collections with more than 20,000 points. Current collection contains 50000 points. Consider using Qdrant in Docker or Qdrant Cloud for better performance with large datasets.

RRF の結果は、上位3件はサーバーと同じでしたが、同点の4位・5位の並びとスコア(ローカル:(4, 0.3429)・(3, 0.3333)、サーバー:(3, 0.3667)・(4, 0.3667))が違いました。ローカルモードは、単体テストや小さな試作に使い、本番と同じ結果を確かめるときはサーバーを使うのが確実です。

REST API で同じことをする

Python 以外から使うときや、動作を確かめるときは、REST API を curl で呼べます。

curl -X PUT localhost:6333/collections/rest_demo -H 'Content-Type: application/json' \
  -d '{"vectors":{"size":4,"distance":"Cosine"}}'
curl -X PUT 'localhost:6333/collections/rest_demo/points?wait=true' -H 'Content-Type: application/json' \
  -d '{"points":[{"id":1,"vector":[0.1,0.9,0.1,0],"payload":{"city":"Tokyo"}},
                 {"id":2,"vector":[0.9,0.1,0,0],"payload":{"city":"Osaka"}}]}'
curl -X POST localhost:6333/collections/rest_demo/points/query -H 'Content-Type: application/json' \
  -d '{"query":[0.2,0.8,0,0],"limit":1,"with_payload":true}'
{"result":true,"status":"ok","time":0.00794942}
{"result":{"operation_id":1,"status":"completed"},"status":"ok","time":0.000590287}
{"result":{"points":[{"id":1,"version":1,"score":0.98500454,"payload":{"city":"Tokyo"}}]},"status":"ok","time":0.000679139}

Qdrant と pgvector の比較

pgvector は PostgreSQL にベクトル型と近傍検索を足す拡張機能です。Qdrant は手元で動かした 1.19.2 の内容、pgvector は README(v0.8.7)の内容です。

項目 Qdrant pgvector
形 ベクトル検索専用のサーバー PostgreSQL の拡張機能
近似インデックス HNSW HNSW・IVFFlat
距離 コサイン・内積・ユークリッド・マンハッタン L2・内積・コサイン・L1・ハミング・ジャカード
ベクトルの次元 1〜65,536(65,537 は検証エラー) HNSW でインデックスできるのは vector 2,000まで(halfvec 4,000、bit 64,000)
疎ベクトル 標準(IDF 付き) sparsevec(HNSW は非ゼロ1,000要素まで)
フィルタ ペイロードのインデックス+フィルタ対応の HNSW WHERE 句。0.8.0 から iterative index scan
ハイブリッド検索 prefetch+RRF・DBSF を1回の API で 全文検索と組み合わせ、RRF などは SQL で書く
量子化 スカラー・バイナリ・プロダクト halfvec・bit の型で容量を減らす
トランザクション・JOIN なし PostgreSQL のものがそのまま使える
運用 新しいサーバーを1つ増やす 既存の PostgreSQL の運用に乗る

選び方の目安です。

  • すでに PostgreSQL があり、件数が数百万件程度まで、業務データと JOIN したいなら、pgvector から始めると構成が増えません。関連記事「pgvectorの使い方|PostgreSQLでベクトル検索をコピペで構築」で扱っています。
  • ベクトル検索が中心で、件数が多い・フィルタが複雑・ハイブリッド検索や量子化を API 1回で使いたいなら、Qdrant が向いています。

RAG の中でベクトル DB をどう使うかは、関連記事「RAGの作り方|Pythonで動かす最小構成と精度の上げ方」で、検索の精度の測り方は、関連記事「RAGの精度改善の方法一覧|原因の切り分けと評価指標の計算」で扱っています。LlamaIndex や LangChain から Qdrant を使う場合は、それぞれの Qdrant 用の統合パッケージを使います(関連記事「LlamaIndex入門|PythonでRAGを作るコピペ集」)。

よくある質問

Q. 埋め込みのモデルを変えたら、コレクションはどうしますか。 A. 次元が変わる場合は、新しいコレクションを作って入れ直します。名前付きベクトルなら、同じコレクションに新しい名前のベクトルを足し(create_vector_name)、入れ終わってから検索の using を切り替える方法もあります。コレクションの別名(エイリアス)を使うと、アプリ側の名前を変えずに切り替えられます。

Q. 同じ文書を何度入れても重複しないようにするには? A. ID を文書のキーから決めます。整数のキーがあればそのまま、文字列なら uuid.uuid5() で UUID にします。upsert は同じ ID を置き換えます。

Q. 検索結果のスコアに、しきい値をいくつに設定すればよいですか。 A. スコアの大きさは埋め込みモデルと距離によって変わるので、決まった値はありません。評価用の質問で上位の結果とスコアを並べ、関係のある・ないの境目を見て決めます。ユークリッド距離では小さいほど近いので、score_threshold は上限の意味になります。

まとめ

  • 流れは create_collection → upsert → query_points。次元はモデルに、距離はモデルの推奨に合わせる
  • qdrant-client 1.19.1 では search() が無く、query_points を使う。結果は .points
  • ID は 符号なし整数か UUID。文字列のキーは uuid5 で UUID にする
  • フィルタは must・should・must_not。使う項目には データを入れる前にペイロードのインデックスを作る(手元では 25ms → 4ms)
  • indexed_vectors_count が全件より少ないのは正常(小さなセグメントは全件比較)
  • 精度が気になるときは exact=True と比べて hnsw_ef を調整する
  • ハイブリッド検索は prefetch で密・疎を検索し、FusionQuery(RRF) で合わせる。サーバー側の BM25 は日本語を空白で区切ってから渡す
  • ローカルモードは試作とテスト用。ペイロードのインデックスは効かず、全件比較

参考・出典

確認日はいずれも 2026年10月13日です。

  • Qdrant Documentation「Quickstart」:https://qdrant.tech/documentation/quickstart/
  • Qdrant Documentation「Indexing」:https://qdrant.tech/documentation/concepts/indexing/
  • Qdrant Documentation「Hybrid Queries」:https://qdrant.tech/documentation/concepts/hybrid-queries/
  • Qdrant Documentation「Quantization」:https://qdrant.tech/documentation/guides/quantization/
  • Qdrant Documentation「Optimizer」:https://qdrant.tech/documentation/ops-optimization/optimizer/
  • Qdrant Documentation「BM25(Inference)」:https://qdrant.tech/documentation/inference/inference-bm25/
  • qdrant/qdrant(GitHub リリース):https://github.com/qdrant/qdrant/releases
  • qdrant/qdrant-client(GitHub):https://github.com/qdrant/qdrant-client
  • pgvector(GitHub README):https://github.com/pgvector/pgvector

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Qdrant入門|ベクトル検索のコレクション作成と検索(Python)」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?