Skip to content

Weaviate — la vector DB modulaire et la meilleure pour le SaaS multi-tenant

TL;DR Weaviate (Go, open-source) est le bon choix quand le multi-tenancy est central (un tenant = une "shard" Weaviate, isolation forte, archive à froid possible). Différenciateurs vs Qdrant : modules built-in (text2vec-openai, reranker-cohere, generative-openai, multi2vec-clip), GraphQL API, named vectors (titre + corps + résumé indexés séparément dans le même objet), hybrid BM25 + dense natif, Verba (UI open-source no-code pour RAG corporate). Weaviate Cloud dispo en EU. Pricing freelance : 6-12 jours pour mettre en place un SaaS multi-tenant prod-grade à 1300-1500€/j.

🧠 Mental model

Le triangle Qdrant / Weaviate / pgvector

                    Modularité / batteries-included

                              │   Weaviate
                              │   (modules, GraphQL,
                              │    named vectors, Verba)


                             ╱ ╲
                            ╱   ╲
              Qdrant       ╱     ╲       pgvector
              (perf       ●───────●       (intégration
               brute,     ╱       ╲        Postgres,
               filters)  ╱         ╲       RLS, simplicité)
                        ╱           ╲
                       ╱             ╲
              Perf brute               Intégration métier

Analogie : Weaviate c'est l'IKEA des vector DB. Tout est en kit, tout s'assemble, le mode "vectorizer intégré" te fait du RAG en 4 lignes. Qdrant c'est le plan de travail béton ciré (brutaliste, performant, à toi de t'occuper du reste). pgvector c'est l'étagère qui rentre dans ton placard existant.

Multi-tenancy native : le killer feature

   ┌─────────────────────────────────────────────────────┐
   │  Class "CV"                                         │
   │                                                     │
   │  tenant: client-alpha      tenant: client-beta      │
   │  ┌──────────────┐          ┌──────────────┐         │
   │  │ shard alpha  │          │ shard beta   │         │
   │  │ HNSW index   │          │ HNSW index   │         │
   │  │ data files   │          │ data files   │         │
   │  └──────────────┘          └──────────────┘         │
   │                                                     │
   │  → hot/cold/frozen par tenant (économies stockage)  │
   │  → désactiver un tenant = unload RAM en 1 appel API │
   │  → backup par tenant                                │
   └─────────────────────────────────────────────────────┘

C'est la différence qui rend Weaviate le choix par défaut pour un SaaS B2B avec 50+ tenants. Tu ne fais pas "filtre WHERE tenant_id = X". Tu fais "query la shard du tenant X" — c'est physiquement isolé.

🛠️ Code minimal

python
# pip install weaviate-client>=4.10
import weaviate
from weaviate.classes.config import (
    Configure, Property, DataType, Tokenization, Multi2VecField,
)
from weaviate.classes.tenants import Tenant
from weaviate.classes.query import Filter, MetadataQuery

client = weaviate.connect_to_weaviate_cloud(
    cluster_url="https://xxx.weaviate.network",
    auth_credentials=weaviate.auth.AuthApiKey("..."),
    headers={"X-OpenAI-Api-Key": "sk-..."},
)

# Schéma : multi-tenant + named vectors + modules
collection = client.collections.create(
    name="CV",
    multi_tenancy_config=Configure.multi_tenancy(
        enabled=True,
        auto_tenant_creation=True,
        auto_tenant_activation=True,
    ),
    vectorizer_config=[
        Configure.NamedVectors.text2vec_openai(
            name="title_vec",
            source_properties=["title"],
            model="text-embedding-3-small",
        ),
        Configure.NamedVectors.text2vec_openai(
            name="body_vec",
            source_properties=["body"],
            model="text-embedding-3-small",
        ),
        Configure.NamedVectors.text2vec_openai(
            name="summary_vec",
            source_properties=["summary"],
            model="text-embedding-3-large",
        ),
    ],
    reranker_config=Configure.Reranker.cohere(model="rerank-multilingual-v3.0"),
    generative_config=Configure.Generative.openai(model="gpt-4o-mini"),
    properties=[
        Property(name="title", data_type=DataType.TEXT),
        Property(name="body", data_type=DataType.TEXT),
        Property(name="summary", data_type=DataType.TEXT),
        Property(name="years_xp", data_type=DataType.INT),
        Property(name="skills", data_type=DataType.TEXT_ARRAY,
                 tokenization=Tokenization.LOWERCASE),
    ],
)

# Création d'un tenant
collection.tenants.create([Tenant(name="client-alpha")])

# Insert dans le tenant
collection.with_tenant("client-alpha").data.insert({
    "title": "Lead Data Scientist Paris",
    "body": "10 ans XP, expert NLP, ex-Criteo...",
    "summary": "Senior NLP, leadership 6+ pers, freelance dispo",
    "years_xp": 10,
    "skills": ["python", "pytorch", "rag", "llm"],
})

# Hybrid search sur body_vec, dans le tenant
res = collection.with_tenant("client-alpha").query.hybrid(
    query="ingénieur senior LLM avec management d'équipe",
    target_vector="body_vec",
    alpha=0.6,                               # 0=BM25 only, 1=vector only
    filters=Filter.by_property("years_xp").greater_than(5),
    limit=10,
    rerank=weaviate.classes.query.Rerank(prop="body", query="management LLM"),
    return_metadata=MetadataQuery(score=True, explain_score=True),
)
for o in res.objects:
    print(o.properties["title"], o.metadata.score)

🎬 Cas d'usage concrets

Cas 1 — SaaS RH multi-tenant (le cas modèle Weaviate)

Contexte : éditeur ATS français, 120 cabinets de recrutement clients. Chaque client a entre 500 et 50 000 CV. Forte demande d'étanchéité prouvable (RGPD, secret commercial). Pinecone testé : impossible à isoler proprement par namespace à ce volume. pgvector testé : RLS Postgres ok mais perf qui dérive au-delà de 30M vecteurs cumulés.

Décision : Weaviate Cloud EU avec multi-tenancy. Un tenant = un client. Auto-activation/désactivation : les clients inactifs depuis 30j passent en mode "cold" (offload disque, frais RAM ~0). Reranker Cohere multilingual sur la shortlist.

Résultat : 120 tenants, ~5M CV cumulés. p95 search = 60ms. Coût RAM divisé par 4 grâce au cold tiering (~50% des tenants en cold à un instant T). Audit RGPD : 1 réunion validée car isolation physique.

TJM facturé : 1400€/j × 11 jours = 15 400€ HT (audit + migration + setup tenants tooling).

Cas 2 — Intranet PME 300 personnes, RAG corporate avec Verba

Contexte : ETI industrielle 300 personnes, DSI souhaite un assistant interne sur la doc qualité (ISO 9001, procédures, contrats fournisseurs). Pas d'équipe ML, pas envie de "construire un RAG". Demande : "quelque chose qui marche en 4 semaines".

Décision : Weaviate self-hosted sur leur Kubernetes interne + Verba (UI open-source RAG de Weaviate). Auth SSO d'entreprise. Modules text2vec-openai (avec keys gérées DSI). Ingestion Sharepoint via Verba.

Résultat : prod en 3 semaines. 8 000 docs ingérés. Utilisation : 60 employés actifs/jour. Coût : ~150€/mois infra + ~80€/mois OpenAI.

TJM facturé : 1300€/j × 7 jours (setup + formation) = 9 100€ HT.

Cas 3 — KG juridique avec named vectors (titre + corps + résumé)

Contexte : Legaltech FR qui construit un graphe de connaissances jurisprudentiel. Chaque décision a un titre (très dense en infos clé : juridiction, date), un corps long (jugement complet), un résumé rédigé (par avocat). Problème : embedder le tout dans un seul vecteur "moyenne" perd la spécificité.

Décision : Weaviate avec named vectorstitle_vec, body_vec, summary_vec indexés séparément sur le même objet. La requête peut cibler le bon vecteur selon le contexte ("résumé court" → summary_vec, "recherche d'extrait" → body_vec, "trouve la jurisprudence X de la chambre Y" → title_vec).

Résultat : nDCG@10 +22% vs vecteur unique. Latence stable car les 3 indexes sont sur le même objet, requête simple.

🛠️ Exemple end-to-end

Contexte : SaaS RH français, ATS multi-tenant. Chaque client = un tenant Weaviate. Code complet : provisioning tenant, ingestion CV, search avec rerank, désactivation cold.

python
# infra/weaviate_schema.py
import weaviate
from weaviate.classes.config import (
    Configure, Property, DataType, Tokenization, VectorDistances,
)
import os

def get_client():
    return weaviate.connect_to_weaviate_cloud(
        cluster_url=os.environ["WCS_URL"],
        auth_credentials=weaviate.auth.AuthApiKey(os.environ["WCS_KEY"]),
        headers={
            "X-OpenAI-Api-Key": os.environ["OPENAI_API_KEY"],
            "X-Cohere-Api-Key": os.environ["COHERE_API_KEY"],
        },
    )


def create_schema(client):
    client.collections.create(
        name="CV",
        multi_tenancy_config=Configure.multi_tenancy(
            enabled=True,
            auto_tenant_creation=False,           # contrôle explicite côté SaaS
            auto_tenant_activation=True,
        ),
        vectorizer_config=[
            Configure.NamedVectors.text2vec_openai(
                name="title_vec",
                source_properties=["title", "current_company"],
                model="text-embedding-3-small",
                vector_index_config=Configure.VectorIndex.hnsw(
                    distance_metric=VectorDistances.COSINE,
                    ef_construction=128, max_connections=32,
                ),
            ),
            Configure.NamedVectors.text2vec_openai(
                name="body_vec",
                source_properties=["experience_text", "skills_text"],
                model="text-embedding-3-small",
            ),
            Configure.NamedVectors.text2vec_openai(
                name="summary_vec",
                source_properties=["recruiter_notes"],
                model="text-embedding-3-large",
            ),
        ],
        reranker_config=Configure.Reranker.cohere(model="rerank-multilingual-v3.0"),
        properties=[
            Property(name="candidate_id", data_type=DataType.UUID),
            Property(name="title", data_type=DataType.TEXT),
            Property(name="current_company", data_type=DataType.TEXT),
            Property(name="experience_text", data_type=DataType.TEXT),
            Property(name="skills_text", data_type=DataType.TEXT,
                     tokenization=Tokenization.LOWERCASE),
            Property(name="recruiter_notes", data_type=DataType.TEXT),
            Property(name="years_xp", data_type=DataType.INT),
            Property(name="location", data_type=DataType.TEXT),
            Property(name="open_to_remote", data_type=DataType.BOOL),
            Property(name="last_active", data_type=DataType.DATE),
        ],
    )
python
# saas/tenants.py
from weaviate.classes.tenants import Tenant, TenantActivityStatus

def provision_tenant(client, client_id: str):
    coll = client.collections.get("CV")
    coll.tenants.create([Tenant(name=client_id)])

def freeze_inactive_tenants(client, inactive_client_ids: list[str]):
    """Passer en COLD (offload disque) les tenants inactifs > 30j."""
    coll = client.collections.get("CV")
    coll.tenants.update([
        Tenant(name=cid, activity_status=TenantActivityStatus.OFFLOADED)
        for cid in inactive_client_ids
    ])

def reactivate_tenant(client, client_id: str):
    """Auto-activé sur première requête grâce à auto_tenant_activation=True."""
    coll = client.collections.get("CV")
    coll.tenants.update([
        Tenant(name=client_id, activity_status=TenantActivityStatus.ACTIVE)
    ])
python
# saas/ingest.py
import uuid
from datetime import datetime, timezone

def ingest_cv(client, tenant_id: str, cv: dict) -> str:
    coll = client.collections.get("CV").with_tenant(tenant_id)
    obj_id = str(uuid.uuid4())
    coll.data.insert(
        properties={
            "candidate_id": cv["candidate_id"],
            "title": cv["title"],
            "current_company": cv.get("current_company") or "",
            "experience_text": cv["experience_text"],
            "skills_text": " ".join(cv.get("skills", [])),
            "recruiter_notes": cv.get("recruiter_notes") or "",
            "years_xp": cv.get("years_xp", 0),
            "location": cv.get("location") or "",
            "open_to_remote": bool(cv.get("open_to_remote")),
            "last_active": datetime.now(timezone.utc).isoformat(),
        },
        uuid=obj_id,
    )
    return obj_id


def batch_ingest_cvs(client, tenant_id: str, cvs: list[dict]):
    coll = client.collections.get("CV").with_tenant(tenant_id)
    with coll.batch.dynamic() as batch:
        for cv in cvs:
            batch.add_object(properties=_to_props(cv), uuid=str(uuid.uuid4()))
python
# saas/search.py
from weaviate.classes.query import Filter, MetadataQuery, Rerank

def search_cvs(
    client,
    tenant_id: str,
    query: str,
    target: str = "body_vec",
    min_xp: int = 0,
    remote_only: bool = False,
    location_contains: str | None = None,
    k: int = 20,
    rerank: bool = True,
):
    coll = client.collections.get("CV").with_tenant(tenant_id)
    filters = [Filter.by_property("years_xp").greater_or_equal(min_xp)]
    if remote_only:
        filters.append(Filter.by_property("open_to_remote").equal(True))
    if location_contains:
        filters.append(Filter.by_property("location").like(f"*{location_contains}*"))

    res = coll.query.hybrid(
        query=query,
        target_vector=target,
        alpha=0.65,
        filters=Filter.all_of(filters),
        limit=k,
        rerank=Rerank(prop="experience_text", query=query) if rerank else None,
        return_metadata=MetadataQuery(score=True, distance=True),
    )
    return [
        {
            "id": str(o.uuid),
            "candidate_id": o.properties["candidate_id"],
            "title": o.properties["title"],
            "company": o.properties["current_company"],
            "years_xp": o.properties["years_xp"],
            "score": o.metadata.score,
        }
        for o in res.objects
    ]
python
# saas/jobs/cold_tiering.py
import datetime as dt
from infra.weaviate_schema import get_client
from saas.tenants import freeze_inactive_tenants

def daily_cold_tiering(activity_threshold_days: int = 30):
    """Nuit : passe en COLD tous les tenants inactifs."""
    client = get_client()
    inactive = _list_inactive_tenants(activity_threshold_days)
    freeze_inactive_tenants(client, inactive)
    print(f"[cold-tiering] {len(inactive)} tenants offloaded")

Numbers prod (120 tenants, 5M CV cumulés, Weaviate Cloud EU Standard) :

  • p50 search hybrid + rerank : 70ms ; p95 : 140ms
  • RAM utilisée : ~40% des tenants actifs en RAM, 60% offloaded
  • Coût mensuel WCS : ~1 400€ (vs ~3 200€ projetés sans cold tiering)
  • Reranker Cohere : ~120€/mois (≈ 2M requêtes)

🎯 Patterns courants

  1. Un tenant = un client SaaS. Toujours. Ne pas mutualiser via filtre.
  2. auto_tenant_activation=True pour des UX seamless, auto_tenant_creation=False pour garder le contrôle métier.
  3. Cold tiering (OFFLOADED) pour les tenants peu actifs. Économie RAM massive.
  4. Named vectors > vecteur unique moyenne dès que tu as plusieurs champs sémantiquement distincts.
  5. Hybrid alpha=0.5-0.7 comme défaut, puis tuning par eval set.
  6. Reranker en bout de chaîne (Cohere ou Voyage) pour squeezer 15-25% de nDCG en plus.
  7. Verba pour les POC : tu démontres un RAG en 1h, le client comprend ce qu'il achète.
  8. Backup par tenant via weaviate-backup (S3, GCS, Azure) — RGPD : si un client demande la suppression, c'est un seul appel API.

🔄 Versions & écosystème 2026

ComposantVersion 2026Notes
Weaviate Core1.28+Multi-tenancy GA stable, OFFLOADED tier, async replication
Client Python4.10+API v4, type-safe, async support
Weaviate Cloud ServicesEU (Frankfurt, Amsterdam)Free Sandbox 14j
Verba2.xUI RAG no-code, Sharepoint/Confluence/Drive connectors
Modulestext2vec-, multi2vec-clip, reranker-cohere/voyage/jinaai, generative-LLM-agnostic
Embeddings hosted WeaviateSnowflake Arctic, BGEInférence dans le cluster (souveraineté)

Nouveautés 2026 :

  • OFFLOADED tier maturé (cold storage S3)
  • Named vectors avec dimensions différentes officiellement supporté
  • Replication async cross-region (DR plan EU multi-AZ)

⚠️ Pitfalls

  1. Oublier with_tenant() sur une class multi-tenant → erreur runtime ou (pire) accès cross-tenant en mode dev. Toujours tester avec 2 tenants.
  2. Mettre 5 000 tenants avec 100 vecteurs chacun en mode "tous actifs" → RAM explosée. Activer OFFLOADED pour les petits/inactifs.
  3. Modules text2vec sans gestion de quota OpenAI → 429 en cascade pendant l'ingestion. Mettre rate-limit côté ingestion.
  4. Named vectors avec dimensions différentes mais même source_properties → confusion d'index. Source distincte par named vector.
  5. Reranker activé par défaut sur toutes les requêtes → latence × 2-3 et coût Cohere multiplié. Activer uniquement quand utile.
  6. Filtres like("*X*") sur grandes shards sans tokenization adaptée → scan complet. Préférer equal ou containsAny.
  7. Pas de backup planifié → 0 récupération en cas de cluster mort. Configurer module backup-s3 avec snapshots quotidiens.
  8. Schema migrations brutales → Weaviate ne supporte pas tous les changements (ex : changer la dim d'un named vector). Re-créer la class et migrer.
  9. GraphQL response trop grosse (limit=1000 sur full payload) → slow. Toujours return_properties=[...] pour limiter le payload.
  10. Confusion hybrid alpha valeur : 0=BM25 only, 1=vector only (et pas l'inverse). Vérifier la doc.

🩺 Observabilité prod

Weaviate expose Prometheus sur /metrics :

weaviate_objects_count{class="CV", tenant="..."}            # taille par tenant
weaviate_queries_durations_ms{class, query_type="hybrid"}   # latence
weaviate_queries_count{class, status="ok|error"}            # throughput / erreurs
weaviate_vector_index_operations_count{operation="search|upsert"}
weaviate_active_tenants                                     # tenants chauds
weaviate_offloaded_tenants                                  # tenants offloaded

Alertes typiques :

  • p95 latence > 200ms (10 min) → investiguer (rerank ? trop de tenants actifs ?)
  • error_rate > 1% → page on-call
  • active_tenants > 80% capacité RAM cluster → planifier scale-up

💰 Pricing / ROI client

Weaviate Cloud Services EU

PlanSpecsPrix mensuel
Sandbox (14j gratuit)1 noeud0 €
Standard (1-10M obj)4 vCPU, 16 GB RAM, replication~400-700 €
Pro Cluster (10-50M obj)16 vCPU, 64 GB RAM, HA~1 600-2 800 €
Enterprise dédiésur mesuresur devis

Self-hosted (Kubernetes / Scaleway Kapsule)

  • ~250€/mois pour un cluster 3 nodes 8 GB (POC / petit prod)
  • ~1 000€/mois pour 50M objets avec OFFLOADED tier S3

Pricing freelance type

MissionJoursTJMPrix HT
POC Weaviate + Verba pour client PME51300€6 500€
Setup SaaS multi-tenant prod-grade101400€14 000€
Migration vector DB → Weaviate avec named vectors81500€12 000€
Conseil archi multi-tenant + cold tiering41500€6 000€

ROI client SaaS RH (cas 1) : passage d'un coût Pinecone projeté 3 200€/mois → 1 400€/mois = -1 800€/mois × 12 = 21 600€/an d'économies, mission rentabilisée en 8-9 mois.

🧪 Testing / Eval

python
# tests/test_weaviate_multitenant.py
import pytest
from infra.weaviate_schema import get_client
from saas.tenants import provision_tenant
from saas.ingest import ingest_cv
from saas.search import search_cvs

@pytest.fixture(scope="module")
def two_tenants():
    client = get_client()
    provision_tenant(client, "tenant-A")
    provision_tenant(client, "tenant-B")
    ingest_cv(client, "tenant-A", {"candidate_id": "A1", "title": "Alpha NLP",
                                    "experience_text": "expert pytorch", "years_xp": 8})
    ingest_cv(client, "tenant-B", {"candidate_id": "B1", "title": "Beta backend",
                                    "experience_text": "go kubernetes", "years_xp": 6})
    yield client


def test_tenant_isolation(two_tenants):
    """A ne doit JAMAIS voir le CV de B et inversement."""
    a_hits = search_cvs(two_tenants, "tenant-A", "kubernetes go", k=10)
    assert all(h["candidate_id"] != "B1" for h in a_hits)

    b_hits = search_cvs(two_tenants, "tenant-B", "expert pytorch", k=10)
    assert all(h["candidate_id"] != "A1" for h in b_hits)


def test_named_vector_targets(two_tenants):
    body = search_cvs(two_tenants, "tenant-A", "pytorch", target="body_vec", k=5)
    title = search_cvs(two_tenants, "tenant-A", "NLP", target="title_vec", k=5)
    assert body and title

À mesurer en continu :

  • Isolation tenant (tests fuzz cross-tenant)
  • nDCG@10 par named vector
  • RAM par tenant (Prometheus metrics Weaviate)
  • Taux d'OFFLOADED / ACTIVE
  • Latence rerank vs no-rerank

📋 Runbook prod Weaviate

markdown
# Runbook Weaviate — SaaS RH multi-tenant

## Configuration cluster
- 3 nodes, 16GB RAM chacun
- Replication factor : 2
- Modules : text2vec-openai, reranker-cohere
- Storage : 500GB BSSD Scaleway
- Backup : S3 quotidien (Scaleway Object Storage Paris)

## Procédures

### Provisioning nouveau tenant
1. Appel `provision_tenant(client_id)` via API SaaS
2. Tenant créé HOT, prêt à recevoir des CV
3. Quota check : si > 80% capacité cluster, scale-up requis

### Freeze tenant inactif (cron quotidien)
1. Liste tenants sans activité > 30j (table SaaS interne)
2. Batch update OFFLOADED
3. Confirmer dans metrics `weaviate_offloaded_tenants` 

### Wake-up tenant (auto)
- Avec `auto_tenant_activation=True`, première requête réactive auto
- Latence première requête : ~2-5s (load shard depuis S3)
- Requêtes suivantes : normales

### Migration schéma (changer named vectors)
1. Créer class `CV_v2` avec nouveau schéma
2. Job de copy par tenant + re-embed
3. Dual-write applicatif pendant la transition
4. Bascule lectures après tenants 100% migrés
5. Drop `CV_v1`

### Recovery node down
1. Replicas servent automatiquement
2. Provisionner replacement Kubernetes
3. Weaviate rejoint cluster automatiquement, sync depuis replicas
4. Si data corrompue : restore snapshot S3

## Contacts
- Weaviate Cloud support : [email protected]
- Freelance : ...

🔁 Quand utiliser / éviter

Utiliser Weaviate quand :

  • SaaS B2B multi-tenant (la killer feature)
  • Plusieurs aspects sémantiques distincts par objet (named vectors)
  • Besoin d'un RAG corporate clé en main (Verba)
  • Stack hétérogène à intégrer (modules text2vec-openai, reranker-cohere, generative-openai)
  • GraphQL API préféré (front team adore)
  • Souveraineté EU (WCS Frankfurt, ou self-hosted Kubernetes interne)

Éviter / préférer Qdrant ou pgvector quand :

  • Mono-tenant ou tenants peu nombreux → pgvector suffit
  • Performance brute > flexibilité modulaire → Qdrant
  • Équipe sans expérience GraphQL (REST plus simple → Qdrant)
  • Volume ultra-massif avec filtering numérique complexe → Qdrant pre-filter cardinality-aware reste devant

🧰 Annexes — modules, generative, déploiement

Modules Weaviate utiles (2026)

ModuleRôleUse case
text2vec-openaiembedding texte hosté OpenAIDefault ergonomique
text2vec-cohereembedding texte hosté CohereMulti-langue solide
text2vec-jinaaiembedding texte JinaEmbeddings longs (8K tokens)
text2vec-transformersembedding self-hosted (BGE, E5, Snowflake Arctic)Souveraineté FR
multi2vec-clipembedding multi-modal texte+imageE-commerce mode
reranker-coherererank top-K+10-25% nDCG
reranker-voyagererank top-K (Voyage AI)Alternative Cohere
generative-openaigénération sur résultatsRAG end-to-end natif
generative-anthropicgénération Claude (claude-opus-4-8 flagship, claude-sonnet-4-6 milieu de gamme, claude-haiku-4-5 économique)Default 2026 pour qualité de réponse FR — voir caveat ci-dessous
qna-openaiquestion-answeringVerba uses this

⚠️ Caveat module generative-* (à savoir pour un senior) : le generative-anthropic intégré au cluster est pratique pour un POC, mais il te dépossède de la couche LLM. Tu ne contrôles pas le system prompt finement, tu ne fais pas de prompt caching sur le préfixe stable, tu n'as pas de typed exceptions / retries / streaming, et tu paies le LLM "à l'aveugle" sans logguer usage par requête. En prod, le pattern senior est : Weaviate retrieve only (hybrid + rerank), puis génération hors cluster avec l'SDK Anthropic (AsyncAnthropic, client.messages.create(...), prompt caching sur les chunks + system, output_config={"effort": ...}, logging du coût). Tu gardes le module generative pour la démo client, tu le retires pour la prod.

Generative search (RAG en 1 appel API)

python
res = collection.with_tenant("client-A").generate.hybrid(
    query="comment résilier un contrat de travail CDI ?",
    target_vector="body_vec",
    limit=5,
    grouped_task=(
        "Tu es assistant juridique. Réponds en français en citant les sources. "
        "Réponse :"
    ),
)
print(res.generative.text)
for o in res.objects:
    print("source :", o.properties["title"])

C'est ce qui fait la rapidité de prototypage Weaviate. Tu ne câbles pas LangChain pour faire un RAG. La class connaît son vectorizer, son reranker, son générateur. 3 lignes.

Pattern prod : Weaviate retrieve-only + génération Claude hors cluster

Pour la prod, on sépare. Weaviate fait ce qu'il fait de mieux (retrieve hybrid + rerank), et la génération passe par l'SDK Anthropic, où tu contrôles le coût, le cache et la latence.

python
# rag/generate.py
import os
from anthropic import AsyncAnthropic
from anthropic import APIStatusError, RateLimitError, OverloadedError, APITimeoutError

# AsyncAnthropic pour un serveur (FastAPI/NestJS-via-sidecar). max_retries gère
# 429/5xx avec backoff exponentiel ; typed exceptions pour le reste.
anthropic = AsyncAnthropic(max_retries=4, timeout=30.0)

SYSTEM = (
    "Tu es un assistant juridique français. Réponds en français, cite les sources "
    "par leur titre, et n'invente jamais une jurisprudence absente du contexte."
)

async def answer(question: str, chunks: list[dict]) -> dict:
    # chunks = résultats Weaviate (déjà hybrid + rerank, top-k réduit à ~5).
    context = "\n\n".join(
        f"[source: {c['title']}]\n{c['body']}" for c in chunks
    )
    try:
        resp = await anthropic.messages.create(
            model="claude-opus-4-8",                 # flagship 2026
            max_tokens=1024,
            system=[
                {
                    "type": "text",
                    "text": SYSTEM,
                    # Cache du préfixe STABLE (system) : ~0.1x en lecture sur les
                    # requêtes suivantes. Le system ne change pas → préfixe stable.
                    "cache_control": {"type": "ephemeral"},
                },
            ],
            thinking={"type": "adaptive"},           # 4.8 : adaptive only, pas de budget_tokens
            output_config={"effort": "medium"},      # low|medium|high|max — medium = bon ratio coût/qualité
            messages=[
                {
                    "role": "user",
                    "content": (
                        f"Contexte :\n{context}\n\n"
                        f"Question : {question}\n\n"
                        "Réponds en citant les sources."
                    ),
                },
            ],
        )
    except RateLimitError:
        # 429 : le SDK a déjà retenté max_retries fois → on dégrade (file d'attente / Haiku).
        raise
    except OverloadedError:
        # 529 : surcharge côté Anthropic → fallback modèle moins chargé possible.
        raise
    except APITimeoutError:
        raise
    except APIStatusError as e:
        # Classification fine via e.status / e.type plutôt que du string-matching.
        raise

    # TOUJOURS logguer usage pour le coût (input/output + cache).
    u = resp.usage
    cost_in = (u.input_tokens / 1e6) * 5.0           # $5 / 1M tok input (Opus 4.8, 1M ctx)
    cost_out = (u.output_tokens / 1e6) * 25.0        # $25 / 1M tok output
    return {
        "text": resp.content[0].text,
        "usage": {
            "input": u.input_tokens,
            "output": u.output_tokens,
            "cache_read": u.cache_read_input_tokens,
            "cost_usd": round(cost_in + cost_out, 6),
        },
    }

Pourquoi ce découpage gagne en prod :

  • Coût observable : tu logs usage par requête. Sans ça, le module generative te facture en boîte noire et tu découvres la facture en fin de mois.
  • Prompt caching : le system + les chunks récurrents (procédures ISO réutilisées sur 100 requêtes/jour) passent en cache → lecture à ~0.1x. Sur un RAG corporate, c'est -60 à -80 % du coût input.
  • Modèle choisi par route : claude-haiku-4-5 (1 $ / 5 $ /Mtok) pour le Q&A simple sur la doc interne, claude-opus-4-8 pour la synthèse juridique sensible. Le module Weaviate te fige un seul modèle pour toute la class.
  • Streaming : pour une réponse longue (synthèse de 5 décisions), tu streames côté SDK pour éviter le timeout HTTP et donner du time-to-first-token à l'UI. Le module generative ne te le donne pas proprement.

Quand garder le module generative-anthropic intégré : POC, démo client (Verba), prototypage en 3 lignes. Tu démontres la valeur, puis tu sors la génération du cluster pour la prod.

Déploiement Kubernetes (Helm chart officiel)

yaml
# values.yaml (extrait)
image:
  repo: semitechnologies/weaviate
  tag: 1.28.0

replicas: 3

env:
  CLUSTER_HOSTNAME: weaviate-{{ .Release.Name }}
  PERSISTENCE_DATA_PATH: /var/lib/weaviate
  DEFAULT_VECTORIZER_MODULE: text2vec-openai
  ENABLE_MODULES: text2vec-openai,reranker-cohere,generative-anthropic
  AUTOSCHEMA_ENABLED: "false"

storage:
  size: 500Gi
  storageClassName: scw-bssd

backups:
  s3:
    enabled: true
    envconfig:
      BACKUP_S3_BUCKET: weaviate-backups-fr
      BACKUP_S3_ENDPOINT: s3.fr-par.scw.cloud
bash
helm repo add weaviate https://weaviate.github.io/weaviate-helm
helm install weaviate weaviate/weaviate -f values.yaml -n weaviate --create-namespace

Plan DR : snapshots S3 quotidiens, restore testé mensuellement.

Migration depuis Pinecone

Pattern identique au document 04-pinecone-self-hosted.md : dual-write applicatif, backfill par batch, shadow reads, cutover via feature flag. Spécificité Weaviate : les named vectors ne sont pas équivalents à plusieurs namespaces Pinecone, ils sont dans le même objet. Donc la migration repense l'archi data, ce n'est pas un simple copy.

🏋️ Exercices

Progressifs, durs, orientés "fais-le tourner puis prouve le chiffre / casse-le puis répare-le". Tu as besoin d'un cluster Weaviate (Sandbox WCS gratuit 14j, ou docker run local) et d'une clé Anthropic.

Exo 1 — Multi-tenant : prouve l'isolation, pas juste l'implémente

Objectif : créer une class CV multi-tenant avec 3 tenants, ingérer 1 000 CV par tenant, et écrire un test qui prouve qu'aucune requête tenant-A ne peut voir un objet tenant-B — y compris via un objet volontairement piégé (même UUID dans deux tenants).

Indice/Solution : with_tenant() sur chaque appel (insert + query). Le test piège : insère le même contenu textuel ultra-discriminant ("XJ9-CANARY-TOKEN") dans tenant-A uniquement, puis fais une hybrid search de ce token depuis tenant-B et tenant-C → 0 résultat attendu. Bonus : oublie volontairement with_tenant() une fois et observe l'erreur (ou pire, l'absence d'erreur en mode dev) — c'est le pitfall n°1.

Exo 2 — Named vectors : défends le +22 % nDCG

Objectif : sur un jeu de 500 décisions (title / body / summary), construire un eval set de 50 requêtes avec ground-truth, mesurer le nDCG@10 d'un vecteur unique moyenné vs 3 named vectors ciblés, et défendre (ou réfuter) le gain annoncé dans le doc.

Indice/Solution : embeddings séparés par champ, requêtes qui ciblent title_vec pour "trouve l'arrêt X de la chambre Y", body_vec pour "recherche d'extrait". Calcule nDCG@10 avec sklearn.metrics.ndcg_score. Si tu n'obtiens pas +22 %, c'est probablement que ton eval set ne contient pas de requêtes "spécifiques au champ" — le gain des named vectors vient EXACTEMENT de ces requêtes-là. Conclusion senior : le chiffre dépend de la distribution des requêtes, pas du tuning.

Exo 3 — RAG retrieve-only + génération Claude, avec coût loggé

Objectif : câbler le pipeline hybrid + rerank Weaviate → génération claude-opus-4-8, et produire un tableau requête → tokens in/out → cache_read → coût $. Faire passer 100 requêtes dont 60 partagent le même contexte (procédures ISO récurrentes).

Indice/Solution : cache_control: {"type": "ephemeral"} sur le system + sur les chunks stables. Vérifie usage.cache_read_input_tokens > 0 à partir de la 2e requête identique. Si le cache lit 0, tu as un invalidateur silencieux (un datetime.now() dans le system, un ordre de chunks non déterministe). Cible : -60 % de coût input sur les 60 requêtes à contexte partagé. Défends le chiffre avec le log usage.

Exo 4 — Cold tiering : casse la RAM, puis répare

Objectif : provisionner 50 tenants "petits" (100 vecteurs chacun) tous ACTIVE, mesurer la RAM cluster, faire exploser le budget, puis implémenter le job daily_cold_tiering qui passe en OFFLOADED les inactifs et mesurer la latence de réveil (première requête sur un tenant froid).

Indice/Solution : metrics Prometheus weaviate_active_tenants / weaviate_offloaded_tenants. Le réveil S3 coûte ~2-5s sur la première requête (auto_tenant_activation=True). Question piège senior : un tenant qui reçoit 1 requête/heure doit-il être OFFLOADED ? Réponse : non — le réveil à 2-5s × 24/jour dégrade l'UX plus que le coût RAM économisé. Le seuil de cold tiering n'est pas "inactif 30j", c'est "coût RAM > coût réveil × fréquence d'accès". Défends ton seuil avec un calcul.

Exo 5 — Migration de schéma sans downtime (production-grade)

Objectif : tu dois changer la dimension d'un named vector (passer summary_vec de text-embedding-3-small 1536d à 3-large 3072d). Weaviate ne supporte pas ce changement in-place. Implémente la migration dual-write + backfill par tenant + shadow reads + cutover par feature flag, sur un cluster qui continue de servir du trafic.

Indice/Solution : crée CV_v2, dual-write applicatif sur les deux class, backfill historique par batch et par tenant (re-embed), shadow reads (compare top-k v1 vs v2 sans servir), puis cutover progressif tenant par tenant via flag. Le piège : ne JAMAIS faire un "stop the world / re-embed tout / restart". Mesure le coût d'embedding du backfill (5M CV × prix embedding) AVANT de lancer — c'est ce chiffre que le client demandera.

Exo 6 — Casse le rerank, puis quantifie le tradeoff

Objectif : activer le reranker Cohere sur toutes les requêtes (anti-pattern), mesurer p50/p95 et coût/mois, puis le restreindre aux requêtes qui en bénéficient et prouver le gain net (qualité gardée, latence/coût récupérés).

Indice/Solution : rerank sur tout → latence ×2-3 et coût Cohere ×N. Puis : n'active le rerank que si top1_score - top2_score < seuil (résultats ambigus) ou sur les requêtes "longue traîne". Mesure le delta nDCG entre "rerank partout" et "rerank conditionnel" — si le delta est < 2 %, le rerank conditionnel gagne. Le livrable senior : un graphe coût-vs-nDCG qui justifie le seuil.

🎤 En entretien

Q : Pourquoi Weaviate multi-tenancy plutôt qu'un filtre WHERE tenant_id = X sur une seule shard ? R : Isolation physique (shard par tenant = HNSW + data files séparés) → étanchéité prouvable RGPD, backup/suppression par tenant en 1 appel, et cold tiering par tenant. Le filtre tenant_id partage l'index HNSW : tu paies le scan de tous les tenants et tu ne peux pas prouver l'isolation à un auditeur.

Q : Named vectors vs plusieurs objets / plusieurs collections — quel est le tradeoff ? R : Named vectors = plusieurs index vectoriels sur le même objet → une seule requête, latence stable, pas de jointure applicative. Plusieurs collections = duplication des propriétés et orchestration côté app. On prend named vectors dès qu'un objet a plusieurs aspects sémantiques distincts (titre dense / corps long / résumé) et qu'on veut cibler le bon index selon la requête.

Q : Tu mets le module generative-anthropic de Weaviate en prod ? Justifie. R : Non en prod — il me dépossède de la couche LLM (pas de prompt caching sur le préfixe stable, pas de logging usage, pas de typed exceptions/retries/streaming, modèle figé pour toute la class). Pattern prod : Weaviate retrieve-only (hybrid + rerank), génération hors cluster avec l'SDK Anthropic (claude-opus-4-8, cache sur system+chunks, usage loggé, modèle choisi par route). Le module reste pour le POC/démo.

Q : alpha=0.6 dans une hybrid search, ça veut dire quoi exactement, et comment tu le tunes ? R : alpha pondère dense vs BM25 : 0 = BM25 only, 1 = vector only (attention au sens, c'est un pitfall classique). On part de 0.5-0.7 par défaut, puis on tune sur un eval set avec ground-truth (nDCG@10) — pas au feeling. Plus le corpus a de jargon exact (références juridiques, codes produit), plus on baisse alpha vers le lexical.

🔗 Liens

→ Voir aussi : 01-pgvector.md, 02-qdrant.md (perf-first), 04-pinecone-self-hosted.md, 05-hybrid-search.md.

Bibliothèque tech perso — Achref