Multi-Tenancy¶
A single Medha backend can serve multiple tenants, applications, or query languages simultaneously. Each collection name is a fully isolated namespace — different vector spaces, different entries, different thresholds.
This page covers four common patterns:
- Per-application isolation — multiple query languages on one backend
- Per-customer isolation — SaaS: one namespace per customer
- Shared embedder — one model in memory, many
Medhainstances - Concurrent fan-out — parallel searches across tenants with
asyncio.gather - Per-tenant stats — independent hit-rate monitoring per tenant
- Per-tenant backend selection — trial vs. paid tier isolation
Per-Application Isolation¶
Two applications share the same backend but use different collection names. Their caches are completely independent — a query cached for the SQL app is never returned by the Cypher app.
from medha import Medha, Settings
from medha.embeddings.fastembed_adapter import FastEmbedAdapter
shared_settings = Settings(backend_type="memory")
shared_embedder = FastEmbedAdapter()
# SQL analytics app
medha_sql = Medha(
collection_name="app_sql_analytics",
embedder=shared_embedder,
settings=shared_settings,
)
await medha_sql.start()
await medha_sql.store("How many users are there?", "SELECT COUNT(*) FROM users")
# Cypher graph explorer
medha_cypher = Medha(
collection_name="app_cypher_graph",
embedder=shared_embedder,
settings=shared_settings,
)
await medha_cypher.start()
await medha_cypher.store("How many Person nodes?", "MATCH (p:Person) RETURN COUNT(p)")
# Same question in each app returns app-specific results
hit_sql = await medha_sql.search("How many users are there?")
hit_cypher = await medha_cypher.search("How many users are there?")
print(hit_sql.generated_query) # SELECT COUNT(*) FROM users
print(hit_cypher.generated_query) # None — no match in the Cypher collection
Per-Customer Isolation¶
In a SaaS platform each customer has their own data schema and question vocabulary. Namespacing collection names with the customer ID keeps each tenant's cache completely isolated — even when they ask the same question.
TENANTS = {
"tenant_acme": {
"pairs": [
("How many users?", "SELECT COUNT(*) FROM acme_users"),
("Total revenue", "SELECT SUM(amount) FROM acme_invoices"),
],
"settings": Settings(backend_type="memory", score_threshold_semantic=0.82),
},
"tenant_globex": {
"pairs": [
("How many users?", "SELECT COUNT(*) FROM globex_accounts"), # same question, different table!
("Total revenue", "SELECT SUM(revenue) FROM globex_deals"),
],
"settings": Settings(backend_type="memory", score_threshold_semantic=0.87),
},
}
shared_embedder = FastEmbedAdapter()
instances: dict[str, Medha] = {}
for tenant_id, config in TENANTS.items():
m = Medha(
collection_name=tenant_id,
embedder=shared_embedder,
settings=config["settings"],
)
await m.start()
for question, sql in config["pairs"]:
await m.store(question, sql)
instances[tenant_id] = m
# Same question, different SQL per tenant
for tenant_id, m in instances.items():
hit = await m.search("How many users do we have?")
print(f"[{tenant_id}] {hit.generated_query}")
# [tenant_acme] SELECT COUNT(*) FROM acme_users
# [tenant_globex] SELECT COUNT(*) FROM globex_accounts
Shared Embedder¶
Loading an embedding model costs ~200 MB. FastEmbedAdapter is stateless and safe to share across multiple Medha instances — you pay the loading cost once regardless of tenant count.
N_TENANTS = 5
one_embedder = FastEmbedAdapter() # loaded once
tenants = []
for i in range(N_TENANTS):
m = Medha(
collection_name=f"tenant_{i:03d}",
embedder=one_embedder, # same object reused
settings=Settings(backend_type="memory"),
)
await m.start()
await m.store(
f"How many records in tenant {i}?",
f"SELECT COUNT(*) FROM tenant_{i:03d}_records",
)
tenants.append(m)
Concurrent Fan-Out¶
Because Medha is async-native, N concurrent tenant searches run in parallel and complete in roughly the time of a single search.
import asyncio
# Fan out searches across all tenants simultaneously
results = await asyncio.gather(*[
m.search(f"Revenue for customer group {i}")
for i, m in enumerate(tenants)
])
Per-Tenant Stats¶
Each Medha instance tracks its own stats independently. Use this to monitor per-tenant hit rates and identify tenants with low cache coverage.
for tenant_id, m in instances.items():
stats = await m.stats()
print(f"[{tenant_id}] hit_rate={stats.hit_rate:.0%} requests={stats.total_requests}")
See Observability for the full CacheStats API.
Per-Tenant Backend Selection¶
A common pattern is to use backend_type="memory" for trial tenants (zero infrastructure cost) and promote to a persistent backend on conversion.
# Trial tenant — pure-Python InMemoryBackend, no infrastructure needed
trial_settings = Settings(backend_type="memory")
medha_trial = Medha("trial_tenant_001", embedder=shared_embedder, settings=trial_settings)
# Paid tenant — persistent Qdrant backend
paid_settings = Settings(backend_type="qdrant", qdrant_mode="docker", qdrant_host="localhost")
medha_paid = Medha("paid_tenant_acme", embedder=shared_embedder, settings=paid_settings)
backend_type |
Extra deps | Use case |
|---|---|---|
"memory" |
none (default) | Ephemeral tenants, CI, trial accounts |
"qdrant" |
medha-archai[qdrant] |
Production — Docker or Cloud |
"pgvector" |
medha-archai[pgvector] |
Teams already running PostgreSQL |
"elasticsearch" |
medha-archai[elasticsearch] |
Teams on the Elastic stack |
"lancedb" |
medha-archai[lancedb] |
Serverless / embedded deployments |
Tenant Lifecycle¶
When a tenant is deactivated, close their Medha instance and drop their collection.
# Deactivate
await medha_tenant.close()
# Delete data (Qdrant docker/cloud)
from qdrant_client import QdrantClient
client = QdrantClient(url="http://localhost:6333")
client.delete_collection("tenant_trial_001")
With backend_type="memory" closing the instance already releases all data.
Full working example
The complete runnable notebook is available at
demo/09_multi_tenant.ipynb.