DEV Community

Cover image for Qdrant's free tier can be your whole database, not just your vector index
Amro
Amro

Posted on

Qdrant's free tier can be your whole database, not just your vector index

TL;DR: Qdrant isn't only a vector index. but a whole database for my usecase

I run Bastet, a dictionary of Ancient Egyptian with Arabic and English translations. The backend is a small NestJS API on Heroku. Its database was RavenDB Cloud, mostly because RavenDB embeds text for you: insert a word with an English gloss, and you can search it by meaning without ever computing a vector yourself.

That convenience cost €40 a month, which is a lot for a side project with 70,000 documents. So I looked at the usual suspects: MongoDB Atlas, Weaviate, Qdrant. My assumption was that a vector database could replace the search part of RavenDB, and I'd need something else for everything that wasn't search: category listings, admin paging, and the app's smaller collections that have nothing to do with search.

That assumption was wrong. Qdrant's free tier is now the only database the API talks to. Here's what moved, what I had to give up, and how the migration went.

Before and after architecture, and the five-step migration

What the API actually asked of its database

Before deciding anything I went through every database call in the codebase. It's worth doing; the list was shorter than I feared.

  • Vector search on words and sentences, in English and Arabic. The core feature.
  • Load by id, create, patch, delete. The admin panel.
  • Filter by field, such as words by category.
  • Paged keyword search for the admin panel, with a regex that treats the variant forms of an Arabic letter (the alef) as one letter.
  • Infix search (*word*) for non-English queries.
  • Sorting by a nested field, and by creation date.
  • A few plain collections with no vectors at all.

Everything in the first three groups maps directly onto Qdrant: a point's payload is the document, filters cover whereEquals and whereIn, and ids can be UUIDs, which RavenDB was already using. The last four needed compromises.

The compromises

No regex, no infix. Qdrant's full-text index matches tokens, not substrings, and there is no regex at all. I replaced both with a prefix tokenizer over a normalized copy of each word: lowercase, Arabic diacritics stripped, alef variants folded to one base letter. The query is normalized the same way, so, much like "abc" finding "ábc", the plain spelling finds the variant spelling without any regex. What I lost is matching the middle of a word: *ankh* no longer finds sankh. For a dictionary that's acceptable.

Sorting happens in code. Qdrant only orders by indexed numeric and date fields. Category lists sorted by the Egyptian word are small, so I sort them after fetching. Anything sorted by creation date works natively because I indexed it as a datetime.

One vector per meaning. A dictionary entry often has several English glosses ("to be whole; to be intact"). RavenDB embedded each one. Qdrant has multivectors for exactly this: each word stores one vector per meaning and is scored by its best match. This turned out to matter for quality; a single joined string blurs the meanings together.

Collections without vectors. The non-search collections are stored as points with an empty vector config and just a payload. It feels slightly odd, and it works fine.

Updates rewrite the whole point. RavenDB tracks changes inside a session; Qdrant doesn't. A patch loads the document, mutates it, and upserts it back, which also re-embeds it if a meaning changed. The one thing that costs is optimistic concurrency, which a single-editor admin panel doesn't need.

The migration

The whole thing is five steps, and the one that took the longest was embedding.

  1. Export. A script streams every document out of RavenDB into one JSON file per collection: more than 70,000 words and sentences. About 50 MB.
  2. Embed. Qdrant Cloud can embed text server-side with hosted models, and that's what the API uses for search queries in production. But hosted inference can't build a multivector from several texts on insert, so the bulk import embeds locally with all-MiniLM-L6-v2 through transformers.js. It's the same model Qdrant hosts; I checked and the vectors are identical (cosine similarity 1.0), so what I tuned locally held in the cloud. 70,000 documents took about 13 minutes on a laptop.
  3. Upsert in batches, keeping the RavenDB UUIDs as point ids. Importing twice overwrites instead of duplicating, which makes the whole thing re-runnable.
  4. Verify. A script checks that every exported id exists in Qdrant and compares a couple of hundred payloads per collection field by field. Zero missing, zero different.
  5. Switch. The API picks its database from one environment variable, DB_PROVIDER. Setting it to qdrant on Heroku restarts the app on Qdrant; deleting it puts RavenDB back. Before flipping it, I re-ran the import with a --sync flag that writes only new or changed documents and deletes removed ones, so nothing written in the meantime was lost.

The code change was one abstract class, DictionaryDb, with the dozen operations above, and two implementations. The services don't know which one they're talking to.

What it looks like in production

The free cluster is a single node with 0.5 vCPU, 1 GB RAM and 4 GB disk. With 384-dimensional vectors, 70,000 points use roughly a quarter of the RAM; the payloads add about 70 MB. There's room to grow the dictionary several times over before it matters, and scalar quantization or on-disk payloads would buy more.

Measured end to end from the Heroku API, vector search takes 170–370 ms (including the server-side query embedding) and filtered keyword search 65–130 ms. Talking to the cluster directly, the same similarity search has a median of 91 ms and a p95 of 101 ms over 200 calls.

Search quality is slightly different rather than worse. For "to be unharmed", RavenDB returned a list of "to protect"; Qdrant returns "to be injured" first, then "to protect". Small models sometimes rank an opposite meaning high. For "king", "mother", "water" and the offering formulas that make up most sentence searches, the results are equivalent.

What you don't get for free

  • No managed backups. The JSON export from step 1 is my backup, and I plan to re-run it regularly. If the cluster disappeared tomorrow, re-importing takes 13 minutes.
  • No SLA, community support only. The terms let Qdrant suspend any cluster "for any reason". Free clusters also get suspended after a period of inactivity; a live app keeps it awake, but a dormant one won't.
  • Single node. No high availability. If you need that, the Standard tier is usage-based and starts at the point where you'd outgrow 1 GB anyway.

How to start with your migration?

If you're considering the same move: list every database call first, try the import on a local Docker Qdrant before touching the cloud, and wish you all success~

Top comments (0)