Citation RAG on Qdrant (Self-Hosted Vector Store)

Run the grounded-citations RAG pattern on a local Qdrant container: stable chunk IDs, payload-filtered retrieval, and answers that cite only the chunks Qdrant returned.

qdrantragvector-databasecitationsself-hostedembeddings
Setup time1-3 hours (estimate)
Estimated costSelf-hosted Qdrant is open source (you pay for the machine). Qdrant Cloud Free tier is $0 (1 GB RAM, 4 GB disk, single node); Standard is usage-based. Embeddings with text-embedding-3-small are $0.02 per 1M tokens, plus your chat model's token price. See qdrant.tech/pricing.
VerificationLast verified 2026-10-23

This is the RAG Agent with Grounded Citations pattern wired to a concrete vector store. Qdrant runs as one Docker container on your machine (or on Qdrant Cloud's free tier), each chunk is stored with its source file and position as payload, and the answer step may only cite chunk IDs that the search actually returned.

Requirements

  • Docker, for the local Qdrant server
  • Python 3.10+ (required by qdrant-client)
  • An OpenAI API key for embeddings and the answer step (any embedding model works if you change the vector size)

1. Start Qdrant

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

REST API is on localhost:6333, the web UI on localhost:6333/dashboard, and gRPC on localhost:6334. Data persists in ./qdrant_storage.

pip install qdrant-client openai
export OPENAI_API_KEY=sk-...

2. Create the collection and index your chunks

import re
import uuid
from openai import OpenAI
from qdrant_client import QdrantClient, models

oai = OpenAI()
qdrant = QdrantClient(url="http://localhost:6333")
COLL = "kb"
EMBED_MODEL = "text-embedding-3-small"  # 1536-dimension vectors by default

def embed(texts):
    resp = oai.embeddings.create(model=EMBED_MODEL, input=texts)
    return [d.embedding for d in resp.data]

if not qdrant.collection_exists(COLL):
    qdrant.create_collection(
        collection_name=COLL,
        vectors_config=models.VectorParams(size=1536, distance=models.Distance.COSINE),
    )
    # Index the field you filter on, so filtered search stays fast
    qdrant.create_payload_index(
        collection_name=COLL,
        field_name="tenant",
        field_schema=models.PayloadSchemaType.KEYWORD,
    )

chunks = [  # from your own chunker
    {"doc": "refund-policy.md", "chunk": 0, "tenant": "acme",
     "text": "Customers can request a refund within 30 days of purchase."},
]

vectors = embed([c["text"] for c in chunks])
qdrant.upsert(
    collection_name=COLL,
    wait=True,
    points=[
        models.PointStruct(
            # Stable ID: re-indexing the same chunk overwrites it instead of duplicating
            id=str(uuid.uuid5(uuid.NAMESPACE_URL, f'{c["doc"]}#{c["chunk"]}')),
            vector=v,
            payload=c,
        )
        for c, v in zip(chunks, vectors)
    ],
)

3. Retrieve with a filter, answer with citations

def retrieve(question, tenant, k=5):
    return qdrant.query_points(
        collection_name=COLL,
        query=embed([question])[0],
        query_filter=models.Filter(
            must=[models.FieldCondition(key="tenant", match=models.MatchValue(value=tenant))]
        ),
        with_payload=True,
        limit=k,
    ).points

def answer(question, tenant):
    hits = retrieve(question, tenant)
    if not hits:
        return "I don't have a source for that."
    context = "\n\n".join(f'[{h.id}] ({h.payload["doc"]}) {h.payload["text"]}' for h in hits)
    resp = oai.chat.completions.create(
        model="gpt-6-luna",  # re-check OpenAI's current model list before shipping
        messages=[
            {"role": "system", "content": (
                "Answer only from the sources. After each claim, cite the source id in "
                "square brackets. If the sources don't answer it, say you don't know.")},
            {"role": "user", "content": f"Sources:\n{context}\n\nQuestion: {question}"},
        ],
    )
    text = resp.choices[0].message.content or ""
    allowed = {str(h.id) for h in hits}
    cited = set(re.findall(r"\[([0-9a-f-]{36})\]", text))
    if not cited or not cited <= allowed:
        return "I couldn't produce a grounded answer for that."
    return text

The last check is the important part: an answer that cites nothing, or cites an ID Qdrant never returned, is rejected rather than shown.

Moving to Qdrant Cloud

Create a cluster, copy its URL and an API key, and change only the client line:

qdrant = QdrantClient(url="https://<your-cluster-url>:6333", api_key="<your-api-key>")

Keep the key in an environment variable or secret manager, never in the repo.

Cost note

Self-hosted Qdrant is open source, so you pay only for the machine it runs on. Qdrant Cloud has a free-forever tier (single node, 0.5 vCPU, 1 GB RAM, 4 GB disk) for testing and prototypes; the Standard tier is usage-based, billed hourly on compute, memory and storage. text-embedding-3-small is $0.02 per 1M tokens, and answer tokens are billed at your chat model's price. Re-check Qdrant pricing and OpenAI's pricing before you budget.

Sources (official docs, checked 2026-10-23)

Failure modes

  • Changing the embedding model without recreating the collection breaks search: the collection's vector size and distance are fixed at creation (1536 for text-embedding-3-small).
  • Random point IDs on every re-index create duplicate chunks; derive stable IDs from document and chunk position.
  • The model can cite IDs that were never retrieved; reject answers whose citations are not a subset of the returned point IDs.
  • Filtering on an unindexed payload field gets slow on large collections; create a payload index for fields you filter on (tenant, doc type).
  • Exposing port 6333 without an API key lets anyone read or delete the collection; keep it on localhost or enable authentication.
  • The Cloud Free tier is capped at 1 GB RAM and 4 GB disk; size your corpus against it before relying on it, or shorten vectors with the embeddings dimensions parameter.

Markdown version: /blueprints/qdrant-rag-citations.md

Want a deeper build of this?

Vote for an extended, fully tested version (more tools, a full workflow file). Votes decide what we build next.

– votes

Related blueprints

Get one fully built blueprint a week

A weekly email: one blueprint with config, cost, setup time and failure modes, plus a vote on what we build next.