Reciprok Docs
Intelligence

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 :

  1. SQL → rapide (~50ms), élimine ce qui ne matche pas les contraintes dures
  2. Vector → moyen (~100-300ms), classe par similarité sémantique
  3. 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 :

FiltreSourceType
Poolmember.poolId = request.poolId (inclut les sub-pools)dur (obligatoire)
Zone geographiquepostalCode, lat/lng (rayon en km)dur
Configuration + capaciteroom_capacity.configuration ∈ formats demandes ET max_capacity >= Xdur
Attributs hardmember_attribute matche request_attribute (hard)dur
Budget compatiblebudgetLow <= budget demande <= budgetHighmou
Attributs softscoring pondéré sur attribute_type.weightmou
Statut actifmember.status = 'active' AND deleted_at IS NULLdur

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 :

  1. On récupère les configurations acceptées (ex: ["cocktail", "banquet"])
  2. On filtre les rooms qui ont une room_capacity matchante avec une jauge suffisante
  3. 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 :

TexteRepré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 :

  1. De ranker
  2. D'expliquer en une phrase pourquoi chaque membre est pertinent
  3. 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 :

  1. Extrait les critères structurés (postalCode 75008, capacity 100)
  2. Garde le reste comme semanticQuery
  3. Lance le pipeline filtrage → vector → ranking
  4. 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 :

TriggerQuand
Création d'un membreSynchrone (job en arrière-plan)
Update de descriptionAsync (queue)
Ajout d'un KnowledgeEntryAsync
Ajout d'un TagAsync, batch toutes les 5 min
Update d'une RoomAsync, 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 :

  1. Partitioning : par zone géographique (région, département)
  2. Pré-filtrage géo plus agressif : ne charger que les membres dans un rayon
  3. Embeddings plus petits : passer de 1536 à 768 dims si la qualité reste OK
  4. Cache de recherches : si deux demandes similaires arrivent dans la même journée, partager les résultats
  5. DB vectorielle dédiée (Qdrant, Pinecone) si pgvector devient un goulot

On this page