Skip to content

Instantly share code, notes, and snippets.

@mathematicalmichael
Created April 20, 2026 18:04
Show Gist options
  • Select an option

  • Save mathematicalmichael/6951676ce3d0d1fb12f1255111e4c9f8 to your computer and use it in GitHub Desktop.

Select an option

Save mathematicalmichael/6951676ce3d0d1fb12f1255111e4c9f8 to your computer and use it in GitHub Desktop.
QDRANT GUIDANCE

Qdrant — local vector database

Runs at:

  • REST: http://127.0.0.1:6333
  • gRPC: 127.0.0.1:6334
  • Dashboard: http://127.0.0.1:6333/dashboard

Data persists in ./storage/ (gitignored). make wipe CONFIRM=1 to reset.

Pair with the local embeddings API at 127.0.0.1:8080 (see top-level CLAUDE.md) — nomic-embed-text/vision share a single 768-dim cosine space, so text and image vectors can live in the same collection.


Install the client

uv add qdrant-client       # or: uv run --with qdrant-client python -c '...'

All snippets below assume:

from qdrant_client import QdrantClient, models
client = QdrantClient(url="http://localhost:6333")
# For higher throughput / embedded use, prefer gRPC:
# client = QdrantClient(host="localhost", grpc_port=6334, prefer_grpc=True)

1. Create a collection

Single unnamed vector (simplest)

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

