Every retrieval tutorial reaches the same sentence: "point it at your vector database." Dify wants one. Langflow wants one. AnythingLLM wants one. None of them explains how to run one, and the hosted options want a credit card before you have decided whether you need one.
A vector database is a single container. This walks through running one — Qdrant, because it is one binary with no external dependencies — from an empty machine to a working search, and then through the one default that will confuse you if nobody warns you about it.
What you need
Docker, and about 70 MB of disk. That is the whole prerequisite list. The image used here is qdrant/qdrant:latest at version 1.19.0, 69 MB, built 4 August 2026.
Start it
docker run -d --name qdrant \
-p 6333:6333 \
-v "$(pwd)/qdrant_storage:/qdrant/storage" \
qdrant/qdrant:latest
The volume is the part people skip and regret. Without it the container's storage lives inside the container, and docker rm takes your data with it.
Check it is alive:
curl http://localhost:6333/
{"title":"qdrant - vector search engine","version":"1.19.0","commit":"74f3e85b"}
At rest the container used 61 MB of memory on the machine this was run on — its whole footprint before any data goes in.
Create a collection
A collection is a table. It needs to know how long your vectors are and how to compare them:
curl -X PUT http://localhost:6333/collections/notes \
-H 'Content-Type: application/json' \
-d '{"vectors":{"size":4,"distance":"Cosine"}}'
Four dimensions here so the examples are readable. Real embeddings are 384, 768 or 1536 depending on the model, and the size must match your model exactly — this is the single most common setup error, and it fails at insert time rather than at creation time.
Cosine is the right default for text embeddings from the usual models. The call returned in 0.042 seconds.
Put something in it
Points are a vector plus a JSON payload. That pairing is what makes a vector database more useful than a bare index:
curl -X PUT 'http://localhost:6333/collections/notes/points?wait=true' \
-H 'Content-Type: application/json' \
-d '{"points":[
{"id":1,"vector":[0.9,0.1,0.0,0.0],"payload":{"title":"invoice terms","team":"finance"}},
{"id":2,"vector":[0.1,0.9,0.0,0.0],"payload":{"title":"deploy runbook","team":"platform"}},
{"id":3,"vector":[0.85,0.15,0.0,0.0],"payload":{"title":"payment reminder","team":"finance"}}
]}'
wait=true makes the call return once the write is durable. Without it you get an acknowledgement and a race with your next query. Three points inserted in 0.0028 seconds.
Search it
curl -X POST http://localhost:6333/collections/notes/points/search \
-H 'Content-Type: application/json' \
-d '{"vector":[0.9,0.1,0.0,0.0],"limit":3,"with_payload":true}'
The three scores that came back, in order: 1.0, 0.99795175, 0.2195122.
The first is the point whose vector is identical to the query — cosine similarity of a vector with itself is exactly 1. The second is "payment reminder", which is nearly parallel to it. The third is the deploy runbook, pointing in a different direction entirely. That gap between 0.998 and 0.22 is what retrieval is: not a keyword match, but an angle.
Filter, and watch the answer change
This is the part worth understanding before you build anything on it. Search for the runbook vector again, but restrict the query to the finance team:
curl -X POST http://localhost:6333/collections/notes/points/search \
-H 'Content-Type: application/json' \
-d '{"vector":[0.1,0.9,0.0,0.0],"limit":3,"with_payload":true,
"filter":{"must":[{"key":"team","match":{"value":"finance"}}]}}'
Without the filter, the top hit is id 2, the deploy runbook, at a perfect 1.0. With it, the top hit is id 3 and only 2 results come back.
Qdrant applied the filter during the search rather than after it, which is why you still get the best two finance notes rather than an empty page. A system that filtered afterwards would have taken the top three by score, found they were mostly platform documents, and handed you very little.
The default that will confuse you
The documentation does not prepare you for what happens next, and this is the reason the guide was run rather than written from the manual.
Qdrant builds its HNSW index once a segment holds indexing_threshold vectors, and that default is 10,000. So the obvious test is to insert more than ten thousand vectors and watch the index appear. We inserted 12,000 more, bringing the collection to 12,003 points, waited, and checked:
curl http://localhost:6333/collections/notes
"points_count": 12003,
"indexed_vectors_count": 0,
"segments_count": 3
Zero. The collection is well past the threshold and nothing is indexed. Reading the per-segment telemetry explains it:
segment: 4003 points, type "plain"
segment: 4000 points, type "plain"
segment: 4000 points, type "plain"
The threshold applies per segment, not per collection. Twelve thousand points were spread across three segments of about four thousand each, and since no single segment reached ten thousand, none of them built an index. plain means exactly that: a brute-force scan.
This is not a bug and it is not a problem at this size — an exhaustive scan of four thousand vectors is fast, and it is more accurate than an approximate index. But if you are load-testing, watching for the index to appear, or wondering why your recall is suspiciously perfect, this is why. Lower indexing_threshold in the collection's optimizer config if you want the index sooner.
Check it survives
docker restart qdrant
All 12,003 points were still there afterwards, which is what the volume mount bought you. Storage on disk came to 9 MB for those twelve thousand four-dimensional vectors, and memory rose from 61 MB idle to 76 MB loaded.
What this is enough for, and what it is not
- Enough for: a local RAG stack, an internal search prototype, the "point it at a vector database" step in Dify, Langflow or AnythingLLM, and any corpus that fits comfortably in memory.
- Not enough for: anything exposed to the internet. This container has no authentication as configured here. Qdrant supports an API key; set one before it leaves your machine, and do not publish port 6333.
- Not a benchmark. Every timing above is one run on one laptop with four-dimensional toy vectors. Real embeddings are hundreds of dimensions and will behave differently.
If you are choosing between running this yourself and paying for it, what one server actually runs covers what a single box genuinely handles.
What is measured here
Everything above was executed on 21 August 2026, not transcribed from documentation. The script that produces every version, timing, score and count in this guide is in our repository and runs the whole sequence end to end against a throwaway container.
The timings are a single run on one machine and are included to give a sense of scale — sub-millisecond searches, a tenth of a second to insert twelve thousand points — rather than as benchmarks. Memory and disk figures are from docker stats and du at the moments described.
The per-segment indexing behaviour is the key finding, and it is the reason the executed-tutorial standard exists. You do not get it from the manual: you see it only when the collection-level number says 12,003, the index count says zero, and both are correct.