Skip to main content
ai voice11 mars 202618 min de lecture

Recherche vectorielle Pinecone pour le matching de partenaires : un exemple concret

Comment nous avons utilisé les embeddings vectoriels Pinecone pour construire un moteur de matching de partenaires propulsé par l'IA pour plus de 17 000 professionnels. Architecture, implémentation et leçons apprises.

Loic Bachellerie

Senior Product Engineer

Introduction

La première version de notre algorithme de matching était un désastre. Nous utilisions le recoupement de mots-clés et des chaînes de filtres pour connecter les professionnels sur onSpark, notre plateforme de partenariat IA. Le résultat : des utilisateurs ayant des objectifs quasi identiques obtenaient zéro correspondance parce que l'un disait "growth hacking" et l'autre "acquisition d'utilisateurs". Même intention, zéro recoupement.

Nous avons reconstruit le moteur en utilisant la recherche vectorielle Pinecone et les embeddings OpenAI. Deux semaines après le déploiement, les taux d'acceptation des matchs sont passés de 23 % à 61 %. Aujourd'hui, onSpark fait passer plus de 17 000 profils professionnels dans ce même index Pinecone chaque jour, retournant des candidats classés en moins de 120 ms.

Cet article est le deep-dive technique que j'aurais aimé avoir quand j'ai commencé. Vous y trouverez l'architecture complète, chaque détail d'implémentation en TypeScript, la stratégie d'embedding qui fonctionne réellement à grande échelle, et les optimisations de production qui maintiennent les coûts raisonnables au-delà de 10 000 utilisateurs.

À la fin, vous comprendrez :

  • Pourquoi la recherche vectorielle surpasse le matching par filtres pour des profils professionnels nuancés
  • Comment structurer les embeddings pour une similarité multidimensionnelle
  • La configuration de l'index Pinecone et le pipeline d'upsert pour plus de 17 000 enregistrements
  • Comment interroger, scorer et classer les candidats en temps réel
  • Les optimisations de production incluant le sharding par namespace et la réindexation par lots

Le problème : pourquoi le matching par mots-clés échoue pour les personnes

Avant d'aborder Pinecone, il est utile de comprendre pourquoi l'approche naïve s'effondre. Ce n'est pas un point académique. Cela nous a coûté trois mois de churn utilisateur avant que nous le corrigions.

Un professionnel sur onSpark remplit un onboarding vocal (géré par Vapi.ai) qui pose quatre questions :

  1. Quel est votre parcours professionnel ?
  2. Sur quoi travaillez-vous en ce moment ?
  3. Quel type de partenariats recherchez-vous ?
  4. Que pouvez-vous apporter à un partenaire ?

Ces réponses sont transcrites en un profil structuré. Le défi : deux personnes peuvent décrire la même réalité professionnelle avec un vocabulaire complètement différent.

Utilisateur A : "Je suis responsable go-to-market avec un parcours en PLG. Je cherche un co-fondateur technique capable de livrer rapidement."

Utilisateur B : "La vente et la croissance, c'est mon domaine. J'ai lancé deux produits SaaS. Je veux un partenaire ingénieur qui avance vite."

Un système par mots-clés donne à ces utilisateurs une similarité proche de zéro. Un modèle de similarité vectorielle leur donne une distance cosinus d'environ 0,07 - extrêmement proches. Cette différence, c'est tout le produit.

Le second problème était l'asymétrie d'intention. Le filtrage permet de faire correspondre "recherche X" avec "offre X" de manière rigide, comme une jointure de table. Mais l'intention réelle de partenariat se situe sur un spectre. Quelqu'un qui propose des "introductions stratégiques auprès d'investisseurs" correspond aussi à quelqu'un qui cherche un "accompagnement en levée de fonds". La similarité sémantique capture cela ; le filtrage par mots-clés, non.

Pourquoi la recherche vectorielle

La recherche vectorielle convertit le texte en une représentation numérique en haute dimension où la distance sémantique correspond à la distance géométrique. Des textes ayant un sens similaire se retrouvent proches dans cet espace, indépendamment des mots spécifiques utilisés.

Le processus est le suivant :

  1. Passer le texte du profil à travers un modèle d'embedding (nous utilisons text-embedding-3-large)
  2. Le modèle retourne un tableau de flottants en 3072 dimensions
  3. Stocker ce vecteur dans Pinecone avec un identifiant de profil et des métadonnées
  4. Au moment de la requête, embedder le profil de l'utilisateur demandeur de la même manière
  5. Pinecone retourne les voisins les plus proches par similarité cosinus en quelques millisecondes

Pour le matching de partenaires, cela signifie que nous trouvons des profils qui sont sémantiquement compatibles - pas seulement syntaxiquement similaires. C'est le déblocage fondamental.

Pipeline de matching vectoriel

Du texte brut du profil aux candidats classés

Texte du profil
OpenAI Embed
Index Pinecone
Matchs classés

Entrée

Transcription vocale + champs structurés

Modèle

Tableau de flottants en 3072 dim

Stockage

17 000+ vecteurs, métrique cosinus

Sortie

Top-K avec scores en <120ms

Taux d'acceptation des matchs : 23 % → 61 % après migration

Configuration de Pinecone

Création de l'index

Commencez avec le tableau de bord Pinecone ou leur SDK. Pour onSpark, j'utilise le SDK afin que la configuration de l'index soit versionnée et reproductible.

// lib/pinecone/client.ts
import { Pinecone } from "@pinecone-database/pinecone";
 
const PINECONE_INDEX_NAME = "onspark-profiles";
const EMBEDDING_DIMENSION = 3072; // text-embedding-3-large
 
let _pineconeClient: Pinecone | null = null;
 
export function getPineconeClient(): Pinecone {
  if (!_pineconeClient) {
    _pineconeClient = new Pinecone({
      apiKey: process.env.PINECONE_API_KEY!,
    });
  }
  return _pineconeClient;
}
 
export async function ensureIndex(): Promise<void> {
  const client = getPineconeClient();
  const existing = await client.listIndexes();
  const exists = existing.indexes?.some(
    (idx) => idx.name === PINECONE_INDEX_NAME
  );
 
  if (!exists) {
    await client.createIndex({
      name: PINECONE_INDEX_NAME,
      dimension: EMBEDDING_DIMENSION,
      metric: "cosine",
      spec: {
        serverless: {
          cloud: "aws",
          region: "us-east-1",
        },
      },
    });
 
    // Wait for index to be ready
    let ready = false;
    while (!ready) {
      await new Promise((resolve) => setTimeout(resolve, 2000));
      const description = await client.describeIndex(PINECONE_INDEX_NAME);
      ready = description.status?.ready ?? false;
    }
  }
}
 
export function getIndex() {
  return getPineconeClient().index(PINECONE_INDEX_NAME);
}

Quelques décisions méritent une explication :

Métrique cosinus plutôt qu'euclidienne : Pour les embeddings textuels, la similarité cosinus est le bon choix. Elle mesure l'angle entre les vecteurs plutôt que leur distance absolue, ce qui signifie que deux profils au contenu similaire mais de verbosité différente se retrouvent correctement proches. La distance euclidienne pénalise les textes plus longs.

Serverless plutôt que pod-based : À 17 000 enregistrements, nous sommes largement dans la zone d'efficacité tarifaire du serverless. Les index pod-based ont du sens au-delà d'environ 1 million de vecteurs ou lorsque vous avez besoin de SLA garantis sur la latence des requêtes. Pour notre charge de travail, le serverless donne un p99 sous 150 ms et coûte une fraction des pods provisionnés.

Dimension 3072 : C'est la dimension de sortie native de text-embedding-3-large. Vous pouvez demander une dimension plus petite via l'API (utile pour réduire les coûts de stockage), mais nous avons constaté que la dimension complète améliorait significativement la qualité du matching - cela vaut les ~0,40 $/jour supplémentaires en stockage à notre échelle.

Stratégie d'embedding

C'est là que la plupart des implémentations de recherche vectorielle se trompent. Si vous concaténez simplement tous les champs du profil et embeddez la chaîne résultante, vous perdez le signal structurel. Le modèle d'embedding compresse tout en un seul point, et les fenêtres de contexte des différents champs entrent en compétition.

Pour onSpark, nous utilisons une stratégie d'embedding composite : nous construisons une seule chaîne riche, mais nous la structurons de façon à ce que le contenu sémantiquement le plus important apparaisse en premier et avec un cadrage naturel. Les Transformers accordent plus d'attention aux premiers tokens.

Construction du document de profil

// lib/pinecone/profile-document.ts
 
export interface ProfileFields {
  background: string;
  currentProject: string;
  partnershipGoal: string;
  offering: string;
  industry: string;
  stage: string; // "idea" | "pre-seed" | "seed" | "series-a" | "growth"
  location: string;
}
 
export function buildProfileDocument(fields: ProfileFields): string {
  // Order matters: most semantically important first
  const sections = [
    `Partnership goal: ${fields.partnershipGoal}`,
    `Offering: ${fields.offering}`,
    `Current project: ${fields.currentProject}`,
    `Professional background: ${fields.background}`,
    `Industry: ${fields.industry}`,
    `Stage: ${fields.stage}`,
    `Location: ${fields.location}`,
  ];
 
  return sections.join(". ");
}
 
// Example output:
// "Partnership goal: Looking for a technical co-founder to build my SaaS MVP.
//  Offering: Go-to-market strategy, investor intros, B2B sales expertise.
//  Current project: Building an AI scheduling tool for healthcare.
//  Professional background: 8 years in enterprise SaaS sales at Oracle and Salesforce.
//  Industry: healthcare technology. Stage: pre-seed. Location: New York."

Génération des embeddings

// lib/pinecone/embeddings.ts
import OpenAI from "openai";
 
const EMBEDDING_MODEL = "text-embedding-3-large";
const BATCH_SIZE = 100; // OpenAI limit per request
 
let _openai: OpenAI | null = null;
 
function getOpenAI(): OpenAI {
  if (!_openai) {
    _openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
  }
  return _openai;
}
 
export async function embedText(text: string): Promise<number[]> {
  const openai = getOpenAI();
  const response = await openai.embeddings.create({
    model: EMBEDDING_MODEL,
    input: text,
  });
  return response.data[0].embedding;
}
 
export async function embedBatch(texts: string[]): Promise<number[][]> {
  const openai = getOpenAI();
  const batches: string[][] = [];
 
  for (let i = 0; i < texts.length; i += BATCH_SIZE) {
    batches.push(texts.slice(i, i + BATCH_SIZE));
  }
 
  const results: number[][] = [];
 
  for (const batch of batches) {
    const response = await openai.embeddings.create({
      model: EMBEDDING_MODEL,
      input: batch,
    });
 
    const sorted = response.data.sort((a, b) => a.index - b.index);
    results.push(...sorted.map((item) => item.embedding));
  }
 
  return results;
}

Un détail important : l'API d'embeddings d'OpenAI retourne les résultats dans un ordre arbitraire lorsque vous soumettez un lot. Le tri par index garantit que le tableau de sortie s'aligne avec le tableau d'entrée. Omettre cette étape provoque un bug subtil où les profils reçoivent les mauvais embeddings - il nous a fallu deux jours pour le tracer lors de notre première indexation en masse.

Indexation des profils

Pipeline d'upsert

// lib/pinecone/indexer.ts
import { getIndex } from "./client";
import { buildProfileDocument, type ProfileFields } from "./profile-document";
import { embedText, embedBatch } from "./embeddings";
 
export interface ProfileRecord {
  id: string;
  fields: ProfileFields;
  metadata: {
    userId: string;
    displayName: string;
    industry: string;
    stage: string;
    location: string;
    createdAt: string;
    updatedAt: string;
    isActive: boolean;
  };
}
 
export async function upsertProfile(record: ProfileRecord): Promise<void> {
  const document = buildProfileDocument(record.fields);
  const embedding = await embedText(document);
  const index = getIndex();
 
  await index.upsert([
    {
      id: record.id,
      values: embedding,
      metadata: record.metadata,
    },
  ]);
}
 
export async function upsertProfiles(
  records: ProfileRecord[]
): Promise<{ indexed: number; failed: number }> {
  const UPSERT_BATCH_SIZE = 100; // Pinecone limit per upsert call
  let indexed = 0;
  let failed = 0;
 
  // Build all documents
  const documents = records.map((r) => buildProfileDocument(r.fields));
 
  // Embed in batches
  const embeddings = await embedBatch(documents);
 
  // Prepare vectors
  const vectors = records.map((record, i) => ({
    id: record.id,
    values: embeddings[i],
    metadata: record.metadata,
  }));
 
  // Upsert to Pinecone in batches
  const index = getIndex();
  for (let i = 0; i < vectors.length; i += UPSERT_BATCH_SIZE) {
    const batch = vectors.slice(i, i + UPSERT_BATCH_SIZE);
 
    try {
      await index.upsert(batch);
      indexed += batch.length;
    } catch (error) {
      console.error(
        `Upsert failed for batch starting at index ${i}:`,
        error
      );
      failed += batch.length;
    }
  }
 
  return { indexed, failed };
}
 
export async function deleteProfile(profileId: string): Promise<void> {
  const index = getIndex();
  await index.deleteOne(profileId);
}

Déclenchement de l'indexation lors des mises à jour de profil

En production, l'indexation des profils se fait à trois endroits :

  1. Webhook Vapi - après la fin de l'onboarding vocal, la transcription est traitée et le nouveau profil est indexé immédiatement
  2. API de modification de profil - chaque fois qu'un utilisateur met à jour ses champs, nous ré-embeddons et upsertons
  3. Job batch nocturne - rattrape toute dérive, réindexe tous les profils modifiés dans les dernières 24 heures
// app/api/webhooks/vapi/route.ts (simplified)
import { NextRequest, NextResponse } from "next/server";
import { processOnboardingTranscript } from "@/lib/onboarding/processor";
import { upsertProfile } from "@/lib/pinecone/indexer";
 
export async function POST(request: NextRequest): Promise<NextResponse> {
  const body = await request.json();
 
  if (body.message?.type !== "end-of-call-report") {
    return NextResponse.json({ status: "ignored" });
  }
 
  const { transcript, call } = body.message;
  const userId = call.metadata?.userId;
 
  if (!userId) {
    return NextResponse.json({ error: "Missing userId in metadata" }, { status: 400 });
  }
 
  // Extract structured fields from voice transcript
  const fields = await processOnboardingTranscript(transcript);
 
  // Save to database
  const profile = await saveProfileToDatabase(userId, fields);
 
  // Index in Pinecone asynchronously (do not block the webhook response)
  upsertProfile({
    id: profile.id,
    fields,
    metadata: {
      userId,
      displayName: profile.displayName,
      industry: fields.industry,
      stage: fields.stage,
      location: fields.location,
      createdAt: profile.createdAt,
      updatedAt: new Date().toISOString(),
      isActive: true,
    },
  }).catch((err) => {
    console.error(`Pinecone upsert failed for user ${userId}:`, err);
  });
 
  return NextResponse.json({ status: "ok" });
}

Déclencheurs d'indexation

Trois chemins qui maintiennent l'index Pinecone à jour

Onboarding vocal
1L'appel Vapi se termine
2Transcription traitée
3Upsert lancé en async
Temps réel
API de modification de profil
1L'utilisateur enregistre ses modifications
2Écriture en BDD terminée
3Ré-embedding et upsert
À la modification
Job batch nocturne
1Récupérer les modifiés des 24h
2Embedding + upsert en masse
3Journaliser les rapports de dérive
Toutes les nuits à 2h UTC

Requêtes de matching

La requête de matching principale

// lib/pinecone/matcher.ts
import { getIndex } from "./client";
import { buildProfileDocument, type ProfileFields } from "./profile-document";
import { embedText } from "./embeddings";
 
export interface MatchCandidate {
  profileId: string;
  score: number;
  metadata: {
    userId: string;
    displayName: string;
    industry: string;
    stage: string;
    location: string;
    isActive: boolean;
  };
}
 
export interface MatchQueryOptions {
  topK?: number;
  minScore?: number;
  filterIndustry?: string;
  filterStage?: string[];
  excludeProfileIds?: string[];
}
 
export async function findMatches(
  queryFields: ProfileFields,
  queryProfileId: string,
  options: MatchQueryOptions = {}
): Promise<MatchCandidate[]> {
  const {
    topK = 20,
    minScore = 0.72,
    filterIndustry,
    filterStage,
    excludeProfileIds = [],
  } = options;
 
  const document = buildProfileDocument(queryFields);
  const queryEmbedding = await embedText(document);
 
  // Build metadata filter
  const filter: Record<string, unknown> = {
    isActive: { $eq: true },
  };
 
  if (filterIndustry) {
    filter.industry = { $eq: filterIndustry };
  }
 
  if (filterStage && filterStage.length > 0) {
    filter.stage = { $in: filterStage };
  }
 
  const index = getIndex();
 
  // Request more than topK to account for excluded IDs
  const fetchCount = topK + excludeProfileIds.length + 10;
 
  const response = await index.query({
    vector: queryEmbedding,
    topK: fetchCount,
    includeMetadata: true,
    filter,
  });
 
  const excluded = new Set([queryProfileId, ...excludeProfileIds]);
 
  const candidates = (response.matches ?? [])
    .filter((match) => !excluded.has(match.id))
    .filter((match) => (match.score ?? 0) >= minScore)
    .slice(0, topK)
    .map((match) => ({
      profileId: match.id,
      score: match.score ?? 0,
      metadata: match.metadata as MatchCandidate["metadata"],
    }));
 
  return candidates;
}

Pourquoi nous avons fixé le minScore à 0,72

Cette valeur a été dérivée empiriquement sur plusieurs semaines de tests A/B. Voici à quoi ressemblait la distribution des scores sur 5 000 requêtes échantillonnées :

Plage de scoreInterprétationTaux d'acceptation
0,90+Profils quasi identiques82 % (souvent trop similaires, faible nouveauté)
0,80-0,90Très compatibles74 %
0,72-0,80Forte compatibilité61 % (zone optimale)
0,62-0,72Match modéré31 %
Sous 0,62Faible ou bruit9 %

Le taux d'acceptation dans la plage 0,72-0,80 est en réalité supérieur à celui de 0,90+ parce que la nouveauté compte. Deux fondateurs avec des parcours très similaires ne s'apportent pas autant de valeur mutuellement que deux professionnels complémentaires dans des domaines adjacents. La zone optimale capture le "très compatible mais suffisamment distinct pour être utile".

Scoring et classement

Les scores bruts de Pinecone sont un bon point de départ, mais ne constituent pas le signal de classement final. Nous appliquons une couche de scoring en post-traitement qui intègre trois facteurs supplémentaires.

Le score composite

// lib/matching/scorer.ts
import type { MatchCandidate } from "../pinecone/matcher";
 
interface ScoringContext {
  queryUserId: string;
  queryStage: string;
  queryLocation: string;
  previouslyShownProfileIds: Set<string>;
  previouslyDeclinedProfileIds: Set<string>;
}
 
interface RankedMatch {
  profileId: string;
  userId: string;
  displayName: string;
  compositeScore: number;
  vectorScore: number;
  freshnessBoost: number;
  locationBoost: number;
  noveltyPenalty: number;
}
 
const WEIGHT_VECTOR = 0.7;
const WEIGHT_FRESHNESS = 0.15;
const WEIGHT_LOCATION = 0.15;
 
export function rankMatches(
  candidates: MatchCandidate[],
  context: ScoringContext
): RankedMatch[] {
  const now = Date.now();
 
  const ranked = candidates.map((candidate) => {
    const vectorScore = candidate.score;
 
    // Freshness boost: profiles updated in last 30 days get a lift
    const updatedAt = new Date(
      (candidate.metadata as Record<string, string>).updatedAt
    ).getTime();
    const daysSinceUpdate = (now - updatedAt) / (1000 * 60 * 60 * 24);
    const freshnessBoost = Math.max(0, 1 - daysSinceUpdate / 30);
 
    // Location boost: same city gets a small signal
    const sameLocation =
      candidate.metadata.location === context.queryLocation;
    const locationBoost = sameLocation ? 1 : 0;
 
    // Novelty penalty: profiles shown before but not acted on get downranked
    const wasShown = context.previouslyShownProfileIds.has(candidate.profileId);
    const wasDeclined = context.previouslyDeclinedProfileIds.has(
      candidate.profileId
    );
    const noveltyPenalty = wasDeclined ? 0.5 : wasShown ? 0.85 : 1.0;
 
    const compositeScore =
      (WEIGHT_VECTOR * vectorScore +
        WEIGHT_FRESHNESS * freshnessBoost +
        WEIGHT_LOCATION * locationBoost) *
      noveltyPenalty;
 
    return {
      profileId: candidate.profileId,
      userId: candidate.metadata.userId,
      displayName: candidate.metadata.displayName,
      compositeScore,
      vectorScore,
      freshnessBoost,
      locationBoost,
      noveltyPenalty,
    };
  });
 
  return ranked.sort((a, b) => b.compositeScore - a.compositeScore);
}

Les trois signaux supplémentaires :

Fraîcheur (15 %) : Un profil mis à jour il y a deux jours a plus de chances de refléter l'intention actuelle qu'un profil intact depuis huit mois. C'est particulièrement vrai dans les contextes de startups early-stage où les gens pivotent fréquemment.

Localisation (15 %) : Les matchs dans la même ville se transforment en rencontres réelles à un taux 2,3x supérieur à celui des matchs uniquement à distance, selon nos données. Nous donnons un boost modeste mais ne filtrons pas les candidats distants.

Pénalité de nouveauté : C'est le signal le plus important après le score vectoriel. Si un utilisateur s'est vu montrer un profil cinq fois sans jamais se connecter, le montrer à nouveau est du bruit. Les matchs refusés reçoivent une pénalité de 50 % ; ceux déjà montrés mais sans action reçoivent une pénalité de 15 %.

Décomposition du scoring composite

Comment les scores vectoriels bruts deviennent des classements finaux

Similarité vectorielle (70 %)Score cosinus Pinecone
Fraîcheur du profil (15 %)Jours depuis la dernière mise à jour
Signal de localisation (15 %)Même ville = +1,0

Puis multiplié par le facteur de nouveauté :

Jamais montré : 1,0xMontré, pas d'action : 0,85xRefusé : 0,5x
Le classement final alimente le fil de matchs

Optimisations de production

À 1 000 utilisateurs, rien de tout cela n'a beaucoup d'importance. À 17 000 utilisateurs avec des centaines de requêtes par minute aux heures de pointe, les petites inefficacités s'accumulent rapidement. Voici les quatre optimisations qui nous ont maintenus sous budget et sous 120 ms de latence p95.

1. Sharding par namespace selon le stade

Les namespaces Pinecone permettent de partitionner un index. Nous shardons par stade d'entreprise, de sorte qu'un fondateur au stade "idée" n'interroge que le sous-ensemble de profils qui se sont déclarés ouverts aux partenariats early-stage.

// lib/pinecone/namespace-strategy.ts
 
const STAGE_NAMESPACE_MAP: Record<string, string> = {
  idea: "stage-early",
  "pre-seed": "stage-early",
  seed: "stage-growth",
  "series-a": "stage-growth",
  growth: "stage-scale",
  enterprise: "stage-scale",
};
 
export function getNamespaceForStage(stage: string): string {
  return STAGE_NAMESPACE_MAP[stage] ?? "stage-growth";
}
 
export function getQueryNamespaces(stage: string): string[] {
  // Return own namespace plus adjacent ones for cross-stage matching
  const own = getNamespaceForStage(stage);
 
  const adjacent: Record<string, string[]> = {
    "stage-early": ["stage-early", "stage-growth"],
    "stage-growth": ["stage-early", "stage-growth", "stage-scale"],
    "stage-scale": ["stage-growth", "stage-scale"],
  };
 
  return adjacent[own] ?? [own];
}
 
// Usage in upsert
export async function upsertProfileWithNamespace(
  record: ProfileRecord
): Promise<void> {
  const namespace = getNamespaceForStage(record.fields.stage);
  const index = getIndex().namespace(namespace);
  // ... rest of upsert
}

Cela seul a réduit le temps de requête moyen de 38 % parce que chaque requête scanne un espace vectoriel plus petit. La logique de "namespaces adjacents" garantit qu'un fondateur au stade seed peut toujours matcher avec un fondateur pre-seed - vous interrogez plusieurs namespaces et fusionnez les résultats.

2. Cache d'embeddings

Générer des embeddings coûte de l'argent et ajoute de la latence. Les embeddings de profils changent rarement, donc nous les mettons en cache dans Redis avec un TTL lié au timestamp updatedAt du profil.

// lib/pinecone/embedding-cache.ts
import { Redis } from "@upstash/redis";
 
const redis = Redis.fromEnv();
const CACHE_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days
 
function embedCacheKey(profileId: string, updatedAt: string): string {
  return `embed:${profileId}:${updatedAt}`;
}
 
export async function getOrCreateEmbedding(
  profileId: string,
  updatedAt: string,
  document: string,
  generateFn: (text: string) => Promise<number[]>
): Promise<number[]> {
  const cacheKey = embedCacheKey(profileId, updatedAt);
 
  const cached = await redis.get<number[]>(cacheKey);
  if (cached) {
    return cached;
  }
 
  const embedding = await generateFn(document);
 
  await redis.setex(cacheKey, CACHE_TTL_SECONDS, embedding);
 
  return embedding;
}

Taux de cache hit en production : 94 %. La plupart des requêtes proviennent d'utilisateurs qui consultent leur fil de matchs, ce qui récupère des profils existants plutôt que de les ré-embedder. Cela réduit nos coûts d'embeddings OpenAI d'environ 15x par rapport au calcul à chaque requête.

3. Cache de matchs avec stale-while-revalidate

Le fil de matchs lui-même est mis en cache par utilisateur avec un TTL court. Les requêtes Pinecone sont rapides, mais quand 500 utilisateurs consultent leur fil à 9h du matin, la charge concurrente s'accumule.

// lib/matching/match-cache.ts
import { Redis } from "@upstash/redis";
 
const redis = Redis.fromEnv();
const MATCH_CACHE_TTL = 60 * 30; // 30 minutes
const STALE_THRESHOLD = 60 * 20; // Revalidate if older than 20 minutes
 
interface CachedMatches {
  matches: RankedMatch[];
  cachedAt: number;
}
 
export async function getMatchesWithCache(
  userId: string,
  computeFn: () => Promise<RankedMatch[]>
): Promise<RankedMatch[]> {
  const cacheKey = `matches:${userId}`;
  const cached = await redis.get<CachedMatches>(cacheKey);
 
  if (cached) {
    const age = (Date.now() - cached.cachedAt) / 1000;
 
    if (age < STALE_THRESHOLD) {
      // Fresh: return immediately
      return cached.matches;
    }
 
    if (age < MATCH_CACHE_TTL) {
      // Stale: return stale data, revalidate in background
      computeFn().then(async (fresh) => {
        await redis.setex(
          cacheKey,
          MATCH_CACHE_TTL,
          { matches: fresh, cachedAt: Date.now() }
        );
      }).catch(console.error);
 
      return cached.matches;
    }
  }
 
  // Cache miss or expired: compute synchronously
  const matches = await computeFn();
  await redis.setex(
    cacheKey,
    MATCH_CACHE_TTL,
    { matches, cachedAt: Date.now() }
  );
 
  return matches;
}

4. Réindexation par lots lors de changements de schéma

Quand nous modifions la fonction buildProfileDocument - ajout d'un nouveau champ ou réorganisation des sections - tous les 17 000 embeddings deviennent obsolètes car ils ont été calculés à partir d'une structure de document antérieure. Nous réindexons l'intégralité du corpus en lots en arrière-plan pour ne pas bloquer l'API principale.

// scripts/reindex-all-profiles.ts
import { getAllActiveProfiles } from "../lib/db/profiles";
import { upsertProfiles } from "../lib/pinecone/indexer";
 
const BATCH_SIZE = 500;
const DELAY_BETWEEN_BATCHES_MS = 2000; // Rate limit headroom
 
async function reindexAllProfiles(): Promise<void> {
  const profiles = await getAllActiveProfiles();
  console.log(`Starting reindex for ${profiles.length} profiles`);
 
  let processed = 0;
  let failed = 0;
 
  for (let i = 0; i < profiles.length; i += BATCH_SIZE) {
    const batch = profiles.slice(i, i + BATCH_SIZE);
 
    const result = await upsertProfiles(batch);
    processed += result.indexed;
    failed += result.failed;
 
    console.log(
      `Progress: ${processed}/${profiles.length} indexed, ${failed} failed`
    );
 
    if (i + BATCH_SIZE < profiles.length) {
      await new Promise((resolve) =>
        setTimeout(resolve, DELAY_BETWEEN_BATCHES_MS)
      );
    }
  }
 
  console.log(`Reindex complete. Indexed: ${processed}, Failed: ${failed}`);
}
 
reindexAllProfiles().catch(console.error);

Une réindexation complète de 17 000 profils prend environ 12 minutes et coûte approximativement 4,80 $ en appels d'embeddings OpenAI aux tarifs actuels. Nous la lançons pendant les heures creuses lors du déploiement de changements de schéma, et le délai entre les lots nous évite d'atteindre les limites de débit d'OpenAI sur l'endpoint d'embeddings.

Résultats et métriques

Après avoir fait tourner ce système en production pendant huit mois, voici les chiffres honnêtes.

Échelle :

  • 17 400 profils actifs indexés dans Pinecone
  • ~2 800 requêtes de matching par jour en pic
  • Taille totale de l'index : ~420 Mo en Pinecone serverless

