← Back to Blog

Qdrant: Vector Database Setup, Semantic Search, and Hybrid Queries

Qdrant: Vector Database Setup, Semantic Search, and Hybrid Queries

You embedded a few thousand documents, and then you realized you do not actually have a search engine. A Python loop over cosine similarities works until it does not: nothing is persisted, nothing is filtered, and every query scans everything. That is the moment people start shopping for a vector database, and the shortlist usually comes down to pgvector, Chroma, and Qdrant. This guide sets up Qdrant for real, with code that runs, and ends with a straight answer on which of the three fits your case.

Prerequisites

  • Docker Engine (check with docker version)
  • Python 3.10+
  • Basic familiarity with Python and pip

Qdrant runs as a service. Everything here targets a local container, so no cloud account is needed.

Step 1: Run Qdrant with Docker

Pull the image and start the server:

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

Two ports matter:

  • 6333 is the REST API, and the web dashboard lives at http://localhost:6333/dashboard
  • 6334 is the gRPC API, used by the Rust, Go, Java, and C# clients

The -v flag keeps every byte of data in ./qdrant_storage, so restarts lose nothing. Qdrant is written in Rust, released under Apache 2.0, and the Docker latest tag is v1.19 at the time of writing.

Step 2: Install the Python client

pip install qdrant-client

Connect and check health:

from qdrant_client import QdrantClient

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

You should see an empty collection list. If the connection fails, check that the container is still running.

Step 3: Create a collection

A collection is Qdrant's equivalent of a table. Two settings matter at creation time: size, which must match your embedding model's output dimension, and distance, the similarity metric.

from qdrant_client import QdrantClient, models

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

client.create_collection(
    collection_name="articles",
    vectors_config=models.VectorParams(
        size=384,
        distance=models.Distance.COSINE,
    ),
)

The distance options are COSINE, DOT, and EUCLID. Cosine is the safe default for text embeddings: it compares direction rather than magnitude, and most embedding providers normalize vectors anyway. The 384 here matches the BAAI/bge-small-en-v1.5 model used below.

One thing to decide before ingesting data: collection settings are fixed at creation. Changing the dimension later means recreating the collection, so pick size and distance once and stick with them.

Step 4: Embed text and upsert points

Install fastembed, Qdrant's embedding library:

pip install fastembed
from fastembed import TextEmbedding

model = TextEmbedding("BAAI/bge-small-en-v1.5")

def embed(text: str) -> list:
    return list(model.embed([text]))[0].tolist()

Now embed a handful of text chunks and store them. The payload is arbitrary JSON attached to each vector, and this is what lets you filter search results later.

docs = [
    ("Qdrant is a vector database written in Rust. It stores vectors on disk and indexes them with HNSW.", "qdrant", "overview"),
    ("Chroma runs inside your Python process, which makes it fast to prototype and awkward to scale.", "chroma", "overview"),
    ("pgvector adds vector search to PostgreSQL as an extension, so vectors live next to relational data.", "pgvector", "overview"),
    ("Hybrid search combines dense and sparse vectors to match both meaning and exact keywords.", "concepts", "search"),
]

client.upsert(
    collection_name="articles",
    points=[
        models.PointStruct(
            id=idx,
            vector=embed(text),
            payload={"text": text, "tool": tool, "category": category},
        )
        for idx, (text, tool, category) in enumerate(docs)
    ],
    wait=True,
)

Points are (id, vector, payload) tuples. wait=True blocks until the write is indexed, which is what you want in a script. For bulk loads, batch the upserts instead and drop the flag.

Step 5: Run a semantic search

query = "which database runs inside the application process?"

hits = client.query_points(
    collection_name="articles",
    query=embed(query),
    limit=3,
).points

for hit in hits:
    print(hit.id, round(hit.score, 3), hit.payload["tool"])

Real output from this exact script:

1 0.659 chroma
0 0.574 qdrant
2 0.557 pgvector

With cosine distance, a higher score means closer meaning. The query is about in-process databases, and the Chroma chunk lands first even though the word "process" appears nowhere in the query. That is the semantic half of semantic search.

Step 6: Filter with payload

Raw similarity is rarely the whole query. Production searches usually carry a constraint: only this tenant, only this author, only documents from this source.

hits = client.query_points(
    collection_name="articles",
    query=embed("vector database"),
    query_filter=models.Filter(
        must=[
            models.FieldCondition(
                key="tool",
                match=models.MatchValue(value="pgvector"),
            )
        ]
    ),
    limit=3,
).points

for hit in hits:
    print(hit.id, hit.payload["text"])

This searches only points where tool == "pgvector", and the filter runs before ranking, not after. That leads to a habit worth adopting early: any field you filter on should get a payload index, or every search degrades into a full scan.

client.create_payload_index(
    collection_name="articles",
    field_name="tool",
    field_schema=models.PayloadSchemaType.KEYWORD,
)

Step 7: Hybrid search with dense and sparse vectors

Exact keywords are where pure dense search is weak. Ask for "HNSW" and a good embedding might still rank a generic paragraph about graph indexes above the one that literally contains the word. Combining a dense vector for meaning with a sparse vector for exact terms fixes that, and Qdrant's prefetch plus fusion API is built for exactly this.

A collection can hold both vector types as named vectors:

if client.collection_exists("articles"):
    client.delete_collection("articles")

client.create_collection(
    collection_name="articles",
    vectors_config={
        "dense": models.VectorParams(size=384, distance=models.Distance.COSINE),
    },
    sparse_vectors_config={
        "text": models.SparseVectorParams(),
    },
)

Sparse vectors store only their non-zero indices and values. In production you produce them with a sparse encoder such as SPLADE. To keep this example dependency-free, a small term-frequency encoder does the same job and keeps counts per token instead of collapsing duplicates:

vocab = {}

def sparse_encode(text: str) -> models.SparseVector:
    counts = {}
    for token in text.lower().split():
        idx = vocab.setdefault(token, len(vocab))
        counts[idx] = counts.get(idx, 0) + 1
    indices = list(counts.keys())
    values = [float(counts[i]) for i in indices]
    return models.SparseVector(indices=indices, values=values)

Upsert every point with both vector keys:

client.upsert(
    collection_name="articles",
    points=[
        models.PointStruct(
            id=idx,
            vector={
                "dense": embed(text),
                "text": sparse_encode(text),
            },
            payload={"text": text, "tool": tool, "category": category},
        )
        for idx, (text, tool, category) in enumerate(docs)
    ],
    wait=True,
)

Then run both searches and fuse the results with reciprocal rank fusion (RRF). RRF ignores raw scores and works on rank position, so two retrievers with totally different score scales combine without calibration:

query = "Rust database with HNSW indexing"

hits = client.query_points(
    collection_name="articles",
    prefetch=[
        models.Prefetch(query=sparse_encode(query), using="text", limit=20),
        models.Prefetch(query=embed(query), using="dense", limit=20),
    ],
    query=models.RrfQuery(rrf=models.Rrf(k=60)),
    limit=5,
).points

for hit in hits:
    print(hit.id, hit.payload["tool"], "|", hit.payload["text"][:60])

Real output from this exact script:

0 qdrant | Qdrant is a vector database written in Rust. It stores vecto
2 pgvector | pgvector adds vector search to PostgreSQL as an extension, s
3 concepts | Hybrid search combines dense and sparse vectors to match bot
1 chroma | Chroma runs inside your Python process, which makes it fast

The qdrant chunk wins on both sides: dense similarity and exact matches for "rust" and "hnsw". The k constant smooths the fusion. Lower values amplify top ranks, higher values give deeper results more say. The default is 2, and 60 appears in Qdrant's own examples. Weighted RRF, which lets you trust one retriever more than the other, has been available since v1.17.

Qdrant vs pgvector vs Chroma

All three do vector search. They differ in where the data lives and how far they scale.

Choose When
pgvector You already run PostgreSQL and vectors can live next to relational data. Filters become ordinary SQL, and there is no extra service to operate. Community benchmarks place it comfortably in the 10M+ vector range for most apps.
Chroma Prototyping and notebooks. It runs in-process, so the first demo takes minutes. Single node, memory-bound, no built-in sharding, so plan the migration before real concurrent traffic shows up.
Qdrant A standalone service that must serve real users. Native payload filtering that runs before ranking, quantization options (roughly 8x compression with TurboQuant on the v1.18 line), hybrid search, and sharding and replication for growth.

The starting point matters more than the feature list. If Postgres is already running, pgvector removes an entire service from your stack. If you need search quality features like hybrid retrieval and predictable latency under load, a purpose-built database like Qdrant earns its keep. If you are still sketching a demo, Chroma is fine, as long as you know it is a prototype, not a platform.

What to do next

  • Take snapshots regularly. Both the dashboard and the API support creating them, which gives you point-in-time backups of the whole storage directory.
  • Read the production checklist before exposing Qdrant publicly: it covers authentication, snapshots, and operator dashboards.
  • For large collections, look at quantization to cut memory per vector, then choose between on-disk and in-memory storage per collection.

References

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation