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.
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/qdrantREST 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 textThe 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)
- Qdrant local quickstart
- Qdrant Cloud authentication
- Qdrant indexing (payload indexes)
- Qdrant pricing
- PyPI qdrant-client
- OpenAI text-embedding-3-small
- OpenAI embeddings guide
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.
Related blueprints
AI Lead Follow-Up and Appointment Booking Agent (GoHighLevel)
Text every new lead within minutes, let a Conversation AI bot qualify them and book a call on your calendar, then hand off to a human, all inside GoHighLevel.
Agent Tracing and Evaluation Setup
Capture structured traces of every LLM and tool call and run a small regression eval suite on each change.
Evaluator-Optimizer Refinement Loop
One model generates, another grades against a rubric, and the loop repeats until the rubric passes or a limit is hit.
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.