Qualité du matching (comparé à l'ancien système par mots-clés) :

  • Taux d'acceptation des matchs : 23 % → 61 % (+165 %)
  • Taux de "aucun match trouvé" : 34 % → 4 % (l'ancien système retournait des résultats vides pour les profils de niche)
  • Qualité perçue des matchs (NPS 5 étoiles) : 2,9 → 4,1

Performance :

  • Latence p50 des requêtes : 68 ms (Pinecone seul)
  • Latence p95 des requêtes : 114 ms (Pinecone seul)
  • Fil de matchs complet avec scoring : p95 à 210 ms (incluant vérification cache Redis, couche de scoring)
  • Taux de cache hit (fil de matchs) : 71 %

Coûts (mensuels) :

  • Pinecone serverless : 38 $/mois
  • Embeddings OpenAI (avec cache) : 22 $/mois
  • Redis (Upstash) : 9 $/mois
  • Infrastructure totale pour le matching : ~69 $/mois pour 17 000 utilisateurs

Cela représente 0,004 $ par utilisateur actif par mois pour l'infrastructure de matching complète. Nous avions initialement budgété 300 $/mois sur la base d'estimations naïves de coût par requête. Le cache d'embeddings a été le levier le plus important.

Ce qui nous a surpris :

La pénalité de nouveauté a eu un impact plus important que prévu. Avant de l'ajouter, les utilisateurs churnaient silencieusement parce qu'ils voyaient toujours les mêmes 10 profils. Après la décroissance de nouveauté, la rétention session à session sur le fil de matchs s'est améliorée de 28 %. Montrer moins de matchs mais plus variés s'est avéré plus important que de montrer les matchs les mieux scorés de façon répétée.

L'autre surprise a été l'impact de la complétude des profils sur la qualité du matching. Un profil entièrement rempli obtient en moyenne 3,5x de meilleurs scores de matching qu'un profil épars. Nous affichons désormais un score de complétude dans l'application et avons constaté une réduction de 40 % des requêtes de faible qualité après que les utilisateurs ont compris le lien.

FAQ

Q : Pourquoi ne pas utiliser la recherche hybride de Pinecone avec des vecteurs sparse ? R : Nous avons expérimenté. Pour notre cas d'usage - des descriptions professionnelles longues - les embeddings denses seuls surpassaient la recherche hybride. L'hybride est plus utile quand vous avez des requêtes courtes contre des documents longs, comme la recherche de produits e-commerce. Deux textes de profil longs qui se comparent l'un à l'autre, c'est le sweet spot de la retrieval dense pure.

Q : Comment gérez-vous les profils dans d'autres langues que l'anglais ? R : text-embedding-3-large est multilingue. En pratique, la plupart des profils onSpark sont en anglais ou en français. Nous les embeddons tels quels et la similarité fonctionne toujours entre les langues avec une légère dégradation de qualité (environ 0,05 de score en moins pour les matchs cross-langue dans nos tests). Nous prévoyons d'ajouter un filtrage par métadonnées de langue dans une future version.

Q : Que se passe-t-il quand le profil de quelqu'un change radicalement ? R : Le ré-embedding au moment de la sauvegarde se déclenche immédiatement. Le nouvel embedding écrase l'ancien dans Pinecone via upsert. Le cache de matchs pour cet utilisateur est invalidé à sa prochaine requête. La seule fenêtre où des matchs obsolètes sont montrés se situe dans les 30 minutes du TTL du cache de matchs, ce que nous acceptons comme un compromis raisonnable.

Q : Pourriez-vous faire cela sans Pinecone - juste pgvector dans Postgres ? R : Oui, et pour moins de 50 000 profils je l'envisagerais sérieusement. pgvector avec un index IVFFlat ou HNSW gère cette échelle sans problème. Nous avons choisi Pinecone pour l'infrastructure managée, le filtrage par métadonnées et le support des namespaces. Si vous êtes sur Supabase, pgvector avec vector_cosine_ops fonctionnerait bien et réduirait la surface opérationnelle.

Q : Quelle est la version minimale viable de tout cela ? R : Trois fonctions : embedText, upsertProfile et findMatches. Vous pouvez livrer un prototype fonctionnel en une journée. Commencez avec text-embedding-3-small (coût moindre, 1536 dimensions) et montez en gamme si la qualité du matching n'est pas suffisante. La majeure partie de la complexité de cet article est du travail d'optimisation qui vient après.

Conclusion

La recherche vectorielle a transformé le matching d'onSpark, passant d'une expérience frustrante contrainte par les mots-clés à une expérience qui comprend l'intention professionnelle à un niveau sémantique. L'échelle de 17 000 profils à laquelle nous opérons aujourd'hui n'était pas la partie difficile - la partie difficile était de comprendre que le recoupement de mots-clés est la mauvaise abstraction pour matcher des personnes, et que la similarité cosinus de bons embeddings vous rapproche bien plus de ce que les utilisateurs veulent réellement dire.

L'implémentation est directe. Le SDK de Pinecone est bien documenté, l'API d'embeddings d'OpenAI est fiable, et la couche de scoring composite par-dessus ajoute les signaux humains (fraîcheur, localisation, nouveauté) que la similarité vectorielle pure manque. Le coût total d'infrastructure à notre échelle actuelle est inférieur à 70 $/mois - une erreur d'arrondi par rapport à la valeur produit.

Si vous construisez un produit de matching, de recommandation ou de recherche par similarité, la recherche vectorielle est la bonne fondation. L'écosystème a mûri au point où la charge opérationnelle est minimale.


Vous construisez un produit de matching ou de recommandation et souhaitez discuter de l'architecture ? Contactez-moi et je pourrai vous aider à déterminer si la recherche vectorielle est la bonne solution pour votre cas d'usage spécifique.

Articles connexes :

  • [Building Production AI Voice Agents with Vapi.ai (2026 Complete Guide)]
  • [Firebase vs Supabase: The Definitive Comparison for Startups (2026)]
  • [MVP Architecture Patterns That Scale]
Share:

Recevez des perspectives d'ingénierie pratiques

Agents vocaux IA, workflows d'automatisation et livraison rapide. Pas de spam, désabonnement à tout moment.