Distances: COSINE, DOT, EUCLID, MANHATTAN. For nomic-embed vectors, use COSINE (they're L2-normalized).

Multiple named vectors

Use named vectors if you want several embeddings per point (e.g. different models or text-vs-summary).

client.create_collection(
    collection_name="multimodal",
    vectors_config={
        "text": models.VectorParams(size=768, distance=models.Distance.COSINE),
        "image": models.VectorParams(size=768, distance=models.Distance.COSINE),
    },
)

HNSW / quantization / on-disk (tuning)

client.create_collection(
    collection_name="big",
    vectors_config=models.VectorParams(
        size=768,
        distance=models.Distance.COSINE,
        on_disk=True,                   # vectors on disk instead of RAM
        hnsw_config=models.HnswConfigDiff(m=32, ef_construct=128),
    ),
    quantization_config=models.ScalarQuantization(
        scalar=models.ScalarQuantizationConfig(
            type=models.ScalarType.INT8, quantile=0.99, always_ram=True,
        ),
    ),
)

Recreate / idempotent setup

create_collection errors if it exists. For idempotent setup use recreate_collection (destructive) or check first:

if not client.collection_exists("docs"):
    client.create_collection(...)

2. Payload indexes (create these for every filterable field)

Filtering without an index triggers a full scan. Create an index for every payload field you'll filter on — do this right after create_collection, before inserting lots of data.

client.create_payload_index("docs", "tenant_id",  models.PayloadSchemaType.KEYWORD)
client.create_payload_index("docs", "created_at", models.PayloadSchemaType.DATETIME)
client.create_payload_index("docs", "price",      models.PayloadSchemaType.FLOAT)
client.create_payload_index("docs", "count",      models.PayloadSchemaType.INTEGER)
client.create_payload_index("docs", "is_active",  models.PayloadSchemaType.BOOL)
client.create_payload_index("docs", "user_uuid",  models.PayloadSchemaType.UUID)
client.create_payload_index("docs", "location",   models.PayloadSchemaType.GEO)

Full-text index (for MatchText queries)

client.create_payload_index(
    "docs", "body",
    field_schema=models.TextIndexParams(
        type=models.TextIndexType.TEXT,
        tokenizer=models.TokenizerType.WORD,
        lowercase=True,
        min_token_len=2,
    ),
)

Nested keys are written with dots ("country.name"); arrays with [] ("tags[]", "country.cities[].population").


3. Upsert points

client.upsert(
    collection_name="docs",
    points=[
        models.PointStruct(
            id=1,                                  # int or UUID str
            vector=[0.1, 0.2, ...],                # 768 floats
            payload={"tenant_id": "acme", "tags": ["ai", "search"], "price": 12.5},
        ),
        models.PointStruct(id=2, vector=[...], payload={"tenant_id": "acme"}),
    ],
)

Named-vector collections take a dict in vector=:

models.PointStruct(id=1, vector={"text": [...], "image": [...]}, payload={...})

Batch-friendly helper: client.upload_collection(...) or client.upload_points(...) for large ingests (handles batching + parallelism).


4. Search with filters

Note: client.search() was removed in qdrant-client ≥1.10 — use client.query_points(...).points instead. Older examples in upstream docs still show .search(); if you see that, mentally rewrite it.

res = client.query_points(
    collection_name="docs",
    query=[0.1, 0.2, ...],                    # query vector
    query_filter=models.Filter(must=[...]),
    limit=10,
    with_payload=True,
)
hits = res.points                             # list[ScoredPoint]

Full example with filter combinators:

hits = client.query_points(
    collection_name="docs",
    query_vector=[0.1, 0.2, ...],                    # plain list for unnamed
    # query_vector=("text", [0.1, ...]),             # for named-vector collection
    query_filter=models.Filter(
        must=[
            models.FieldCondition(key="tenant_id",
                                  match=models.MatchValue(value="acme")),
            models.FieldCondition(key="price",
                                  range=models.Range(gte=10, lte=100)),
        ],
        should=[                                     # OR block
            models.FieldCondition(key="tags",
                                  match=models.MatchAny(any=["ai", "ml"])),
        ],
        must_not=[
            models.FieldCondition(key="is_deleted",
                                  match=models.MatchValue(value=True)),
        ],
    ),
    limit=10,
    with_payload=True,
).points
for h in hits:
    print(h.id, h.score, h.payload)

Filter primitives

Want Condition
exact match match=models.MatchValue(value=x)
in a set (OR on one field) match=models.MatchAny(any=[...])
not in a set match=models.MatchExcept(**{"except": [...]})
numeric / date range range=models.Range(gt=, gte=, lt=, lte=)
full text (needs text index) match=models.MatchText(text="...")
geo radius geo_radius=models.GeoRadius(center=..., radius=meters)
nested object models.NestedCondition(nested=models.Nested(key=..., filter=...))
payload key exists models.IsEmptyCondition(is_empty=models.PayloadField(key=...)) (negate with must_not)

Filter semantics: must = AND, should = OR (matches if any satisfied), must_not = NOT. Nest Filter inside must/should for arbitrary logic.


5. Scroll (list/iterate with filter, no vector query)

points, next_page = client.scroll(
    collection_name="docs",
    scroll_filter=models.Filter(must=[
        models.FieldCondition(key="tenant_id",
                              match=models.MatchValue(value="acme")),
    ]),
    limit=500,
    with_payload=True,
)

Paginate with offset=next_page until next_page is None.


6. Recommend / discover (optional)

client.recommend(
    collection_name="docs",
    positive=[1, 7],            # IDs or vectors you liked
    negative=[3],
    query_filter=...,
    limit=10,
)

7. Operational bits

client.get_collection("docs")            # schema + counts
client.count("docs", exact=True)
client.delete(collection_name="docs",
              points_selector=models.FilterSelector(filter=...))
client.set_payload("docs", payload={"k": "v"}, points=[1, 2])
client.delete_payload("docs", keys=["k"], points=[1, 2])
client.delete_collection("docs")
client.create_snapshot("docs")           # for backup

8. Gotchas

  • Always create payload indexes before bulk inserts — filtering without an index is a full scan and the index has to be built retroactively.
  • Distance must match the embedding model. Nomic = COSINE.
  • Named vs unnamed vectors are not interchangeable; pick up-front. Named vectors are the safe default if you might ever add a second embedding.
  • IDs can be positive ints or UUID strings — not arbitrary strings.
  • should on its own matches if any condition matches (OR). Combined with must, it acts as a "nice-to-have" scorer influence? No — it's still a strict match: at least one should must match in addition to all must.
  • gRPC client is faster for high-volume upserts and search. Toggle via prefer_grpc=True.
  • Full-text match (MatchText) only works on fields with a TEXT payload index — otherwise it errors.

Upstream links

services:
qdrant:
image: qdrant/qdrant:latest
container_name: qdrant
restart: unless-stopped
ports:
- "127.0.0.1:6333:6333" # HTTP / REST
- "127.0.0.1:6334:6334" # gRPC
volumes:
- ./storage:/qdrant/storage
# Qdrant vector database — docker compose group.
# Persistent storage in ./storage (bind-mount; gitignored).
DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
COMPOSE := docker compose -f $(DIR)docker-compose.yml
.PHONY: help install enable disable uninstall status restart logs pull wipe
help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*##' $(firstword $(MAKEFILE_LIST)) | awk 'BEGIN {FS = ":.*## "}; {printf " %-12s %s\n", $$1, $$2}'
install: pull ## Pull image (no-op for systemd parity)
enable: ## Start container (detached, restart=unless-stopped)
@$(COMPOSE) up -d
@echo "qdrant: REST 127.0.0.1:6333 gRPC 127.0.0.1:6334 dashboard http://127.0.0.1:6333/dashboard"
disable: ## Stop and remove container (keeps storage/)
@$(COMPOSE) down
uninstall: disable ## Alias for disable (storage/ is preserved)
status: ## Show container status
@$(COMPOSE) ps
restart: ## Restart container
@$(COMPOSE) restart
logs: ## Tail logs (use N=100 for more lines)
@$(COMPOSE) logs --tail=$(or $(N),50) -f
pull: ## Pull latest image
@$(COMPOSE) pull
wipe: ## Stop and DELETE all data (requires CONFIRM=1)
@test "$$CONFIRM" = "1" || (echo "set CONFIRM=1 to wipe all qdrant data"; exit 1)
@$(COMPOSE) down
@rm -rf $(DIR)storage
@echo "qdrant storage wiped."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment