Qdrant
-
Four Embedding Models, All 1024 Dimensions, All Incompatible
If you run a vector database, you’ve probably got a startup check that compares your configured embedding dimension against the collection’s actual vector size. Mine has two of them. They’re both useless for the failure I care about.
Here are four embedding models I could plausibly point my memory server at, with the dimension each one emits:
Model Dimensions mistral-embed1024 @cf/baai/bge-large-en-v1.51024 bge-m31024 Qwen3-Embedding-0.6B1024 I checked the last two against their model configs rather than trusting my own commit note, and
hidden_sizeis 1024 in both. Mistral and Cloudflare’s BGE I’d already written into a comparison table months ago without noticing what the column was telling me.So: swap providers, keep the number, and every dimension check on the system passes. Qdrant accepts the writes. The server boots clean. Preflight is green. And your recall results are garbage, because a vector from
mistral-embedand a vector frombge-m3are both 1024 floats that mean entirely unrelated things. Cosine similarity between them isn’t wrong so much as it’s meaningless, and meaningless similarity still returns a top-k. You get five confident results that have nothing to do with the query.That’s a bad failure mode. It’s silent, it survives every check, and the symptom (bad recall quality) looks like a tuning problem rather than a data problem. You’d spend an afternoon adjusting
RECALL_MIN_SCOREbefore you’d suspect the collection.Dimension is a checksum with one byte
The mistake I made was treating dimension as an identity check when it’s a shape check. It tells you the vectors will fit. It says nothing about where they came from.
Think about what actually has to match for a vector search to mean anything. The model, obviously. But also the provider serving it, because a self-hosted
bge-m3and a hosted one behind an inference API can differ in pooling or normalization. The distance metric, since cosine and dot product rank differently on unnormalized vectors. And the dimension, which is the only one of those four I was checking.So the collection now carries a fingerprint: provider host, model, dimensions, distance, and a schema version, written into Qdrant as a well-known point. It’s non-secret by design, no keys, no account IDs, just enough to answer “did these vectors come from the same place the current config points at?” It’s validated at startup and again in preflight, and a mismatch is fatal with its own exit code, 9.
Nine is deliberately separate from the two dimension codes I already had. 4 is config disagreeing with Qdrant. 8 is config disagreeing with what the provider actually returned. 9 is the case where 4 and 8 both pass, every number agrees, and the vectors are still incompatible. Those want three different fixes, so they get three different exit codes. Telling failures apart by status instead of by grepping a log message is a habit I keep being glad about.
The hard part wasn’t detecting it
Writing the check took an afternoon. Deciding what to do about the collection that already existed took longer.
I have a live collection with real memories in it, created before any of this existed. It has no fingerprint. The naive implementation treats that as a mismatch and refuses to boot, which would mean shipping a change that bricks my own running server and everyone else’s, in defense of a problem none of them currently have.
So an absent fingerprint is a third state, not a failure. The server boots normally, preflight succeeds and appends
fingerprint=unadoptedto its ok line, and you get a one-line notice at boot. Nothing breaks. You’re just told.Adoption is then an explicit command,
mem0-mcp --adopt-fingerprint, with two rules I’d encourage you to steal if you build something similar:- It refuses to relabel a collection that already records a different fingerprint. Adoption is for unlabeled collections. If there’s a label and it disagrees with your config, that’s exactly the mismatch the feature exists to catch, and letting a flag overwrite it turns the safety check into a suggestion.
- Auto-stamping only happens on a collection that’s verifiably empty. An empty collection has no vectors to be wrong about, so stamping it is free. A populated one requires you to say out loud that you know where those vectors came from.
The general principle: when you add a validation to a system that’s already running, the pre-existing state needs a name that isn’t “invalid.” Otherwise the check is unshippable, and an unshippable check gets loosened until it stops checking.
The bit I’d get wrong again
I want to be honest about where this came from, because it wasn’t foresight. I was adding Cloudflare Workers AI as a second embedding provider. It’s an OpenAI-compatible REST endpoint, so it needed no adapter at all, just a different base URL, model and token. A config change, nothing more.
I was writing the provider comparison table for the docs, put
1024in the Mistral column and1024in the Workers AI column, and sat there looking at it. The whole feature exists because I typed the same number twice in a markdown table.If I’d added a provider at 768 or 3072, the dimension check would have caught the swap on the first boot, I’d have fixed my config, and I’d never have learned the check was load-bearing for the wrong reason. The collision is what made the gap visible. 1024 is a popular number, and popular numbers are where your shape checks quietly stop being identity checks.
Go look at your own table. If two rows have the same dimension, you have this bug too, and nothing in your stack is going to tell you about it.
Sources
- Mistral embeddings —
mistral-embed, 1024 dimensions - Cloudflare Workers AI: bge-large-en-v1.5 — model card and dimensions
- Workers AI OpenAI compatibility — why no adapter was needed
- Cloudflare AI Gateway — the optional proxy layer in front of the provider
- Qdrant documentation — collections, vector params and distance metrics
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
-
Running Mem0's Memory Backend as Real Infrastructure with Ansible
Mem0 works best when you treat its memory backend as infrastructure, not as some throwaway process you start by hand and forget about. There are two pieces worth automating early: the vector store and the graph store. Qdrant holds the embeddings for semantic lookup. Neo4j holds the relationship-oriented graph memory.
Quick note before we go further, because this can be confusing. When folks talk about Mem0’s “graph” support, that is not GraphQL. GraphQL is an API query layer you’d put in front of your app. Mem0’s graph memory is an actual graph database, usually Neo4j, that the client talks to over Bolt. Different thing entirely.
This post walks through a practical Ansible shape for running those backing services with Docker Compose. I’m not going to hand you a complete role. The point is to show the decisions that make the setup repeatable, because those are the parts people usually get wrong.
What the deployment actually does
Three responsibilities, that’s it:
- Get the Mem0 compose project onto the server.
- Write a server-local
.envfile with the runtime config. - Start the Compose stack with Qdrant and Neo4j.
In a small setup, the Python client runs on your laptop while the services run on a LAN host or a small VPS. The server exposes these ports, but only to trusted networks:
- Qdrant HTTP:
6333 - Qdrant gRPC:
6334 - Neo4j browser:
7474 - Neo4j Bolt:
7687
If you’re on a public internet host, bind these to localhost or hide them behind a VPN, firewall, or private network. Do not casually publish database ports to the internet. I shouldn’t have to say remind you…
Role inputs
A generalized role only needs a handful of variables:
mem0_home: /opt/mem0 mem0_repo: [email protected]:your-org/your-mem0-project.git mem0_branch: main mem0_qdrant_collection: default mem0_neo4j_enabled: true mem0_embedder_base_url: https://api.example.com/v1 mem0_embedder_model: your-embedding-model mem0_embedder_dimensions: 1024The collection name matters more than it looks. Qdrant collections have a fixed vector size. If you switch embedding models and the dimensions change, create a new collection. Don’t try to reuse the old one. This is a common way people break a working Mem0 setup, so treat the collection name as part of the embedding config, not an afterthought.
Secrets
Keep API keys and database passwords out of your regular vars files. Ansible Vault, a secrets manager, whatever your deployment system gives you. Then template a
.envfile with tight permissions:- name: Write Mem0 environment file ansible.builtin.template: src: mem0.env.j2 dest: "{{ mem0_home }}/.env" mode: "0600" no_log: trueThat
no_log: trueis not optional. Template diffs will happily leak plaintext API keys and graph passwords into CI logs, terminal scrollback, and ticket attachments. Once a secret lands in a CI log, you’re rotating it, not deleting it.The template itself stays small:
EMBEDDER_API_KEY={{ vault_embedder_api_key }} EMBEDDER_MODEL={{ mem0_embedder_model }} EMBEDDER_DIMENSIONS={{ mem0_embedder_dimensions }} QDRANT_HOST=qdrant QDRANT_PORT=6333 QDRANT_COLLECTION={{ mem0_qdrant_collection }} NEO4J_URL=bolt://neo4j:7687 NEO4J_USERNAME=neo4j NEO4J_PASSWORD={{ vault_neo4j_password }}Notice
QDRANT_HOST=qdrant, not an IP. When the Mem0 container talks to sibling Compose services, use the service names. Save hostnames and LAN DNS for clients that live outside the Compose network. More on that in a second, because it’s a real gotcha.Compose shape
Just what you need to run Mem0’s memory backend in Docker.
services: qdrant: image: qdrant/qdrant:v1.12.6 ports: - "6333:6333" - "6334:6334" volumes: - qdrant_storage:/qdrant/storage neo4j: image: neo4j:5.26 environment: NEO4J_AUTH: "neo4j/${NEO4J_PASSWORD}" ports: - "7474:7474" - "7687:7687" volumes: - neo4j_data:/data - neo4j_logs:/logs volumes: qdrant_storage: neo4j_data: neo4j_logs:Pin your image versions. Floating tags make it impossible to tell whether a later failure came from your playbook, your app, or an upstream image that changed under you at 2am. Pinning turns a mystery into a diff.
The two Ansible flags that matter
Most of the role can stay boring. Ensure the directory exists, clone the repo, template the
.env, start the stack withcommunity.docker.docker_compose_v2. The two details I want you to actually read areforce: falseon the git task andremove_orphans: trueon the compose task.force: falseprotects local edits in the checkout. If the role needs to patch a generated file, make that explicit and safe instead of letting Git clobber the tree.remove_orphans: truekeeps Compose honest. Say you rip out an old local embedding service and switch to a managed embedding API. Without this, the old container just keeps running forever, quietly, and you’ll swear the new config isn’t taking effect.And for restarts, use handlers. Notify a restart handler when the checkout changes or when
.envchanges. Don’t bounce the stack on every single playbook run. The steady-state run should be quiet.Service names vs. hostnames
From a client running outside the Compose network, point Mem0 at the server’s reachable hostname:
QDRANT_HOST=mem0.example.test NEO4J_URL=bolt://mem0.example.test:7687From a container inside the same Compose project, use service names:
QDRANT_HOST=qdrant NEO4J_URL=bolt://neo4j:7687Get this backwards and you burn an afternoon on it…
Check your work
After the playbook runs, verify the pieces independently:
docker compose ps curl http://localhost:6333/healthz docker compose logs --tail=100 neo4jFor Neo4j, actually test Bolt from the network where your client lives. The browser port on
7474being reachable does not prove the Bolt endpoint on7687is usable. Different port, different assumption, don’t confuse a green browser page for a working client.The whole pattern
Strip away the YAML and it’s simple. Docker Compose owns Qdrant and Neo4j. Ansible owns the checkout, the
.env, and the Compose lifecycle. Vault owns the credentials. The client picks service names or external hostnames depending on where it runs.That gets you a repeatable Mem0 backend without turning your Ansible role into a second copy of the entire app. Pin your images, guard your secrets with
no_log, and never reuse a collection after the vector size changes. Do those three and the rest is boring, which is exactly what you want from infrastructure.I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].