Skip to main content
ai voice11 de março de 202618 min de leitura

Pinecone Vector Search para Matching de Parceiros: Um Exemplo Real

Como usamos embeddings vetoriais do Pinecone para construir um motor de matching de parceiros com IA para mais de 17 mil profissionais. Arquitetura, implementação e lições aprendidas.

Loic Bachellerie

Senior Product Engineer

Introdução

A primeira versão do nosso algoritmo de matching foi um desastre. Estávamos usando sobreposição de palavras-chave e cadeias de filtros para conectar profissionais no onSpark, nossa plataforma de parcerias com IA. O resultado: usuários com objetivos quase idênticos recebiam zero matches porque um dizia "growth hacking" e o outro dizia "aquisição de usuários". Mesma intenção, zero sobreposição.

Reconstruímos o motor usando Pinecone vector search e embeddings da OpenAI. Duas semanas após o lançamento, as taxas de aceitação de matches subiram de 23% para 61%. Hoje, o onSpark processa mais de 17.000 perfis profissionais nesse mesmo índice do Pinecone todos os dias, retornando candidatos ranqueados em menos de 120ms.

Este post é o mergulho técnico profundo que eu gostaria que existisse quando comecei. Você vai ter a arquitetura completa, todos os detalhes de implementação em TypeScript, a estratégia de embedding que realmente funciona em escala, e as otimizações de produção que mantêm os custos sob controle acima da marca de 10 mil usuários.

Ao final, você vai entender:

  • Por que a busca vetorial supera o matching baseado em filtros para perfis profissionais complexos
  • Como estruturar embeddings para similaridade multidimensional
  • A configuração do índice Pinecone e o pipeline de upsert para mais de 17 mil registros
  • Como consultar, pontuar e ranquear candidatos em tempo real
  • Otimizações de produção incluindo sharding por namespace e re-indexação em lote

O Problema: Por Que Matching por Palavras-Chave Falha para Pessoas

Antes de entrar no Pinecone, vale a pena entender por que a abordagem ingênua falha. Isso não é um ponto acadêmico. Nos custou três meses de churn de usuários antes de corrigirmos.

Um profissional no onSpark preenche um onboarding por voz (gerenciado pelo Vapi.ai) que faz quatro perguntas:

  1. Qual é a sua formação profissional?
  2. No que você está trabalhando agora?
  3. Que tipo de parcerias você está buscando?
  4. O que você pode oferecer a um parceiro?

Essas respostas são transcritas em um perfil estruturado. O desafio: duas pessoas podem descrever a mesma realidade profissional com linguagens completamente diferentes.

Usuário A: "Sou líder de go-to-market com experiência em PLG. Procuro um cofundador técnico que consiga entregar rápido."

Usuário B: "Vendas e crescimento é o meu forte. Construí dois produtos SaaS até o scale-up. Quero um parceiro de engenharia que se mova rápido."

Um sistema de palavras-chave dá a esses usuários similaridade próxima de zero. Um modelo de similaridade vetorial dá a eles uma distância cosseno de aproximadamente 0,07 — extremamente próximos. Essa diferença é o produto inteiro.

O segundo problema era a assimetria de intenção. A filtragem permite combinar "procurando X" com "oferece X" de forma rígida como um table-join. Mas a intenção real de parceria vive em um espectro. Alguém oferecendo "apresentações estratégicas a investidores" também é um match para quem busca "apoio em captação de recursos". A similaridade semântica captura isso; a filtragem por palavras-chave não.

Por Que Busca Vetorial

A busca vetorial converte texto em uma representação numérica de alta dimensão onde a distância semântica se mapeia para a distância geométrica. Textos com significados similares ficam próximos nesse espaço, independentemente das palavras específicas usadas.

O processo é:

  1. Passar o texto do perfil por um modelo de embedding (usamos text-embedding-3-large)
  2. O modelo retorna um array de floats com 3072 dimensões
  3. Armazenar esse vetor no Pinecone junto com um ID de perfil e metadados
  4. No momento da consulta, gerar o embedding do perfil do usuário solicitante da mesma forma
  5. O Pinecone retorna os vizinhos mais próximos por similaridade cosseno em milissegundos

Para matching de parceiros, isso significa que estamos encontrando perfis semanticamente compatíveis — não apenas sintaticamente similares. Esse é o diferencial fundamental.

Pipeline de Matching Vetorial

Do texto bruto do perfil aos candidatos ranqueados

Texto do Perfil
OpenAI Embed
Índice Pinecone
Matches Ranqueados

Entrada

Transcrição de voz + campos estruturados

Modelo

Array de floats com 3072 dimensões

Armazenamento

17K+ vetores, métrica cosseno

Saída

Top-K com scores em <120ms

Taxa de aceitação de matches: 23% → 61% após migração

Configuração do Pinecone

Criando o Índice

Comece pelo dashboard do Pinecone ou pelo SDK deles. Para o onSpark eu uso o SDK para que a configuração do índice seja versionada e reproduzível.

// 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);
}

Algumas decisões que vale explicar aqui:

Métrica cosseno ao invés de Euclidiana: Para embeddings de texto, similaridade cosseno é a escolha certa. Ela mede o ângulo entre vetores ao invés da distância absoluta, o que significa que dois perfis com conteúdo similar mas verbosidades diferentes acabam corretamente próximos um do outro. A distância Euclidiana penaliza textos mais longos.

Serverless ao invés de pod-based: Com 17 mil registros estamos bem dentro da eficiência de preço do serverless. Índices pod-based fazem sentido acima de aproximadamente 1M de vetores ou quando você precisa de SLAs de latência de consulta garantidos. Para nossa carga de trabalho, serverless dá p99 abaixo de 150ms e custa uma fração de pods provisionados.

Dimensão 3072: Esta é a dimensão nativa de saída do text-embedding-3-large. Você pode solicitar uma dimensão menor via API (útil para reduzir custos de armazenamento), mas descobrimos que a dimensão completa melhorou significativamente a qualidade dos matches — vale os ~$0,40/dia extras em armazenamento na nossa escala.

Estratégia de Embedding

É aqui que a maioria das implementações de busca vetorial erra. Se você simplesmente concatenar todos os campos do perfil e gerar o embedding da string resultante, você perde sinal estrutural. O modelo de embedding comprime tudo em um único ponto, e as janelas de contexto dos diferentes campos competem entre si.

Para o onSpark, usamos uma estratégia de embedding composto: construímos uma única string rica, mas a estruturamos de forma que o conteúdo semanticamente mais importante apareça primeiro e com enquadramento natural. Transformers prestam mais atenção aos tokens iniciais.

Construindo o Documento do Perfil

// 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."

Gerando 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;
}

Um detalhe importante: a API de embeddings da OpenAI retorna resultados em ordem arbitrária quando você envia um lote. A ordenação por index garante que o array de saída esteja alinhado com o array de entrada. Pular isso causa um bug sutil onde perfis recebem embeddings errados — levamos dois dias para rastrear durante nossa indexação em massa inicial.

Indexando Perfis

Pipeline de 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);
}

Disparando a Indexação em Atualizações de Perfil

Em produção, a indexação de perfis acontece em três lugares:

  1. Webhook do Vapi — após o onboarding por voz ser concluído, a transcrição é processada e o novo perfil é indexado imediatamente
  2. API de edição de perfil — sempre que um usuário atualiza campos, re-geramos o embedding e fazemos upsert
  3. Job noturno em lote — captura qualquer descompasso, re-indexa todos os perfis modificados nas últimas 24 horas
// 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" });
}

Gatilhos de Indexação

Três caminhos que mantêm o índice Pinecone atualizado

Onboarding por Voz
1Chamada Vapi encerra
2Transcrição processada
3Upsert disparado de forma assíncrona
Tempo real
API de Edição de Perfil
1Usuário salva alterações
2Escrita no banco concluída
3Re-embedding e upsert
Sob demanda
Job Noturno em Lote
1Buscar modificados nas últimas 24h
2Embedding em massa + upsert
3Registrar relatórios de desvio
Diário às 2h UTC

Consultando Matches

A Consulta Principal de Match

// 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;
}

Por Que Definimos minScore em 0,72

Isso foi derivado empiricamente ao longo de várias semanas de testes A/B. Veja como a distribuição de scores ficou em 5.000 consultas amostradas:

Faixa de scoreInterpretaçãoTaxa de aceitação do usuário
0,90+Perfis quase duplicados82% (frequentemente muito similares, baixa novidade)
0,80–0,90Altamente compatíveis74%
0,72–0,80Forte compatibilidade61% (ponto ideal)
0,62–0,72Match moderado31%
Abaixo de 0,62Fraco ou ruído9%

A taxa de aceitação em 0,72–0,80 é na verdade maior que em 0,90+ porque a novidade importa. Dois fundadores com históricos muito similares não agregam tanto valor um ao outro quanto dois profissionais complementares em áreas adjacentes. O ponto ideal captura "altamente compatível mas distinto o suficiente para ser útil".

Pontuação e Ranqueamento

Os scores brutos do Pinecone são um bom começo, mas não são o sinal final de ranqueamento. Aplicamos uma camada de pontuação pós-processamento que incorpora três fatores adicionais.

O Score Composto

// 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);
}

Os três sinais adicionais:

Frescor (15%): Um perfil atualizado há dois dias tem mais chance de refletir a intenção atual do que um que não é tocado há oito meses. Isso é especialmente verdade no contexto de startups em estágio inicial, onde as pessoas pivotam com frequência.

Localização (15%): Matches na mesma cidade convertem em reuniões presenciais a uma taxa 2,3x maior que matches apenas remotos, com base nos nossos dados. Damos um impulso modesto mas não filtramos candidatos remotos.

Penalidade de novidade: Este é o sinal mais importante depois do score vetorial. Se um usuário já viu um perfil cinco vezes e nunca se conectou, mostrar novamente é ruído. Matches recusados recebem uma penalidade de 50%; mostrados anteriormente mas sem ação recebem uma penalidade de 15%.

Detalhamento do Score Composto

Como scores vetoriais brutos se tornam ranqueamentos finais

Similaridade Vetorial (70%)Score cosseno do Pinecone
Frescor do Perfil (15%)Dias desde a última atualização
Sinal de Localização (15%)Mesma cidade = +1.0

Depois multiplicado pelo fator de novidade:

Nunca mostrado: 1.0xMostrado, sem ação: 0.85xRecusado: 0.5x
O ranqueamento final alimenta o feed de matches

Otimizações de Produção

Com 1.000 usuários, nada disso importa muito. Com 17.000 usuários e centenas de consultas por minuto durante horários de pico, pequenas ineficiências se acumulam rápido. Estas são as quatro otimizações que nos mantiveram abaixo do orçamento e abaixo de 120ms de latência p95.

1. Sharding por Namespace por Estágio

Namespaces do Pinecone permitem particionar um índice. Fazemos sharding por estágio da empresa, então um fundador em "estágio de ideia" só consulta o subconjunto de perfis que se declararam abertos a parcerias em estágio inicial.

// 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
}

Isso sozinho reduziu o tempo médio de consulta em 38% porque cada consulta varre um espaço vetorial menor. A lógica de "namespaces adjacentes" garante que um fundador em estágio seed ainda possa fazer match com um fundador em estágio pre-seed — você consulta múltiplos namespaces e faz merge dos resultados.

2. Cache de Embeddings

Gerar embeddings custa dinheiro e adiciona latência. Embeddings de perfil raramente mudam, então os cacheamos no Redis com um TTL baseado no timestamp updatedAt do perfil.

// 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;
}

Taxa de cache hit em produção: 94%. A maioria das consultas vem de usuários verificando seu feed de matches, que recupera perfis existentes ao invés de re-gerar embeddings. Isso reduz nossos custos de embedding da OpenAI em aproximadamente 15x comparado a computar do zero em cada consulta.

3. Cache de Matches com Stale-While-Revalidate

O feed de matches em si é cacheado por usuário com um TTL curto. Consultas ao Pinecone são rápidas, mas quando 500 usuários checam seu feed às 9h da manhã, a carga concorrente se acumula.

// 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. Re-indexação em Lote em Mudanças de Schema

Quando mudamos a função buildProfileDocument — adicionando um novo campo ou reordenando seções — todos os 17 mil embeddings ficam obsoletos porque foram computados a partir de uma estrutura de documento anterior. Re-indexamos o corpus completo em lotes em background para evitar bloquear a API principal.

// 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);

Uma re-indexação completa de 17 mil perfis leva cerca de 12 minutos e custa aproximadamente $4,80 em chamadas de embedding da OpenAI nos preços atuais. Executamos durante horários de baixo tráfego quando fazemos deploy de mudanças de schema, e o intervalo entre lotes nos impede de atingir os limites de taxa da OpenAI no endpoint de embeddings.

Resultados e Métricas

Após rodar este sistema em produção por oito meses, aqui estão os números honestos.

Escala:

  • 17.400 perfis ativos indexados no Pinecone
  • ~2.800 consultas de match por dia no pico
  • Tamanho total do índice: ~420MB no Pinecone serverless

Qualidade dos Matches (comparado ao sistema anterior de palavras-chave):

  • Taxa de aceitação de matches: 23% → 61% (+165%)
  • Taxa de "nenhum match encontrado": 34% → 4% (o sistema antigo retornava resultados vazios para perfis de nicho)
  • Qualidade reportada pelo usuário (NPS de 5 estrelas): 2,9 → 4,1

Performance:

  • Latência p50 de consulta: 68ms (apenas Pinecone)
  • Latência p95 de consulta: 114ms (apenas Pinecone)
  • Feed de matches completo com pontuação: p95 em 210ms (inclui verificação de cache Redis, camada de pontuação)
  • Taxa de cache hit (feed de matches): 71%

