Building a Production MCP Memory Service: What We Learned
Rembr is a memory-as-a-service platform exposed over the Model Context Protocol. This is the engineering post: the decisions that weren't obvious, the things that broke, and what we'd do differently.
Why Postgres, Not a Dedicated Vector DB
The obvious choice for vector search is a dedicated vector database — Pinecone, Weaviate, Qdrant. We chose Postgres with pgvector. Here's why.
Agent memory isn't just vectors. It's structured data: tenant IDs, categories, timestamps, visibility flags, relationship types, PII markers. A dedicated vector DB forces you to maintain two systems — one for vector similarity, one for everything else — and joins across them at the application layer. That's slow and fragile.
Postgres lets you do this in a single query:
SELECT m.id, m.content, m.category,
1 - (m.embedding <=> $1::vector) AS similarity
FROM memories m
WHERE m.tenant_id = $2
AND m.category = ANY($3)
AND m.created_at >= $4
ORDER BY m.embedding <=> $1::vector
LIMIT 20;Vector similarity, tenant isolation, category filter, and date range — all in one query, all inside one transaction, with ACID guarantees. No cross-system join. No eventual consistency edge cases.
The trade-off is that pgvector's ANN performance tops out below the scale of a dedicated vector DB. For agent memory workloads (tens of thousands to low millions of vectors per tenant), it's more than sufficient. If you're indexing a 100M-document corpus, use a dedicated system. If you're storing AI agent memories, Postgres is the right call.
HNSW vs IVFFlat: The Indexing Decision
pgvector supports two index types: IVFFlat and HNSW.
IVFFlat clusters vectors into buckets and searches only the closest buckets. It's fast to build and has a small memory footprint — but recall degrades sharply as you add more vectors if you don't rebuild or tune the number of lists.
HNSW (Hierarchical Navigable Small World) builds a multi-layer graph structure. Build time is slower and memory usage is higher (roughly 8 bytes × m × vectors), but query performance is more consistent as the corpus grows, and it doesn't require periodic rebuilds.
We went with HNSW. The parameters that matter:
CREATE INDEX idx_memories_embedding ON memories
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);- m = 16 — edges per node. Higher m improves recall at the cost of index size. 16 is a good default for most workloads.
- ef_construction = 64 — search width during index build. Higher values improve index quality but slow build time. 64 is balanced.
Adaptive ef_search: The Tuning Problem Nobody Talks About
Here's the problem: ef_search controls how many candidates the HNSW graph explores during a query. Higher values = better recall, lower values = faster queries. The default is 64.
But the right value depends on corpus size. A tenant with 500 memories doesn't need the same ef_search as a tenant with 500,000. Setting a single global value means you're either over-searching small tenants (slow for no benefit) or under-searching large ones (poor recall).
Our solution: adaptive ef_search, set per-query using SET LOCAL:
-- Tier logic (inside the query transaction)
SET LOCAL hnsw.ef_search = CASE
WHEN memory_count < 10000 THEN 40
WHEN memory_count < 100000 THEN 64
WHEN memory_count < 500000 THEN 100
ELSE 128
END;
SELECT ... FROM memories ORDER BY embedding <=> $1 LIMIT 20;SET LOCAL is transaction-scoped — it resets automatically at commit/rollback. No risk of a high-value setting leaking to other queries. We implement this in a VectorSearchService that reads the tenant's memory count, selects the appropriate tier, and wraps the search in a transaction.
Multi-Tenancy: Row-Level Security, Not Application Filters
Every query that touches memory data could theoretically return another tenant's data if a WHERE tenant_id = $x clause is accidentally omitted. Application-layer filtering is a single point of failure.
We enforce isolation at the database layer with Postgres row-level security:
ALTER TABLE memories ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON memories
USING (tenant_id = current_setting('app.current_tenant')::uuid);The application sets app.current_tenant at the start of each connection. If a query somehow misses the tenant filter, RLS catches it. The security property holds even under application bugs.
One subtlety: RLS has a performance overhead. The policy predicate is evaluated for every row the query touches before filtering. With a compound index on(tenant_id, category, created_at), the planner pushes the tenant filter into the index scan and RLS overhead becomes negligible.
The MCP Interface
MCP (Model Context Protocol) defines a standard for AI models to call external tools. A Rembr MCP server exposes memory operations as tools — the model can call them the same way it would call a web search or a code interpreter.
The core tools:
// Store a memory
memory({ operation: "create", content: "...", category: "facts" })
// Retrieve relevant memories
search({ operation: "query", query: "user's preferred stack", limit: 10 })
// Find semantically similar memories
search({ operation: "similar", memoryId: "uuid", limit: 5 })
// Detect contradictions
contradictions({ operation: "detect" })The model decides when to call these tools based on its understanding of the task. You don't need to hardcode memory reads into your agent loop — the model learns to retrieve context when it needs it and store information when it's likely to be useful later.
Embedding Model Consistency: The Footgun
Here's a mistake that's easy to make and expensive to fix: switching embedding models after you have data.
Vector similarity only works if all vectors were generated by the same model. Mix embeddings from nomic-embed-text andtext-embedding-3-small in the same index and your similarity scores are meaningless — you're comparing vectors in different geometric spaces.
We enforce model consistency at the migration level: the embedding model name is stored in a schema metadata table. Any attempt to write an embedding generated by a different model is rejected. Migrations that change the model trigger a full re-embedding job.
What We'd Do Differently
- 1.Start with hybrid search from day one. We added BM25 full-text search later. Retrofitting it onto an existing schema is messier than designing for it upfront. Always include a tsvector column in your initial migrations.
- 2.Track embedding model per-row, not just globally. During a migration, you'll have a mix of old and new embeddings. A global model setting doesn't handle the transition period well. A per-row model column does.
- 3.Instrument vector search latency from the start. ef_search tuning is a data-driven exercise — you need latency percentiles and recall metrics to tune it. Add slow-query logging (>200ms) in v1 so you have a baseline when you need to optimise.
The Stack
- Database: PostgreSQL 15 + pgvector, pgBouncer for connection pooling
- Embeddings: Ollama (nomic-embed-text) for local; fallback to OpenAI text-embedding-3-small
- Server: Node.js, TypeScript, Fastify
- Protocol: MCP 2024-11-05 (Streamable HTTP transport)
- Infra: Kubernetes, ArgoCD, Prometheus + Grafana, Falco
- UI: Next.js 15, React, D3.js, react-force-graph-2d