Stratégie de recherche
Comment chercher dans 70k+ membres en quelques secondes
Le problème central : il y a jusqu'à 70 000 membres en base. L'IA ne doit JAMAIS lire les 70 000 fiches. Il faut un pipeline en plusieurs étapes pour réduire intelligemment.
Le pipeline en 3 etapes
Pool first : toute recherche commence par filtrer sur
poolId. Une demande "Hotels" ne verra jamais les membres "Restaurants". C'est le premier filtre, avant meme la geo.
Chaque étape a ses propres garanties :
- SQL → rapide (~50ms), élimine ce qui ne matche pas les contraintes dures
- Vector → moyen (~100-300ms), classe par similarité sémantique
- LLM → lent (~1-3s), affine et explique sur un petit set
Total cible : < 5 secondes end-to-end.
Étape 1, Filtrage SQL dur
Filtres applicables :
| Filtre | Source | Type |
|---|---|---|
| Pool | member.poolId = request.poolId (inclut les sub-pools) | dur (obligatoire) |
| Zone geographique | postalCode, lat/lng (rayon en km) | dur |
| Configuration + capacite | room_capacity.configuration ∈ formats demandes ET max_capacity >= X | dur |
| Attributs hard | member_attribute matche request_attribute (hard) | dur |
| Budget compatible | budgetLow <= budget demande <= budgetHigh | mou |
| Attributs soft | scoring pondéré sur attribute_type.weight | mou |
| Statut actif | member.status = 'active' AND deleted_at IS NULL | dur |
Filtrage par configuration Kactus
L'organisateur précise une ou plusieurs configurations souhaitées (réunion, U, théâtre, cabaret, école, banquet, cocktail). Pour chaque demande :
- On récupère les configurations acceptées (ex:
["cocktail", "banquet"]) - On filtre les rooms qui ont une
room_capacitymatchante avec une jauge suffisante - On remonte au membre via
room.member_id
Une demande "180 personnes en cocktail OU banquet" matche un membre s'il a au moins une room avec :
- Soit
(configuration = 'cocktail' AND max_capacity >= 180) - Soit
(configuration = 'banquet' AND max_capacity >= 180)
Optimisations :
- Index composite sur
(postalCode, status)côtémember - Index géospatial GIST pour la recherche par rayon
- Index sur
room_capacity (configuration, max_capacity)pour le filtrage par config - Index sur
member_attribute (member_id, attribute_type_id)pour le join des attributs - Statistiques Postgres à jour (
ANALYZE)
À propos des disponibilités, le filtre n'est plus dans le pipeline de recherche : l'équipe envoie une
request_availability_check(magic link) aux candidats retenus après le scoring. Ça évite d'éliminer un membre encore "frais" qu'on n'a pas encore interrogé.
Exemple SQL :
SELECT DISTINCT m.id, m.code, m.name
FROM member m
INNER JOIN room r ON r.member_id = m.id
INNER JOIN room_capacity rc ON rc.room_id = r.id
WHERE m.status = 'active'
-- Filtre Pool (premier filtre, obligatoire)
AND m.pool_id = $0
-- Filtre geographique
AND (
m.postal_code = ANY($1)
OR earth_distance(
ll_to_earth(m.lat::float8, m.lng::float8),
ll_to_earth($2, $3)
) < $4
)
-- Filtre configuration + capacité (le cœur du nouveau pipeline)
AND rc.configuration = ANY($5) -- ['cocktail', 'banquet']
AND rc.max_capacity >= $6 -- 180
LIMIT 500;Les disponibilités sont demandées après ce SQL via
request_availability_check(magic link envoyé aux candidats retenus). Le pipeline initial ne filtre pas sur la dispo pour éviter d'éliminer un membre que l'équipe n'a pas encore interrogé.
Note : on utilise DISTINCT car un même membre peut avoir plusieurs rooms qui matchent, on ne veut pas le compter plusieurs fois.
Si après ce filtrage on a moins de 50 candidats, on saute directement à l'étape 3. Si on a entre 50 et 500, on passe par le vector search. Si on a plus de 500 (filtres trop laxistes), on ajuste les seuils ou on retourne directement les 500 sans LLM.
Étape 2, Recherche vectorielle
Pourquoi : trier les ~500 candidats par similarité sémantique avec la demande.
Qu'est-ce qu'un embedding ?
Un embedding est un vecteur (une liste de nombres, typiquement 1 536 dimensions) qui représente le sens d'un texte. Deux textes au sens proche ont des vecteurs proches dans l'espace (au sens de la distance cosinus).
Quelques intuitions :
| Texte | Représentation simplifiée |
|---|---|
"loft industriel pour cocktail" | [0.12, -0.08, 0.34, ..., 0.51] |
"espace brut style atelier privatif" | [0.15, -0.10, 0.31, ..., 0.49] |
"restaurant gastronomique étoilé" | [-0.42, 0.28, -0.05, ..., 0.11] |
Les deux premiers vecteurs sont presque identiques → le modèle "comprend" qu'un loft et un espace brut style atelier, c'est la même chose. Le troisième est très différent → un resto étoilé n'a rien à voir.
On ne fait pas du keyword matching. Si un membre n'a jamais utilisé le mot "loft" mais décrit "atelier privatif avec poutres apparentes", il sortira quand même quand on cherche "loft industriel", parce que le modèle a appris la sémantique.
C'est ce qui rend la recherche résiliente au vocabulaire : l'organisateur tape "rooftop romantique vue Eiffel", et on trouve un membre qui a écrit "terrasse panoramique au coucher du soleil sur Paris" même s'il n'utilise pas un seul des mots de la requête.
Quel modèle ? OpenAI text-embedding-3-small (1 536 dims) ou Voyage voyage-3-large. Le premier est moins cher et largement suffisant pour notre cas (cf. ADR-02).
Stockage ? Une colonne vector(1536) dans Postgres via l'extension pgvector. Indexée avec ivfflat (cf. ADR-01).
Comment on compare ? Distance cosinus (<=> en SQL pgvector). Plus la distance est faible, plus les textes sont proches. On prend les LIMIT 50 les plus petits et on a notre top 50 sémantiquement pertinent.
Génération des embeddings
Chaque membre a un embedding qui résume :
- Sa description
- Ses tags (joints en texte)
- Le contenu de ses
KnowledgeEntry(concaténé) - Les noms et descriptions de ses rooms
Le tout est concaténé en un seul texte, puis embeddé. Re-généré quand un de ces champs change (trigger ou worker).
async function buildMemberEmbeddingText(memberId: string): Promise<string> {
const member = await db.query.member.findFirst({
where: eq(member.id, memberId),
with: {
rooms: true,
tags: { with: { tag: true } },
knowledgeEntries: true,
},
});
return [
member.name,
member.description,
member.tags.map((t) => t.tag.name).join(", "),
member.rooms.map((r) => `${r.name}: ${r.description}`).join("\n"),
member.knowledgeEntries.map((k) => k.content).join("\n"),
].filter(Boolean).join("\n\n");
}Génération de l'embedding de la demande
Pour chaque demande, on construit une "query" sémantique qui résume le besoin :
function buildRequestQuery(request: Request): string {
return [
request.description,
request.complementaryInfo,
`${request.participantsCount} personnes`,
`Budget ${request.budget}`,
`Format: ${request.formats.join(", ")}`,
request.area,
].filter(Boolean).join(". ");
}L'embedding de cette query est généré au moment de la recherche (pas stocké, sauf si on veut tracer pour debug).
Requête vectorielle SQL
SELECT
m.id,
m.code,
m.name,
1 - (m.embedding <=> $1::vector) AS similarity
FROM member m
WHERE m.id = ANY($2) -- les 500 candidats de l'étape 1
ORDER BY m.embedding <=> $1::vector
LIMIT 50;<=> est l'opérateur de distance cosinus de pgvector.
Index :
CREATE INDEX member_embedding_idx ON member
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);(Le nombre de lists à ajuster selon la taille de la table, règle approximative : sqrt(rows).)
Étape 3, Ranking LLM
Sur les 20-50 finalistes, on demande à Claude :
- De ranker
- D'expliquer en une phrase pourquoi chaque membre est pertinent
- D'éliminer ceux qu'il juge inappropriés malgré le score vectoriel
Prompt :
Voici une demande d'événement :
[résumé structuré de la demande]
Voici 30 lieux candidats avec leurs informations clés :
[liste structurée : code, nom, adresse, capacités, tarifs, tags, extraits KB]
Tâche :
1. Sélectionne les 8 meilleurs lieux pour cette demande, en distinguant :
- 4 "fortement recommandés" (ceux qui matchent parfaitement)
- 4 "compatibles" (alternatives solides)
2. Pour chaque lieu sélectionné, donne une justification d'UNE phrase (pourquoi il convient à CETTE demande spécifique).
3. Si un lieu te paraît mauvais malgré son score, dis-le.
Réponse en JSON :
{
"recommended": [
{ "memberCode": "#102", "explanation": "..." },
...
],
"compatible": [
{ "memberCode": "#205", "explanation": "..." },
...
],
"rejected": [
{ "memberCode": "#157", "reason": "..." }
]
}Le LLM ne voit JAMAIS les 70k membres. Il voit au maximum 50 fiches résumées en une page.
Recherche par prompt libre (chat)
Quand l'utilisateur tape "Trouve-moi un rooftop dans le 8ème pour 100 personnes festif avec vue sur Paris", Claude utilise le tool search_members qui :
- Extrait les critères structurés (postalCode
75008, capacity100) - Garde le reste comme
semanticQuery - Lance le pipeline filtrage → vector → ranking
- Retourne les top résultats avec explications
C'est le même pipeline que pour la recherche initiale d'une demande, juste appelé via tool.
Mise à jour des embeddings
Stratégie de re-embedding :
| Trigger | Quand |
|---|---|
| Création d'un membre | Synchrone (job en arrière-plan) |
Update de description | Async (queue) |
Ajout d'un KnowledgeEntry | Async |
Ajout d'un Tag | Async, batch toutes les 5 min |
Update d'une Room | Async, batch |
Implémentation : worker dédié qui consomme une queue (BullMQ ou simple Postgres LISTEN/NOTIFY pour commencer).
Cas spéciaux
Demande très large
Si l'organisateur dit "n'importe où en Île-de-France", l'étape 1 ne réduit presque rien. Stratégie :
- L'IA propose à l'utilisateur de préciser ("Tu peux affiner la zone ?")
- Ou : on prend les 500 plus proches géographiquement de Paris-centre
Demande très précise
Si l'utilisateur demande spécifiquement #102 et #157, on saute le pipeline et on sert directement.
Demande de recommandation similaire
Si on a déjà une demande similaire dans l'historique, on peut réutiliser le catalogue sans relancer le pipeline complet (avec accord de l'utilisateur).
Métriques à suivre
- Temps total du pipeline (p50, p95, p99)
- Taux de candidats par étape (combien sortent de l'étape 1, 2, 3)
- Taux d'acceptation des suggestions IA (combien gardés vs corrigés par l'utilisateur)
- Coût par recherche (tokens LLM + appels API embedding)
Scalabilité au-delà de 70k
Si la base grossit beaucoup :
- Partitioning : par zone géographique (région, département)
- Pré-filtrage géo plus agressif : ne charger que les membres dans un rayon
- Embeddings plus petits : passer de 1536 à 768 dims si la qualité reste OK
- Cache de recherches : si deux demandes similaires arrivent dans la même journée, partager les résultats
- DB vectorielle dédiée (Qdrant, Pinecone) si pgvector devient un goulot