Custos (mensal):

  • Pinecone serverless: $38/mês
  • Embeddings OpenAI (com cache): $22/mês
  • Redis (Upstash): $9/mês
  • Infraestrutura total para matching: ~$69/mês com 17 mil usuários

Isso é $0,004 por usuário ativo por mês para toda a infraestrutura de matching. Tínhamos originalmente orçado $300/mês baseado em estimativas ingênuas de custo por consulta. O cache de embeddings foi a maior alavanca isolada.

O que nos surpreendeu:

A penalidade de novidade teve um impacto maior que o esperado. Antes de adicioná-la, usuários estavam silenciosamente abandonando a plataforma porque continuavam vendo os mesmos 10 perfis. Depois do decaimento de novidade, a retenção sessão-a-sessão no feed de matches melhorou em 28%. Mostrar menos matches, porém mais variados, acabou importando mais do que mostrar os matches com maior pontuação repetidamente.

A outra surpresa foi o quanto a completude do perfil afetava a qualidade dos matches. Um perfil totalmente preenchido obtém scores de match 3,5x melhores em média que um esparso. Agora mostramos um score de completude no app e vimos uma redução de 40% em consultas de baixa qualidade depois que os usuários entenderam a conexão.

FAQ

P: Por que não usar a busca híbrida do Pinecone com vetores esparsos? R: Experimentamos. Para nosso caso de uso — descrições profissionais longas — embeddings densos sozinhos superaram a busca híbrida. A híbrida é mais valiosa quando você tem consultas curtas contra documentos longos, como busca de produtos em e-commerce. Dois textos longos de perfil se comparando é o ponto ideal para recuperação puramente densa.

P: Como vocês lidam com perfis em idiomas diferentes do inglês? R: text-embedding-3-large é multilíngue. Na prática, a maioria dos perfis do onSpark está em inglês ou francês. Geramos embeddings como estão e a similaridade ainda funciona entre idiomas com uma pequena degradação de qualidade (aproximadamente 0,05 scores mais baixos para matches entre idiomas nos nossos testes). Planejamos adicionar filtragem de metadados por idioma em uma versão futura.

P: O que acontece quando o perfil de alguém muda drasticamente? R: O re-embedding ao salvar dispara imediatamente. O novo embedding sobrescreve o antigo no Pinecone via upsert. O cache de matches para esse usuário é invalidado na próxima requisição. A única janela onde matches obsoletos são mostrados é dentro do TTL de cache de matches de 30 minutos, que aceitamos como um tradeoff razoável.

P: Seria possível fazer isso sem o Pinecone — apenas pgvector no Postgres? R: Sim, e para menos de 50 mil perfis eu consideraria seriamente. pgvector com um índice IVFFlat ou HNSW lida com essa escala tranquilamente. Escolhemos o Pinecone pela infraestrutura gerenciada, filtragem de metadados e suporte a namespaces. Se você está no Supabase, pgvector com vector_cosine_ops funcionaria bem e reduziria a superfície operacional.

P: Qual é a versão mínima viável disso? R: Três funções: embedText, upsertProfile e findMatches. Você consegue entregar um protótipo funcional em um dia. Comece com text-embedding-3-small (custo menor, 1536 dimensões) e faça upgrade se a qualidade dos matches não for boa o suficiente. A maior parte da complexidade neste post é trabalho de otimização que vem depois.

Conclusão

A busca vetorial transformou o matching do onSpark de uma experiência frustrante e limitada por palavras-chave para uma que entende a intenção profissional em nível semântico. A escala de 17 mil perfis em que operamos hoje não foi a parte difícil — a parte difícil foi descobrir que sobreposição de palavras-chave é a abstração errada para combinar pessoas, e que similaridade cosseno de bons embeddings chega muito mais perto do que os usuários realmente querem dizer.

A implementação é direta. O SDK do Pinecone é bem documentado, a API de embeddings da OpenAI é confiável, e a camada de pontuação composta no topo adiciona os sinais humanos (frescor, localização, novidade) que a similaridade vetorial pura não captura. O custo total de infraestrutura na nossa escala atual é inferior a $70/mês — um erro de arredondamento comparado ao valor do produto.

Se você está construindo qualquer tipo de matching, recomendação ou produto de busca por similaridade, a busca vetorial é a fundação certa. O ecossistema amadureceu ao ponto em que a carga operacional é mínima.


Construindo um produto de matching ou recomendação e quer discutir a arquitetura? Entre em contato e posso ajudar você a descobrir se a busca vetorial é a escolha certa para o seu caso de uso específico.

Posts Relacionados:

  • [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:

Receba insights práticos de engenharia

Agentes de voz com IA, fluxos de automação e entregas rápidas. Sem spam, cancele quando quiser.