Claude Code has no memory between sessions by default. Every conversation starts cold — no awareness of how you like to work, what conventions your project follows, or what you discovered last week. The Mem0 / OpenMemory project exists to fix that. It runs an MCP server that Claude Code can call to store and retrieve memories across sessions.

Getting it working locally against a real embedding model took three rounds of debugging.

The stack

The setup is three services running in Docker:

Service Role
Qdrant Vector store — holds the memory embeddings
OpenMemory MCP mem0 API server + MCP endpoint for Claude Code
OpenMemory UI Next.js dashboard for browsing and managing memories
Ollama (host) Embedding model — nomic-embed-text-v2-moe
graph TD
    subgraph host["Host machine"]
        CC["Claude Code"]
        OL["Ollama\nnomic-embed-text-v2-moe"]
        BR["Browser\nport 8766"]
    end
    subgraph docker["Docker"]
        OM["OpenMemory MCP\nport 8765"]
        UI["OpenMemory UI\nport 8766"]
        QD["Qdrant\nport 6333"]
    end
    CC -->|"MCP over HTTP"| OM
    OM -->|"POST /api/embed"| OL
    OL -->|"768-dim vector"| OM
    OM -->|"store / search"| QD
    BR -->|"HTTP"| UI
    UI -->|"REST API"| OM

Qdrant, OpenMemory MCP, and the UI all run in containers. Ollama runs on the host and is reachable from the containers at host.docker.internal:11434. The UI is a Next.js app that talks to the MCP server’s REST API — useful for inspecting what Claude has stored, deleting stale memories, and verifying that a memory round-trip actually worked. The OpenMemory container is built from the mem0 monorepo, which means any fixes to the integration go into vendor-mounted files rather than the image itself.

Why build from source instead of the Docker Hub image? The published image on Docker Hub was 12 months old at the time of writing. The GitHub repository, by contrast, gets regular commits — the project is actively developed and the team is building a business on top of it. Building from the repo means you’re running current code, not a snapshot from last year. It also puts you one git pull away from any fix they ship. And practically speaking, when you find something worth improving — the three bugs in this post are good candidates — building from source is the starting point for a pull request. If you get to that point, fork the repo first; that gives you a branch to push to and makes opening the PR straightforward.

The embedding model is nomic-embed-text-v2-moe — a 305M parameter Mixture-of-Experts model trained on 1.6 billion contrastive pairs across ~100 languages. It scores ~63 on MTEB, runs on CPU, and produces 768-dimensional vectors. That last number matters, and it’s where the first bug lives.

Bug 1: the collection always initializes at 1536 dimensions

Adding the first memory failed immediately:

Vector dimension error: expected dim: 1536, got 768

The Qdrant collection was created with 1536 dimensions. The configured embedder produces 768. Something upstream ignored the embedder config and created the collection at the wrong size.

The culprit is in mem0’s QdrantConfig:

class QdrantConfig(BaseModel):
    embedding_model_dims: Optional[int] = Field(1536, description="...")

1536 is the default — the size of OpenAI’s text-embedding-3-small. OpenMemory’s get_memory_client() builds a config object that overrides the LLM and embedder from the database, but it never writes embedding_model_dims into the vector store config. So QdrantConfig always falls back to 1536, regardless of what embedder you configure.

The database config had the right information:

"embedder": {
  "provider": "ollama",
  "config": {
    "model": "nomic-embed-text-v2-moe",
    "embedding_dims": 768,
    "ollama_base_url": "http://host.docker.internal:11434"
  }
}

The fix is to propagate embedding_dims from the embedder config into the vector store config after the database config is loaded:

# After loading the embedder override from the database:
dims = config["embedder"].get("config", {}).get("embedding_dims")
if dims:
    config["vector_store"]["config"]["embedding_model_dims"] = dims
    print(f"Set vector store embedding_model_dims={dims} from embedder config")

This runs after the DB config is applied and before Memory.from_config() is called, so QdrantConfig gets the right value and creates the collection at 768 dimensions.

Bug 2: wrong keyword argument in the search path

With the collection dimension fixed, memory storage worked. Search didn’t:

Error searching memory: Qdrant.search() got an unexpected keyword argument 'limit'

OpenMemory’s mcp_server.py calls the vector store search directly:

hits = memory_client.vector_store.search(
    query=query,
    vectors=embeddings,
    limit=10,        # ← wrong
    filters=filters,
)

The mem0 Qdrant vector store’s search method signature is:

def search(self, query: str, vectors: list, top_k: int = 5, filters: dict = None) -> list:

The parameter is top_k, not limit. The fix is a one-word change: limit=10top_k=10.

Because mcp_server.py isn’t mounted as a volume by default, the file gets copied to vendor/openmemory_mcp_server.py and added to the compose volumes:

volumes:
  - ./vendor/openmemory_mcp_server.py:/usr/src/openmemory/app/mcp_server.py:ro

Bug 3: cold-start timeout on the first request

With both bugs fixed, the first add_memories call timed out. The container was healthy — the health check passed. But the MCP client gave up before the operation completed.

The cause was Ollama loading the embedding model on demand. The first call triggered a model load that took longer than the MCP client’s timeout. Subsequent calls were fast.

Two fixes together solve this permanently.

Warm up the model at container start. A Python script runs before uvicorn starts and makes a single embed request to Ollama:

# vendor/openmemory_warmup.py
import os, sys, json, time, urllib.request

model = os.environ.get("EMBEDDING_MODEL", "nomic-embed-text-v2-moe")
base_url = os.environ.get("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
url = f"{base_url}/api/embed"
payload = json.dumps({"model": model, "input": "warmup", "keep_alive": -1}).encode()

for attempt in range(1, 6):
    try:
        req = urllib.request.Request(
            url, data=payload, headers={"Content-Type": "application/json"}
        )
        with urllib.request.urlopen(req, timeout=60) as r:
            r.read()
        print(f"Embedding model ready: {model} (keep_alive=-1)")
        sys.exit(0)
    except Exception as e:
        print(f"Attempt {attempt}/5 failed: {e}", file=sys.stderr)
        if attempt < 5:
            time.sleep(2)

sys.exit(1)

The entrypoint script runs this before handing off to uvicorn:

#!/bin/sh
set -e
echo "Warming up embedding model: ${EMBEDDING_MODEL:-nomic-embed-text-v2-moe}"
python3 /usr/src/openmemory/warmup.py
exec uvicorn main:app --host 0.0.0.0 --port 8765 --workers 1

Pin the model with keep_alive: -1. The warm-up request includes "keep_alive": -1 in the request body. Ollama honors this per-request and keeps the model loaded until Ollama restarts. Without it, Ollama unloads the model after 5 minutes of inactivity and the cold-start problem returns the next morning.

The result: the container only becomes healthy after the model is loaded. The health check already verifies the API endpoint responds. With the entrypoint blocking on the warm-up, “healthy” now also means “won’t timeout on the first embed call.”

sequenceDiagram
    participant E as entrypoint.sh
    participant W as warmup.py
    participant O as Ollama
    participant U as uvicorn
    participant H as health check

    E->>W: python3 warmup.py
    W->>O: POST /api/embed (keep_alive=-1)
    Note over O: loads nomic-embed-text-v2-moe
    O-->>W: 200 OK
    W-->>E: exit 0
    E->>U: exec uvicorn
    U-->>H: 200 OK
    Note over H: container status: healthy

Embedding model choice

nomic-embed-text-v2-moe is the right default for a self-hosted setup. The alternatives worth knowing about:

Model MTEB Dims Context Notes
nomic-embed-text-v2-moe ~63 768 8192 MoE, ~100 languages, 305M params
bge-m3 ~63 1024 8192 Similar score, larger vectors
mxbai-embed-large ~64.7 1024 512 Slightly better, English-focused
qwen3-embedding:8B ~70.6 varies 8192 Best local option, requires significant RAM

bge-m3 matches on MTEB but uses 1024-dimensional vectors — more Qdrant storage, slower similarity search, for no accuracy gain. mxbai-embed-large is marginally better but English-only and has a 512-token context limit, which matters for long memories. qwen3-embedding:8B leads locally but is significantly larger.

nomic-embed-text-v2-moe threads the needle: 768 dimensions, 8192-token context, multilingual, CPU-capable, competitive MTEB.

The vendor pattern

All three fixes live in vendor/ as host-mounted files:

vendor/
  openmemory_memory_utils.py   # patched get_memory_client()
  openmemory_mcp_server.py     # patched search() call
  openmemory_entrypoint.sh     # warm-up + exec uvicorn
  openmemory_warmup.py         # model warm-up script

The compose volumes mount each patch over the corresponding file in the container:

volumes:
  - ./vendor/openmemory_memory_utils.py:/usr/src/openmemory/app/utils/memory.py:ro
  - ./vendor/openmemory_mcp_server.py:/usr/src/openmemory/app/mcp_server.py:ro
  - ./vendor/openmemory_entrypoint.sh:/usr/src/openmemory/entrypoint.sh:ro
  - ./vendor/openmemory_warmup.py:/usr/src/openmemory/warmup.py:ro

One operational note: docker compose restart does not re-apply volume mount changes. If you add a new mount to docker-compose.yml, you need docker compose up -d to recreate the container with the updated config. restart just stops and starts the existing container spec — the new volume never gets attached. This bit us when adding the mcp_server.py mount and is easy to miss since the container comes back healthy either way.

This approach keeps the fixes visible in the repository, co-located with an explanation of what they patch, and easy to drop when upstream fixes the underlying issues. The image itself is unchanged — rebuilding the image picks up the vendor patches automatically.

The three bugs are independent and each would have blocked a working integration on its own. Together they explain why “just run the docker-compose” doesn’t work out of the box when you’re using a local embedding model with non-default dimensions.

The full working setup — docker-compose.yml, all four vendor patches, and the startup scripts — is at github.com/sseely/claude-memory.