O Problema Que Ninguém Me Avisou
Quando comecei a construir o onSpark, achei que a parte difícil seria conseguir que profissionais se cadastrassem. Eu estava errado. A parte difícil era fazer com que o primeiro match parecesse ter sido feito por alguém que realmente leu o perfil da pessoa.
Curadoria manual não escala. Com 200 usuários, você consegue avaliar a compatibilidade a olho nu. Com 2.000, você está chutando. Com 17.000, você está se afogando. A equipe fundadora tentou tags, categorias e busca por texto livre. As três abordagens produziram o mesmo resultado: apresentações de baixa qualidade, baixas taxas de resposta e usuários desistindo, dizendo que a plataforma "não os entendia".
Esse foi o briefing que herdei quando fui chamado para reconstruir a camada de matching. Este post é um walkthrough técnico completo do que construímos: um sistema de onboarding por voz apoiado por embeddings da OpenAI, busca vetorial com Pinecone e um scorer de compatibilidade multifatorial. A stack roda em um frontend Angular com backend Node.js implantado no Google Cloud Run.
Quando lançamos a v2, as taxas de aceitação de matches subiram de 14% para 61%. Veja como chegamos lá.
O Desafio: Matching de Parceiros em Escala
O matching de parcerias é mais difícil que matching de emprego ou de relacionamento por um motivo específico: a superfície de compatibilidade é enorme. Dois profissionais podem compartilhar uma indústria, um tamanho de audiência e uma meta de crescimento, mas um quer um acordo de co-promoção enquanto o outro quer revenue share. São parceiros incompatíveis independentemente de qualquer outro sinal.
A abordagem clássica, filtros facetados mais ordenação por relevância, falha porque:
- Usuários se descrevem de forma inconsistente. Uma pessoa diz "fundador de SaaS", outra diz "empreendedor de software B2B". Ambos são válidos. Nenhum encontra o outro por busca de palavras-chave.
- Objetivos mudam. Alguém que se cadastrou buscando um convidado para podcast agora quer um parceiro para webinar conjunto. Perfis estáticos ficam obsoletos imediatamente.
- Confiança é assimétrica. Um novo usuário com 500 seguidores no LinkedIn e um lançamento de produto verificado é um parceiro melhor que uma conta antiga com 50.000 seguidores e nenhum ativo verificável.
Precisávamos de um sistema que entendesse significado, não palavras-chave, e pudesse ranquear candidatos por uma pontuação composta em vez de uma única métrica de similaridade. Isso nos levou a embeddings e busca vetorial desde o primeiro dia.
Onboarding por Voz com Vapi.ai
O primeiro insight que mudou tudo: os dados de perfil mais ricos não vêm de formulários. Vêm de conversas.
Quando mudamos de um formulário de cadastro com 12 campos para uma entrevista de 7 minutos com IA por voz, a completude média dos perfis foi de 38% para 91%. Mais importante, os dados que extraímos eram qualitativamente diferentes. As pessoas contavam à IA coisas que nunca digitariam em um campo de texto.
Por Que Voz Supera Formulários
Formulários otimizam para velocidade de preenchimento. Usuários abreviam, pulam campos opcionais e colam textos genéricos. Uma entrevista conversacional é diferente porque uma boa pergunta de acompanhamento traz à tona o detalhe que o usuário não pensou em oferecer voluntariamente.
Por exemplo: um usuário digita "eu tenho uma newsletter" em um campo de formulário. O assistente Vapi.ai ouve isso e pergunta: "Quais tópicos você cobre e quem é seu leitor típico?" A resposta - "Eu escrevo sobre operações para marcas Shopify que faturam entre um e cinco milhões de dólares" - é infinitamente mais útil para matching.
A Arquitetura da Entrevista
Construímos o assistente de onboarding no Vapi.ai porque ele nos deu integração limpa de function-calling com nosso próprio backend. O assistente segue um guia de entrevista estruturado mas conversacional cobrindo quatro domínios:
- Identidade central - função, indústria, estágio do negócio
- Ativos de parceria - audiência, alcance, relacionamentos existentes, canais de conteúdo
- Objetivos de parceria - o que querem obter de colaborações nos próximos 90 dias
- Preferências de acordo - apetite para revenue share, compromisso de tempo, requisitos de exclusividade
A configuração Vapi para o assistente se parece com isto:
// vapi-onboarding-assistant.config.ts
import type { CreateAssistantDTO } from "@vapi-ai/server-sdk";
export const onboardingAssistantConfig: CreateAssistantDTO = {
name: "onSpark Onboarding",
firstMessage:
"Hi, I'm here to build your partnership profile. " +
"This takes about seven minutes and everything you share helps us find you better matches. " +
"Let's start - what does your business do, and who do you serve?",
model: {
provider: "openai",
model: "gpt-4o",
temperature: 0.3,
systemPrompt: ONBOARDING_SYSTEM_PROMPT,
tools: [
{
type: "function",
function: {
name: "save_profile_section",
description:
"Save a completed section of the user's partnership profile. " +
"Call this after gathering sufficient information for each domain.",
parameters: {
type: "object",
properties: {
section: {
type: "string",
enum: ["identity", "assets", "goals", "deal_preferences"],
},
data: {
type: "object",
description: "Structured data extracted from the conversation",
},
confidence: {
type: "number",
description: "0-1 confidence score for the extracted data",
},
},
required: ["section", "data", "confidence"],
},
},
},
{
type: "function",
function: {
name: "complete_onboarding",
description:
"Mark onboarding as complete and trigger profile embedding generation.",
parameters: {
type: "object",
properties: {
summary: {
type: "string",
description:
"A 2-3 sentence plain-language summary of the user's partnership profile.",
},
},
required: ["summary"],
},
},
},
],
},
voice: {
provider: "11labs",
voiceId: "21m00Tcm4TlvDq8ikWAM",
},
endCallFunctionEnabled: true,
recordingEnabled: true,
transcriptPlan: {
enabled: true,
},
};A função save_profile_section dispara incrementalmente durante a chamada. Quando o usuário desliga, já temos JSON estruturado cobrindo os quatro domínios. Nenhum pós-processamento necessário.
Extraindo Dados Estruturados da Transcrição
Mesmo com function calling, os dados brutos de cada seção precisavam de normalização antes do embedding. Executamos uma segunda passagem de LLM para produzir um objeto de perfil canônico:
// profile-extractor.service.ts
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
interface RawProfileSection {
section: string;
data: Record<string, unknown>;
confidence: number;
}
interface NormalizedProfile {
userId: string;
industry: string;
businessStage: "idea" | "mvp" | "growth" | "scale";
audienceSize: number;
audienceDescription: string;
contentChannels: string[];
partnershipGoals: string[];
dealTypes: string[];
timeCommitmentHoursPerMonth: number;
revenueShareWilling: boolean;
summary: string;
rawSections: RawProfileSection[];
extractedAt: string;
}
export async function normalizeProfileSections(
userId: string,
sections: RawProfileSection[],
callSummary: string
): Promise<NormalizedProfile> {
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
temperature: 0,
response_format: { type: "json_object" },
messages: [
{
role: "system",
content:
"You are a data normalization assistant. Given raw profile sections " +
"from a voice interview, produce a single canonical JSON profile object " +
"matching the specified schema exactly. Infer missing numeric fields " +
"from context where possible.",
},
{
role: "user",
content: JSON.stringify({ sections, callSummary }),
},
],
});
const parsed = JSON.parse(
completion.choices[0].message.content ?? "{}"
) as Omit<NormalizedProfile, "userId" | "rawSections" | "extractedAt">;
return {
...parsed,
userId,
rawSections: sections,
extractedAt: new Date().toISOString(),
};
}Construindo o Motor de Matching
Com perfis normalizados armazenados no Firestore, a próxima camada era o motor de matching em si. O objetivo de design era simples de enunciar e difícil de executar: dado um usuário, retornar os top N parceiros compatíveis ranqueados por uma pontuação que captura mais do que similaridade textual.
Embedding de Perfis com OpenAI
A entrada do embedding não é o JSON bruto do perfil. Isso codificaria nomes de campos e estrutura no vetor, o que é ruído. Em vez disso, renderizamos cada perfil em uma passagem rica em linguagem natural antes do embedding:
// profile-passage.builder.ts
export function buildProfilePassage(profile: NormalizedProfile): string {
const goalsList = profile.partnershipGoals.join(", ");
const channelsList = profile.contentChannels.join(", ");
const dealsList = profile.dealTypes.join(", ");
return [
`${profile.businessStage} stage business in the ${profile.industry} industry.`,
`Audience: ${profile.audienceDescription} (approximately ${profile.audienceSize.toLocaleString()} people).`,
`Primary content channels: ${channelsList}.`,
`Partnership goals for the next 90 days: ${goalsList}.`,
`Open to the following deal types: ${dealsList}.`,
`Available approximately ${profile.timeCommitmentHoursPerMonth} hours per month for partnership work.`,
profile.revenueShareWilling
? "Open to revenue share arrangements."
: "Prefers non-revenue-share arrangements.",
`Summary: ${profile.summary}`,
]
.filter(Boolean)
.join(" ");
}Então fazemos o embedding dessa passagem usando text-embedding-3-large, que produz vetores de 3.072 dimensões. Para custos de armazenamento, truncamos para 1.536 dimensões - a primeira metade do vetor retém ~96% da qualidade de recuperação em nossos benchmarks, e reduz pela metade os custos de armazenamento no Pinecone.
// embedding.service.ts
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const EMBEDDING_DIMENSIONS = 1536;
export async function generateProfileEmbedding(
passage: string
): Promise<number[]> {
const response = await openai.embeddings.create({
model: "text-embedding-3-large",
input: passage,
dimensions: EMBEDDING_DIMENSIONS,
});
return response.data[0].embedding;
}Armazenando Vetores no Pinecone
Cada registro no Pinecone armazena o embedding junto com um payload de metadados que permite pré-filtragem antes da busca por similaridade. A pré-filtragem é crítica com mais de 17 mil registros - sem ela, cada consulta varre o índice inteiro.
// pinecone.service.ts
import { Pinecone } from "@pinecone-database/pinecone";
const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });
const index = pinecone.index(process.env.PINECONE_INDEX_NAME!);
interface ProfileMetadata {
userId: string;
industry: string;
businessStage: string;
audienceSize: number;
revenueShareWilling: boolean;
dealTypes: string[];
updatedAt: number;
}
export async function upsertProfileVector(
profile: NormalizedProfile,
embedding: number[]
): Promise<void> {
const metadata: ProfileMetadata = {
userId: profile.userId,
industry: profile.industry,
businessStage: profile.businessStage,
audienceSize: profile.audienceSize,
revenueShareWilling: profile.revenueShareWilling,
dealTypes: profile.dealTypes,
updatedAt: Date.now(),
};
await index.upsert([
{
id: profile.userId,
values: embedding,
metadata,
},
]);
}
export interface MatchCandidate {
userId: string;
cosineSimilarity: number;
metadata: ProfileMetadata;
}
export async function findSimilarProfiles(
queryEmbedding: number[],
requestingUserId: string,
filters: Partial<ProfileMetadata>,
topK = 50
): Promise<MatchCandidate[]> {
const queryResponse = await index.query({
vector: queryEmbedding,
topK,
includeMetadata: true,
filter: buildPineconeFilter(filters),
});
return (queryResponse.matches ?? [])
.filter((m) => m.id !== requestingUserId)
.map((m) => ({
userId: m.id,
cosineSimilarity: m.score ?? 0,
metadata: m.metadata as ProfileMetadata,
}));
}
function buildPineconeFilter(
filters: Partial<ProfileMetadata>
): Record<string, unknown> {
const conditions: Record<string, unknown>[] = [];
if (filters.industry) {
conditions.push({ industry: { $eq: filters.industry } });
}
if (filters.audienceSize) {
conditions.push({
audienceSize: {
$gte: filters.audienceSize * 0.1,
$lte: filters.audienceSize * 10,
},
});
}
if (!conditions.length) return {};
return conditions.length === 1 ? conditions[0] : { $and: conditions };
}Pontuação e Ranqueamento
A similaridade de cosseno bruta do Pinecone é um sinal forte, mas não conta toda a história. Adicionamos três dimensões extras em uma pontuação final de compatibilidade: alinhamento de objetivos, compatibilidade de acordo e índice de confiança.
A Arquitetura do Sistema
Arquitetura do Motor de Matching do onSpark
Da chamada de voz aos resultados de match ranqueados
Cálculo da Pontuação de Compatibilidade
A pontuação final é uma soma ponderada de quatro componentes:
// compatibility-scorer.ts
interface ScoringWeights {
semanticSimilarity: number;
goalAlignment: number;
dealCompatibility: number;
trustIndex: number;
}
const DEFAULT_WEIGHTS: ScoringWeights = {
semanticSimilarity: 0.4,
goalAlignment: 0.3,
dealCompatibility: 0.2,
trustIndex: 0.1,
};
interface CompatibilityResult {
userId: string;
finalScore: number;
breakdown: {
semantic: number;
goals: number;
deals: number;
trust: number;
};
explanation: string;
}
export function computeCompatibilityScore(
requester: NormalizedProfile,
candidate: MatchCandidate,
candidateProfile: NormalizedProfile,
trustScore: number,
weights: ScoringWeights = DEFAULT_WEIGHTS
): CompatibilityResult {
const semantic = candidate.cosineSimilarity;
const goals = computeGoalAlignment(requester, candidateProfile);
const deals = computeDealCompatibility(requester, candidateProfile);
const trust = trustScore;
const finalScore =
semantic * weights.semanticSimilarity +
goals * weights.goalAlignment +
deals * weights.dealCompatibility +
trust * weights.trustIndex;
return {
userId: candidate.userId,
finalScore,
breakdown: { semantic, goals, deals, trust },
explanation: buildExplanation(requester, candidateProfile, {
semantic,
goals,
deals,
trust,
}),
};
}
function computeGoalAlignment(
a: NormalizedProfile,
b: NormalizedProfile
): number {
// Similaridade de Jaccard nos conjuntos de tokens de objetivos
const setA = new Set(a.partnershipGoals.map(normalizeGoalToken));
const setB = new Set(b.partnershipGoals.map(normalizeGoalToken));
const intersection = new Set([...setA].filter((g) => setB.has(g)));
const union = new Set([...setA, ...setB]);
return union.size === 0 ? 0 : intersection.size / union.size;
}
function computeDealCompatibility(
a: NormalizedProfile,
b: NormalizedProfile
): number {
const sharedDealTypes = a.dealTypes.filter((d) => b.dealTypes.includes(d));
const revenueShareConflict =
a.revenueShareWilling !== b.revenueShareWilling ? 0.3 : 0;
const timeCompatibility =
1 -
Math.abs(
a.timeCommitmentHoursPerMonth - b.timeCommitmentHoursPerMonth
) /
Math.max(a.timeCommitmentHoursPerMonth, b.timeCommitmentHoursPerMonth, 1);
const dealTypeScore =
sharedDealTypes.length /
Math.max(a.dealTypes.length, b.dealTypes.length, 1);
return Math.max(
0,
dealTypeScore * 0.5 + timeCompatibility * 0.5 - revenueShareConflict
);
}
function normalizeGoalToken(goal: string): string {
return goal.toLowerCase().trim().replace(/\s+/g, "_");
}O Índice de Confiança
O índice de confiança não é uma pontuação de reputação - é uma pontuação de completude e verificação de sinais. Um perfil ganha pontos de confiança por:
- Completar o onboarding por voz (40 pontos)
- Verificar um perfil LinkedIn via OAuth (20 pontos)
- Ter pelo menos uma parceria concluída com avaliação positiva (20 pontos)
- Fornecer métricas de audiência verificáveis (contagem de assinantes de newsletter, downloads de podcast) (20 pontos)
Normalizamos para 0-1 e armazenamos no Firestore junto com o perfil, atualizado a cada evento de verificação.
O Pipeline de Negociação
O matching produz uma lista ranqueada. Converter essa lista em parcerias reais exigiu um pipeline de negociação que mantivesse o momentum sem intervenção manual.
O pipeline tem quatro estágios: Sugerido (match apresentado ao usuário), Interessado (usuário sinaliza interesse), Apresentado (interesse mútuo, apresentação enviada), Ativo (acordo em andamento).
Construímos um job no Cloud Scheduler que roda a cada 6 horas e avança ou expira negociações baseado em limites de tempo de resposta. Se um usuário não age em um match Sugerido dentro de 72 horas, o match sai do feed e é substituído. Isso mantém o feed atualizado e cria um sinal de urgência sutil sem dark patterns.
A mensagem de apresentação é gerada pelo GPT-4o e referencia ativos compartilhados específicos entre ambas as partes. As versões iniciais usavam um template genérico. A mudança para geração personalizada reduziu as taxas de ignorar apresentações de 44% para 18%.
// introduction-generator.service.ts
export async function generateIntroductionMessage(
sender: NormalizedProfile,
recipient: NormalizedProfile,
compatibilityResult: CompatibilityResult
): Promise<string> {
const prompt = [
"Write a warm, professional partnership introduction message.",
"The message is sent from the platform on behalf of the sender to the recipient.",
"Reference one specific shared goal and one complementary asset.",
"Keep it under 120 words. Do not use salesy language.",
"",
`Sender summary: ${sender.summary}`,
`Recipient summary: ${recipient.summary}`,
`Top compatibility reason: ${compatibilityResult.explanation}`,
].join("\n");
const completion = await openai.chat.completions.create({
model: "gpt-4o",
temperature: 0.6,
max_tokens: 200,
messages: [{ role: "user", content: prompt }],
});
return completion.choices[0].message.content?.trim() ?? "";
}Desafios de Escala
Ir do protótipo para 17.000 perfis ativos não foi uma linha reta. Estes foram os três problemas que mais nos custaram tempo de engenharia.
Latência de Embedding na Conclusão do Onboarding
Quando uma chamada de voz termina, o usuário espera ver seus primeiros matches em segundos. O pipeline de embedding - normalizar, construir passagem, gerar embedding, upsert no Pinecone - levava 3-4 segundos de forma síncrona, o que parecia lento na UI Angular.
Resolvemos isso com uma abordagem em duas fases. Ao final da chamada, retornamos imediatamente um estado de loading e mostramos ao usuário uma tela "construindo seu perfil". Um job no Cloud Tasks executa o pipeline completo de forma assíncrona. O frontend Angular faz polling em um endpoint /onboarding-status a cada 2 segundos e transiciona para a visualização de matches quando o job completa. A latência percebida caiu de 4 segundos para menos de 1 segundo.
Obsolescência do Índice Vetorial
Perfis mudam. Um usuário que completou o onboarding seis meses atrás pode ter objetivos completamente diferentes. Adicionamos um sinal de frescor de perfil ao índice de confiança: perfis com mais de 90 dias sem atualização recebem uma penalidade de 15 pontos de frescor. Isso faz com que usuários recentemente ativos apareçam mais alto nos resultados e cria um prompt natural de reengajamento.
Também adicionamos um fluxo leve de "atualização rápida" - um check-in de 90 segundos por voz usando o mesmo assistente Vapi - que atualiza apenas as seções de objetivos e preferências de acordo e dispara um re-embedding sem repetir o onboarding completo.
Cold Start para Novos Usuários
Um usuário completamente novo sem histórico e com um perfil recém-criado não tem sinal comportamental. Seus primeiros matches dependem inteiramente do embedding semântico. Descobrimos que a qualidade dos 3 primeiros matches é decisiva para retenção - usuários que interagiram com pelo menos um match nas primeiras 48 horas tinham 4,7x mais chance de ainda estar ativos em 30 dias.
Para melhorar a qualidade no cold start, aumentamos o topK do Pinecone para 100 para usuários com menos de 7 dias, depois re-ranqueamos agressivamente usando alinhamento de objetivos e compatibilidade de acordo. Novos usuários também recebem um flag manual de "controle de qualidade" que impede que perfis incompletos (confiança abaixo de 0,7 em qualquer seção) apareçam nos feeds de outros usuários até completarem o check-in de atualização rápida.
Resultados e Métricas
Após seis meses executando a v2 em produção, os números ficaram assim:
- 17.400 perfis ativos com embeddings completos no Pinecone
- Taxa de aceitação de matches: 61% (contra 14% com o sistema baseado em filtros)
- Taxa de resposta a apresentações: 49% (contra 22%)
- Parcerias ativas criadas: mais de 3.200 em toda a base de usuários
- Taxa de conclusão do onboarding por voz: 84% (versus 61% para o formulário)
- Latência mediana de matching: 340ms para uma lista ranqueada de 20 candidatos (p99: 820ms)
- Custo do pipeline de embedding: $0,0023 por perfil nos preços atuais da OpenAI
A latência mediana de 340ms nos surpreendeu. O índice HNSW do Pinecone em um deployment baseado em pods resolve a busca vetorial em menos de 80ms. O tempo restante é dividido entre buscar perfis candidatos do Firestore, executar as funções de pontuação e gerar o texto de apresentação.
O maior impulsionador da melhoria na taxa de aceitação não foi o modelo de embedding. Foi a qualidade dos dados do onboarding por voz. Quando rodamos uma ablação onde substituímos perfis derivados de voz por perfis equivalentes preenchidos por formulário para um subconjunto de usuários, a taxa de aceitação caiu de 61% para 39%. A profundidade conversacional dos dados de voz é o verdadeiro diferencial.
Lições Aprendidas
Lixo Entra, Lixo Sai Também Se Aplica a Embeddings
Um embedding é tão bom quanto sua entrada. O construtor de passagem não é uma camada cosmética - é um dos componentes mais importantes do sistema. Passamos duas semanas iterando na estrutura da passagem antes que a qualidade do embedding se estabilizasse. Se os matches parecem errados, verifique a passagem antes de mexer no modelo.
Pré-filtre Agressivamente no Pinecone
Sem filtros de metadados, uma consulta contra 17 mil registros com topK=50 retorna resultados de toda a população. Usuários construindo uma audiência de newsletter SaaS B2B não se beneficiam de serem pareados com influenciadores de e-commerce. Pré-filtrar por indústria e faixa de tamanho de audiência antes da similaridade semântica reduz resultados irrelevantes e melhora a precisão percebida. O custo de uma consulta pré-filtrada é quase idêntico ao de uma não filtrada.
Os Pesos da Pontuação São uma Decisão de Produto, Não de Engenharia
Gastamos tempo demais tratando os pesos da pontuação de compatibilidade como um problema de otimização de engenharia. Não são. Eles refletem valores de produto: quanto a adequação semântica deve importar versus a adequação da estrutura do acordo? A resposta muda dependendo de qual tipo de parceria a plataforma está otimizando. Converse com os usuários, escolha os pesos, meça os resultados, ajuste. Entregue mais rápido.
Voz É Subestimada Como Canal de Coleta de Dados
O campo de onboarding com IA está prestes a mudar significativamente. Abandono de formulários é um problema conhecido. Abandono de voz com 84% de conclusão não é. Quando propus pela primeira vez mudar o onboarding para voz, a resistência foi "os usuários não vão fazer isso". Eles fizeram. A duração modal das nossas chamadas de onboarding por voz é de 6 minutos e 42 segundos. Ninguém preenche um formulário de 7 minutos.
O Que Estamos Construindo a Seguir
O sistema atual é reativo: ele faz matching de usuários baseado em objetivos declarados. A próxima versão será proativa. Estamos experimentando com uma camada de sinais que ingere atividade pública - posts no LinkedIn, aparições em podcasts, conteúdo de newsletters - e infere objetivos não declarados a partir de padrões comportamentais. Um usuário que começa a escrever muito sobre crescimento no YouTube provavelmente está pensando em parcerias de vídeo, mesmo que seu perfil ainda diga "newsletter".
Também estamos explorando embeddings multimodais que combinam a transcrição de voz com características de prosódia do áudio - ritmo, energia, pausas - como um sinal adicional para compatibilidade de estilo de comunicação. Testes iniciais sugerem que isso adiciona ~4 pontos à taxa de aceitação por conta própria.
Conclusão
Construir um motor de matching com IA em escala é um problema de sistemas antes de ser um problema de machine learning. A qualidade da sua coleta de dados, o cuidado na construção da passagem e a honestidade dos pesos da sua pontuação importam mais do que qual modelo de embedding você escolhe.
A combinação de Vapi.ai para onboarding por voz, OpenAI para embeddings e normalização, e Pinecone para armazenamento vetorial nos deu uma stack que é acessível e performática com 17 mil usuários. A arquitetura escala para 10x esse número sem mudanças estruturais.
Se você está construindo um marketplace, uma rede profissional ou qualquer plataforma onde a qualidade das apresentações determina a retenção, estou convencido de que essa abordagem supera o matching baseado em filtros em qualquer escala acima de algumas centenas de usuários.
Se você está trabalhando em algo similar e quer trocar ideias sobre a arquitetura, entre em contato. Tenho interesse particular em conversas sobre pontuação de confiança, onboarding multimodal e escalar o Pinecone além do limite de 100 mil registros.