# 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.

Category: AI Agent Workflows  
Tags: qdrant, rag, vector-database, citations, self-hosted, embeddings  
Setup time (estimate): 1-3 hours  
Estimated cost: Self-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.  
Last verified: 2026-10-23

This is the [RAG Agent with Grounded Citations](/blueprints/rag-with-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

```bash
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`.

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

## 2. Create the collection and index your chunks

```python
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

```python
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:

```python
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](https://qdrant.tech/pricing/) and OpenAI's pricing before you budget.

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

- [Qdrant local quickstart](https://qdrant.tech/documentation/quickstart/)
- [Qdrant Cloud authentication](https://qdrant.tech/documentation/cloud/authentication/)
- [Qdrant indexing (payload indexes)](https://qdrant.tech/documentation/concepts/indexing/)
- [Qdrant pricing](https://qdrant.tech/pricing/)
- [PyPI qdrant-client](https://pypi.org/project/qdrant-client/)
- [OpenAI text-embedding-3-small](https://developers.openai.com/api/docs/models/text-embedding-3-small)
- [OpenAI embeddings guide](https://developers.openai.com/api/docs/guides/embeddings)

## 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.
