Agregando Búsqueda Semántica a una Tabla Existente de DynamoDB con Índices Vectoriales
Descubre cómo los índices vectoriales de DynamoDB y los embeddings de Bedrock Titan se combinan para habilitar búsqueda semántica en tus tablas existentes.
Este artículo fue traducido usando IA.
Cuando alguien me pide agregar búsqueda a una aplicación, intento buscar alternativas. La implementación en sí no es el problema, es todo lo que viene con ella: componentes adicionales que manejar, más puntos de falla y el constante reto de mantener los datos sincronizados. Durante los últimos años, he trabajado mucho con DynamoDB y con la introducción de Vector Search me siento mucho más cómodo agregando este tipo de funcionalidad. La semana pasada escribí sobre por qué AWS lanzó otro vector store y dónde encaja DynamoDB Vector Search en el panorama. En este post quiero mostrarte cómo puedes tomar una tabla existente de DynamoDB y agregarle búsqueda vectorial.
El punto de partida
El API actual usa una arquitectura serverless. Incluye SAM para infraestructura, API Gateway al frente, funciones Lambda detrás, y DynamoDB como almacenamiento. El API contiene operaciones CRUD simples para manejar recetas: crear, leer, actualizar, eliminar y listar.
La complejidad surge cuando quieres agregar filtros para consultar exactamente lo que necesitas. En DynamoDB esto significa que necesitas agregar Global Secondary Indexes (GSI) para cada permutación… esto realmente no escala. Así que la única opción hasta ahora era tener un pipeline de datos para indexar la información por separado y proporcionar búsqueda. ¡Eso ya no es así! Con la búsqueda vectorial en DynamoDB, podemos almacenar embeddings vectoriales junto a nuestros datos y buscarlos directamente. Ahora, los usuarios pueden buscar nuestras recetas usando lenguaje natural y encontrar recetas basándose en el significado, no solo en coincidencias exactas de palabras clave.
¿Qué es la búsqueda semántica?
La búsqueda semántica funciona convirtiendo texto en embeddings, que son listas de números que representan el significado del texto. Dos textos que significan cosas similares terminan cerca en el espacio vectorial, así que “estofado de pollo picante” queda cerca de “plato abundante y caliente de ave” aunque casi no comparten palabras. Esta cercanía es lo que te permite buscar por intención en lugar de por palabra clave.
Agregando Embeddings Vectoriales con Amazon Bedrock
Para almacenar un embedding primero necesitamos generarlo. Para eso necesitas un modelo de embeddings, que convierte tu texto en una representación numérica.
Elegí el modelo Titan Text Embeddings V2 de Amazon Bedrock. Produce vectores de 1024 dimensiones, los devuelve normalizados, y se combina naturalmente con similitud coseno.
Lo primero que necesitas determinar es qué texto quieres convertir en embedding. Nuestras recetas usan datos estructurados, por eso creé un string único que combina campos clave: nombre, descripción, cocina, etiquetas dietéticas e ingredientes. Combinarlos en una sola representación significa que una búsqueda puede coincidir con cualquiera de esos campos a la vez.
function buildEmbeddingText(recipe: RecipeInput): string {
const ingredientNames = recipe.ingredients.map((i) => i.name).join(", ");
const dietaryInfo = recipe.dietary?.length ? `Dietary: ${recipe.dietary.join(", ")}.` : "";
return [
recipe.name,
recipe.description,
`Cuisine: ${recipe.cuisine}.`,
dietaryInfo,
`Ingredients: ${ingredientNames}.`,
`Prep time: ${recipe.prepTimeMinutes} minutes. Cook time: ${recipe.cookTimeMinutes} minutes.`,
]
.filter(Boolean)
.join(" ");
}
async function generateEmbedding(text: string): Promise<number[]> {
const response = await bedrock.send(
new InvokeModelCommand({
modelId: amazon.titan-embed-text-v2:0,
contentType: "application/json",
accept: "application/json",
body: JSON.stringify({ inputText: text, dimensions: 1024, normalize: true }),
})
);
const result = JSON.parse(new TextDecoder().decode(response.body));
return result.embedding;
}
Como el embedding se genera inline, cada item es buscable en el momento en que se escribe.
Índices Vectoriales de DynamoDB
Lo que hace que esto funcione sin un servicio separado es que DynamoDB ahora soporta índices vectoriales de forma nativa. Almacenas el embedding como un atributo en el item, creas un índice vectorial sobre ese atributo, y lo consultas con un API de similitud dedicado. Es muy similar a cómo ya creamos un GSI y lo consultamos usando el comando Query.
Nota: El índice vectorial aún no está soportado por CloudFormation, así que no pude definirlo en mi template de SAM. En su lugar, agregué un script que se ejecuta después del despliegue y crea el índice usando el comando
UpdateTablesi aún no existe.
Creé el índice con distancia coseno, 1024 dimensiones para coincidir con la salida de Titan, y un filtro inline para cocina. El filtro inline te permite prefiltrar resultados, piénsalo como la partition key en un índice regular de DynamoDB pero sin ser obligatorio.
await dynamodb.send(
new UpdateTableCommand({
TableName: tableName,
AttributeDefinitions: [
{ AttributeName: "cuisine", AttributeType: "S" },
],
VectorIndexUpdates: [
{
Create: {
IndexName: VECTOR_INDEX_NAME,
VectorAttribute: { AttributeName: "embedding" },
SearchSchema: [
{ AttributeName: "cuisine", SearchSchemaElementType: "INLINE_FILTER" },
],
Projection: { ProjectionType: "ALL" },
Dimensions: VECTOR_DIMENSIONS,
DistanceFunction: "COSINE",
},
},
],
})
);
La implementación de búsqueda
Con el índice en su lugar, la búsqueda es bastante directa. Recibo la consulta del usuario, la convierto en embedding con el mismo modelo Titan que usé al escribir (usar un modelo diferente, o diferentes dimensiones, pondría la consulta en un espacio diferente y los resultados no tendrían sentido), y luego llamo al API SearchVectors con ese vector de consulta y un TopK para cuántas coincidencias quiero.
// Embed the search query
const queryVector = await generateEmbedding(query);
// Vector search across all recipes
const response = await dynamodb.send(
new SearchVectorsCommand({
TableName: TABLE_NAME,
IndexName: INDEX_NAME,
SearchVector: queryVector.map((v) => ({ N: String(v) })),
TopK: TOP_K,
})
);
const results = (response.SearchResults ?? []).map((result) => {
const item = result.Item!;
return {
recipeId: item.recipeId?.S,
name: item.name?.S,
cuisine: item.cuisine?.S,
description: item.description?.S,
dietary: item.dietary?.L?.map((d) => d.S).filter(Boolean) ?? [],
prepTimeMinutes: item.prepTimeMinutes?.N ? Number(item.prepTimeMinutes.N) : null,
cookTimeMinutes: item.cookTimeMinutes?.N ? Number(item.cookTimeMinutes.N) : null,
servings: item.servings?.N ? Number(item.servings.N) : null,
score: result.Score,
};
});
DynamoDB devuelve los items más cercanos junto con un puntaje de similitud, y paso ese puntaje directamente al caller. Para el usuario final, una búsqueda como “algo picante con pollo” regresa una lista ordenada de recetas que realmente coinciden con la intención, cada una con un puntaje que muestra qué tan fuerte es la coincidencia.
Cosas a considerar
Latencia al escribir
Para aplicaciones que no pueden manejar el retraso de 100-150ms de la llamada al embedding, necesitarás generar el embedding de forma asíncrona. Esto se puede hacer usando DynamoDB streams y una función Lambda para actualizar el item con el embedding. Ten cuidado, actualizar el item con el embedding disparará el stream de nuevo, lo que puede crear un loop infinito.
Llenando datos existentes
Si tu tabla tiene datos existentes, esos items no tendrán embeddings. Por lo tanto, no aparecerán en los resultados de búsqueda vectorial. En este caso necesitarás ejecutar un backfill. Este es un script de una sola vez que escanea tu tabla, genera embeddings para cada item, y los actualiza. Si un scan completo no es posible, puedes usar DynamoDB Export a S3 y luego ejecutar lotes de forma asíncrona.
Si quieres un tutorial detallado de cualquiera de las dos soluciones, déjame saber. Puedo escribirlo.
Conclusión
Todo vive en la misma tabla de DynamoDB donde los datos ya estaban. Sin servicio de búsqueda separado, sin pipeline de sincronización, sin infraestructura extra que operar. Genera el embedding al escribir, almacénalo en el item, y consúltalo con un índice vectorial.
Lo que más me llevo es que elegir qué texto convertir en embedding importa más de lo que esperaba. Combinar múltiples campos en una sola representación le da a la búsqueda mucho más con qué trabajar que solo un nombre o descripción.
En el siguiente post, voy a explorar AgentCore Gateway y cómo puede convertir este REST API en un servidor MCP sin reescribir nada.
Si has pospuesto agregar búsqueda semántica porque no querías ejecutar un stack de búsqueda separado, vale la pena darle otra mirada si ya estás usando DynamoDB. Los datos pueden quedarse justo donde están.
Puedes encontrar el código completo en el repositorio recipe-catalogue.
Andres Moreno