Introducción
La primera versión de nuestro algoritmo de emparejamiento fue un desastre. Estábamos utilizando coincidencia de palabras clave y cadenas de filtros para conectar profesionales en onSpark, nuestra plataforma de asociaciones impulsada por IA. El resultado: usuarios con objetivos casi idénticos obtenían cero coincidencias porque uno decía "growth hacking" y el otro decía "adquisición de usuarios". Misma intención, cero coincidencia.
Reconstruimos el motor utilizando búsqueda vectorial con Pinecone y embeddings de OpenAI. En las dos semanas posteriores al lanzamiento, las tasas de aceptación de coincidencias subieron del 23% al 61%. Hoy, onSpark procesa más de 17,000 perfiles profesionales a través de ese mismo índice de Pinecone todos los días, devolviendo candidatos de coincidencia ordenados por relevancia en menos de 120ms.
Este artículo es la inmersión técnica detallada que me hubiera gustado que existiera cuando empecé. Obtendrá la arquitectura completa, cada detalle de implementación en TypeScript, la estrategia de embeddings que realmente funciona a escala, y las optimizaciones de producción que mantienen los costos razonables por encima de la marca de 10K usuarios.
Al final, comprenderá:
- Por qué la búsqueda vectorial supera al emparejamiento basado en filtros para perfiles profesionales con matices
- Cómo estructurar embeddings para similitud multidimensional
- La configuración del índice de Pinecone y el pipeline de upsert para más de 17K registros
- Cómo consultar, puntuar y clasificar candidatos en tiempo real
- Optimizaciones de producción incluyendo sharding por namespace y re-indexación por lotes
El Problema: Por Qué la Coincidencia por Palabras Clave Falla con las Personas
Antes de entrar en Pinecone, vale la pena entender por qué el enfoque ingenuo falla. Esto no es un punto académico. Nos costó tres meses de abandono de usuarios antes de solucionarlo.
Un profesional en onSpark completa un onboarding por voz (manejado por Vapi.ai) que hace cuatro preguntas:
- ¿Cuál es su experiencia profesional?
- ¿En qué está trabajando ahora mismo?
- ¿Qué tipo de asociaciones está buscando?
- ¿Qué puede ofrecer a un socio?
Esas respuestas se transcriben en un perfil estructurado. El desafío: dos personas pueden describir exactamente la misma realidad profesional con un lenguaje completamente diferente.
Usuario A: "Soy líder de go-to-market con experiencia en PLG. Busco un cofundador técnico que pueda entregar rápido."
Usuario B: "Lo mío son las ventas y el crecimiento. Llevé dos productos SaaS a la rampa. Quiero un socio de ingeniería que se mueva rápidamente."
Un sistema de palabras clave da a estos usuarios una similitud cercana a cero. Un modelo de similitud vectorial les da una distancia coseno de aproximadamente 0.07, extremadamente cercana. Esa diferencia es todo el producto.
El segundo problema era la asimetría de intención. Los filtros permiten emparejar "busco X" contra "ofrezco X" de una manera rígida tipo join de tablas. Pero la intención real de asociación vive en un espectro. Alguien que ofrece "presentaciones estratégicas a inversores" también es una coincidencia para alguien que busca "apoyo en recaudación de fondos". La similitud semántica captura eso; el filtrado por palabras clave no.
Por Qué la Búsqueda Vectorial
La búsqueda vectorial convierte texto en una representación numérica de alta dimensionalidad donde la distancia semántica se mapea a la distancia geométrica. Los textos con significados similares terminan cerca uno del otro en ese espacio independientemente de las palabras específicas utilizadas.
El proceso es:
- Pasar el texto del perfil a través de un modelo de embeddings (usamos
text-embedding-3-large) - El modelo devuelve un array de flotantes de 3072 dimensiones
- Almacenar ese vector en Pinecone junto con un ID de perfil y metadatos
- Al momento de la consulta, generar el embedding del perfil del usuario solicitante de la misma manera
- Pinecone devuelve los vecinos más cercanos por similitud coseno en milisegundos
Para el emparejamiento de socios, esto significa que estamos encontrando perfiles que son semánticamente compatibles, no solo sintácticamente similares. Esa es la clave del desbloqueo.
Pipeline de Emparejamiento Vectorial
Del texto del perfil en bruto a candidatos de coincidencia clasificados
Entrada
Transcripción de voz + campos estructurados
Modelo
Array de flotantes de 3072 dimensiones
Almacenamiento
17K+ vectores, métrica coseno
Salida
Top-K con puntuaciones en <120ms
Configuración de Pinecone
Creación del Índice
Comience con el dashboard de Pinecone o su SDK. Para onSpark uso el SDK para que la configuración del índice esté versionada y sea reproducible.
// 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);
}Algunas decisiones que vale la pena explicar:
Métrica coseno sobre Euclidiana: Para embeddings de texto, la similitud coseno es la elección correcta. Mide el ángulo entre vectores en lugar de su distancia absoluta, lo que significa que dos perfiles con contenido similar pero diferente nivel de verbosidad terminan correctamente cerca uno del otro. La distancia Euclidiana penaliza textos más largos.
Serverless sobre basado en pods: Con 17K registros estamos bien dentro de la eficiencia de precios de serverless. Los índices basados en pods tienen sentido por encima de aproximadamente 1M de vectores o cuando necesita SLAs garantizados de latencia de consulta. Para nuestra carga de trabajo, serverless nos da un p99 por debajo de 150ms y cuesta una fracción de los pods provisionados.
Dimensión 3072: Esta es la dimensión de salida nativa de text-embedding-3-large. Puede solicitar una dimensión menor a través de la API (útil para reducir costos de almacenamiento), pero encontramos que la dimensión completa mejoró significativamente la calidad de las coincidencias, lo cual vale los aproximadamente $0.40/día adicionales en almacenamiento a nuestra escala.
Estrategia de Embeddings
Aquí es donde la mayoría de las implementaciones de búsqueda vectorial se equivocan. Si simplemente concatena todos los campos del perfil y genera el embedding del string resultante, pierde señal estructural. El modelo de embeddings comprime todo en un solo punto, y las ventanas de contexto de los diferentes campos compiten entre sí.
Para onSpark, usamos una estrategia de embedding compuesto: construimos un solo string enriquecido, pero lo estructuramos de manera que el contenido más importante semánticamente aparezca primero y con un marco natural. Los Transformers prestan más atención a los tokens iniciales.
Construcción del Documento de 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."Generación de 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 detalle importante: la API de embeddings de OpenAI devuelve resultados en un orden arbitrario cuando envía un lote. La ordenación por index asegura que el array de salida se alinee con el array de entrada. Omitir esto causa un bug sutil donde los perfiles obtienen embeddings incorrectos; nos tomó dos días rastrearlo durante nuestra indexación masiva inicial.
Indexación de Perfiles
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);
}Disparadores de Indexación en Actualizaciones de Perfil
En producción, la indexación de perfiles ocurre en tres lugares:
- Webhook de Vapi - después de que se completa el onboarding por voz, la transcripción se procesa y el nuevo perfil se indexa inmediatamente
- API de edición de perfil - cada vez que un usuario actualiza campos, re-generamos el embedding y hacemos upsert
- Trabajo batch nocturno - captura cualquier desviación, re-indexa todos los perfiles modificados en las ú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" });
}Disparadores de Indexación
Tres caminos que mantienen actualizado el índice de Pinecone
Consultas para Coincidencias
La Consulta Principal de Coincidencia
// 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 Qué Establecimos minScore en 0.72
Este valor fue derivado empíricamente durante varias semanas de pruebas A/B. Así es como se veía la distribución de puntuaciones en 5,000 consultas muestreadas:
| Rango de puntuación | Interpretación | Tasa de aceptación del usuario |
|---|---|---|
| 0.90+ | Perfiles casi duplicados | 82% (frecuentemente demasiado similares, baja novedad) |
| 0.80–0.90 | Altamente compatibles | 74% |
| 0.72–0.80 | Compatibilidad fuerte | 61% (punto ideal) |
| 0.62–0.72 | Coincidencia moderada | 31% |
| Debajo de 0.62 | Débil o ruido | 9% |
La tasa de aceptación en 0.72–0.80 es en realidad más alta que la de 0.90+ porque la novedad importa. Dos fundadores con antecedentes muy similares no aportan tanto valor el uno al otro como dos profesionales complementarios en áreas adyacentes. El punto ideal captura "altamente compatible pero lo suficientemente distinto para ser útil".
Puntuación y Clasificación
Las puntuaciones brutas de Pinecone son un buen punto de partida, pero no son la señal de clasificación final. Aplicamos una capa de puntuación posterior que incorpora tres factores adicionales.
La Puntuación Compuesta
// 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);
}Las tres señales adicionales:
Frescura (15%): Un perfil actualizado hace dos días tiene más probabilidades de reflejar la intención actual que uno sin cambios durante ocho meses. Esto es especialmente cierto en contextos de startups en etapa temprana donde las personas pivotan frecuentemente.
Ubicación (15%): Las coincidencias de la misma ciudad se convierten en reuniones reales a una tasa 2.3x mayor que las coincidencias solo remotas, según nuestros datos. Damos un impulso modesto pero no filtramos candidatos remotos.
Penalización de novedad: Esta es la señal más importante después de la puntuación vectorial. Si a un usuario se le ha mostrado un perfil cinco veces y nunca se conectó, mostrarlo de nuevo es ruido. Las coincidencias rechazadas reciben una penalización del 50%; las mostradas anteriormente pero sin acción reciben una penalización del 15%.
Desglose de Puntuación Compuesta
Cómo las puntuaciones vectoriales brutas se convierten en clasificaciones finales
Luego multiplicado por el factor de novedad:
Optimizaciones de Producción
Con 1,000 usuarios, nada de esto importa mucho. Con 17,000 usuarios y cientos de consultas por minuto durante las horas pico, las pequeñas ineficiencias se acumulan rápidamente. Estas son las cuatro optimizaciones que nos mantuvieron por debajo del presupuesto y por debajo de 120ms de latencia p95.
1. Sharding por Namespace según Etapa
Los namespaces de Pinecone permiten particionar un índice. Hacemos sharding por etapa de la empresa, de modo que un fundador en "etapa de idea" solo consulta contra el subconjunto con namespace de perfiles que se declararon abiertos a asociaciones en etapa temprana.
// 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
}Esto por sí solo redujo el tiempo promedio de consulta en un 38% porque cada consulta escanea un espacio vectorial más pequeño. La lógica de "namespaces adyacentes" asegura que un fundador en etapa seed aún pueda coincidir con un fundador en etapa pre-seed: se consultan múltiples namespaces y se fusionan los resultados.
2. Caché de Embeddings
Generar embeddings cuesta dinero y añade latencia. Los embeddings de perfiles rara vez cambian, así que los almacenamos en caché en Redis con un TTL vinculado a la marca de tiempo updatedAt del 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;
}Tasa de acierto de caché en producción: 94%. La mayoría de las consultas provienen de usuarios revisando su feed de coincidencias, que recupera perfiles existentes en lugar de re-generar embeddings. Esto reduce nuestros costos de embeddings de OpenAI aproximadamente 15x en comparación con calcular de nuevo en cada consulta.
3. Caché de Coincidencias con Stale-While-Revalidate
El feed de coincidencias en sí está en caché por usuario con un TTL corto. Las consultas a Pinecone son rápidas, pero cuando 500 usuarios revisan su feed a las 9AM, la carga concurrente 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-indexación por Lotes en Cambios de Esquema
Cuando cambiamos la función buildProfileDocument, agregando un nuevo campo o reordenando secciones, todos los 17K embeddings se vuelven obsoletos porque fueron calculados a partir de una estructura de documento anterior. Re-indexamos el corpus completo en lotes en segundo plano para evitar bloquear la 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);Una re-indexación completa de 17K perfiles toma aproximadamente 12 minutos y cuesta alrededor de $4.80 en llamadas de embeddings de OpenAI a los precios actuales. La ejecutamos durante horas de baja actividad cuando desplegamos cambios de esquema, y el retraso entre lotes evita que alcancemos los límites de tasa de OpenAI en el endpoint de embeddings.
Resultados y Métricas
Después de ejecutar este sistema en producción durante ocho meses, estos son los números honestos.
Escala:
- 17,400 perfiles activos indexados en Pinecone
- ~2,800 consultas de coincidencia por día en el pico
- Tamaño total del índice: ~420MB en Pinecone serverless
Calidad de Coincidencias (comparado con el sistema anterior de palabras clave):
- Tasa de aceptación de coincidencias: 23% → 61% (+165%)
- Tasa de "sin coincidencias encontradas": 34% → 4% (el sistema anterior devolvía resultados vacíos para perfiles de nicho)
- Calidad de coincidencia reportada por usuarios (NPS de 5 estrellas): 2.9 → 4.1
Rendimiento:
- Latencia de consulta p50: 68ms (solo Pinecone)
- Latencia de consulta p95: 114ms (solo Pinecone)
- Feed de coincidencias completo con puntuación: p95 en 210ms (incluye verificación de caché Redis, capa de puntuación)
- Tasa de acierto de caché (feed de coincidencias): 71%
Costos (mensuales):
- Pinecone serverless: $38/mes
- Embeddings de OpenAI (con caché): $22/mes
- Redis (Upstash): $9/mes
- Total de infraestructura para emparejamiento: ~$69/mes con 17K usuarios
Eso es $0.004 por usuario activo por mes para toda la infraestructura de emparejamiento. Originalmente habíamos presupuestado $300/mes basándonos en estimaciones ingenuas de costo por consulta. El caché de embeddings fue la palanca individual más grande.
Lo que nos sorprendió:
La penalización de novedad tuvo un impacto mayor de lo esperado. Antes de agregarla, los usuarios estaban abandonando silenciosamente porque seguían viendo los mismos 10 perfiles. Después de la degradación por novedad, la retención de sesión a sesión en el feed de coincidencias mejoró un 28%. Mostrar menos pero más variadas coincidencias resultó importar más que mostrar las coincidencias con la puntuación más alta repetidamente.
La otra sorpresa fue cuánto afectaba la completitud del perfil a la calidad de las coincidencias. Un perfil completamente llenado obtiene puntuaciones de coincidencia 3.5x mejores en promedio que uno escaso. Ahora mostramos una puntuación de completitud en la aplicación y vimos una reducción del 40% en consultas de baja calidad después de que los usuarios entendieron la conexión.
Preguntas Frecuentes
P: ¿Por qué no usar la búsqueda híbrida de Pinecone con vectores dispersos? R: Experimentamos con ella. Para nuestro caso de uso, descripciones profesionales de formato largo, los embeddings densos por sí solos superaron a la búsqueda híbrida. La búsqueda híbrida es más valiosa cuando tiene consultas cortas contra documentos largos, como la búsqueda de productos en e-commerce. Dos textos largos de perfil comparándose entre sí es el punto ideal para la recuperación puramente densa.
P: ¿Cómo manejan perfiles en idiomas distintos al inglés?
R: text-embedding-3-large es multilingüe. En la práctica, la mayoría de los perfiles de onSpark están en inglés o francés. Generamos los embeddings tal cual y la similitud aún funciona entre idiomas con una pequeña degradación de calidad (aproximadamente 0.05 puntuaciones más bajas para coincidencias entre idiomas en nuestras pruebas). Planeamos agregar filtrado de metadatos por idioma en una versión futura.
P: ¿Qué pasa cuando el perfil de alguien cambia drásticamente? R: El re-embedding al guardar se dispara inmediatamente. El nuevo embedding sobrescribe el anterior en Pinecone vía upsert. El caché de coincidencias para ese usuario se invalida en su siguiente solicitud. La única ventana donde se muestran coincidencias obsoletas está dentro del TTL de 30 minutos del caché de coincidencias, lo cual aceptamos como un compromiso razonable.
P: ¿Podría hacer esto sin Pinecone, solo pgvector en Postgres?
R: Sí, y para menos de 50K perfiles lo consideraría seriamente. pgvector con un índice IVFFlat o HNSW maneja esta escala bien. Elegimos Pinecone por la infraestructura gestionada, el filtrado de metadatos y el soporte de namespaces. Si está en Supabase, pgvector con vector_cosine_ops funcionaría bien y reduciría la superficie operacional.
P: ¿Cuál es la versión mínima viable de esto?
R: Tres funciones: embedText, upsertProfile y findMatches. Puede enviar un prototipo funcional en un día. Comience con text-embedding-3-small (menor costo, 1536 dimensiones) y actualice si la calidad de las coincidencias no es suficientemente buena. La mayor parte de la complejidad en este artículo es trabajo de optimización que viene después.
Conclusión
La búsqueda vectorial transformó el emparejamiento de onSpark de una experiencia frustrante, limitada por palabras clave, a una que entiende la intención profesional a nivel semántico. La escala de 17K perfiles en la que operamos hoy no fue la parte difícil: la parte difícil fue descubrir que la coincidencia por palabras clave es la abstracción incorrecta para emparejar personas, y que la similitud coseno de buenos embeddings lo acerca mucho más a lo que los usuarios realmente quieren decir.
La implementación es directa. El SDK de Pinecone está bien documentado, la API de embeddings de OpenAI es confiable, y la capa de puntuación compuesta encima agrega las señales humanas (frescura, ubicación, novedad) que la similitud vectorial pura omite. El costo total de infraestructura a nuestra escala actual es menos de $70/mes, un error de redondeo comparado con el valor del producto.
Si está construyendo cualquier tipo de producto de emparejamiento, recomendación o búsqueda por similitud, la búsqueda vectorial es la base correcta. El ecosistema ha madurado al punto donde la carga operacional es mínima.
¿Está construyendo un producto de emparejamiento o recomendación y quiere discutir la arquitectura? Contáctenos y puedo ayudarle a determinar si la búsqueda vectorial es la opción correcta para su caso de uso específico.
Artículos 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]