Búsqueda semántica con LangChain: motor en TypeScript
Búsqueda semántica con LangChain en TypeScript: carga, chunks, embeddings, vector store y retriever, con código ejecutado y qué cambia al pasar a pgvector.

El tutorial oficial de búsqueda semántica con LangChain enseña un resultado con Score: 0.23699893057346344 y explica que es una distancia: cuanto más bajo, más parecido. Lo lees, lo apuntas y escribes un filtro: descarta todo lo que pase de 0,3.
Ese número sale de otra pregunta del mismo PDF. Ejecutado el 5 de noviembre de 2026 con las versiones actuales, el fragmento que responde a "When was Nike incorporated?" sale con 0,58. Una consulta que no tiene nada que ver con el documento sale con 0,01. Tu filtro tira lo bueno y se queda con la basura.
No hay error ni excepción. El código compila. Y el día que cambies MemoryVectorStore por pgvector, el significado del número se invierte otra vez.
En corto: la búsqueda semántica con LangChain.js son seis piezas: cargar, trocear, embeber, guardar en un vector store, consultar y exponer como retriever. LangChain las une en unas pocas decenas de líneas. El tamaño de chunk, el modelo, los metadatos, el significado del score y el vector store siguen siendo cosa tuya.
¿Qué es la búsqueda semántica con LangChain?
La búsqueda semántica con LangChain es un pipeline que convierte tus documentos en vectores con un modelo de embeddings, los guarda en un vector store y devuelve los fragmentos cuyo significado se parece más a una pregunta, aunque no compartan palabras.
La guía oficial lo monta sobre el informe anual 10-K de Nike de 2023, un PDF de 107 páginas.
Si no tienes claro qué es un embedding ni cómo se compara con similitud coseno, lee antes qué son los vector embeddings y cómo usarlos en TypeScript.
Las piezas del motor de búsqueda semántica en TypeScript
Cada pieza del pipeline tiene una clase de LangChain y una decisión que nadie toma por ti:
| Pieza | Clase (paquete) | Qué decides tú | Error típico |
|---|---|---|---|
| Cargar | PDFParse (pdf-parse) + Document (@langchain/core) |
Qué metadatos guardas: fuente, página, fecha | Perder la página y no poder citar de dónde sale el fragmento |
| Trocear | RecursiveCharacterTextSplitter (@langchain/textsplitters) |
chunkSize y chunkOverlap, en caracteres |
Creer que chunkSize: 1000 son 1.000 tokens |
| Embeber | OpenAIEmbeddings (@langchain/openai) |
Modelo y dimensiones | No pasar model y quedarte con text-embedding-ada-002 |
| Guardar | MemoryVectorStore (@langchain/classic) |
Dónde viven los vectores | Pensar que la memoria persiste entre arranques |
| Buscar | similaritySearch, similaritySearchWithScore |
k y umbral de score |
Fijar un umbral sin saber si el número es distancia o similitud |
| Exponer | asRetriever |
searchType, k, fetchK |
Usar MMR con fetchK: 1 y anular la diversidad |
Versiones de npm a 5 de noviembre de 2026: langchain 1.6.1, @langchain/core 1.3.4, @langchain/classic 1.0.57, @langchain/textsplitters 1.0.2, @langchain/openai 1.8.0 y pdf-parse 2.4.5.
bun add @langchain/core @langchain/classic @langchain/textsplitters @langchain/openai pdf-parse
Cargar y trocear: chunkSize y chunkOverlap en LangChain.js
El tutorial no usa un loader de LangChain para el PDF. Escribe un helper de unas 20 líneas sobre pdf-parse 2.x que crea un Document por página y guarda la página en los metadatos:
import { readFileSync } from "node:fs";
import { Document } from "@langchain/core/documents";
import { PDFParse } from "pdf-parse";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
// Una página del PDF = un Document, con su página en los metadatos
async function loadPdfPages(filePath: string): Promise<Document[]> {
const parser = new PDFParse({ data: new Uint8Array(readFileSync(filePath)) });
try {
const { pages } = await parser.getText();
return pages.map(
(page) =>
new Document({
pageContent: page.text,
metadata: { source: filePath, page: page.num - 1 },
})
);
} finally {
await parser.destroy();
}
}
const docs = await loadPdfPages("./data/nke-10k-2023.pdf");
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200,
});
const allSplits = await textSplitter.splitDocuments(docs);
console.log(docs.length, allSplits.length); // 107 515
Ejecutado el 5 de noviembre de 2026: 107 páginas y 515 chunks. La doc dice 516. La diferencia viene del parser, y es el primer aviso de que el resultado depende de versiones que no controlas.
El splitter mide con text.length, así que chunkSize: 1000 son 1.000 caracteres, no tokens. Los chunks salieron con 856 caracteres de media y 1.000 como máximo.
Bajar a chunkSize: 500 y chunkOverlap: 50 da 965 chunks, casi el doble. Son casi el doble de vectores que guardar, y cada fragmento tiene menos contexto.
Los metadatos se heredan: cada chunk conserva source y page, y el splitter añade loc.lines.
Embeber y guardar en un vector store de LangChain
Embeber y guardar son tres líneas: el modelo de embeddings, el vector store que lo usa y addDocuments. La trampa está en la primera:
import { OpenAIEmbeddings } from "@langchain/openai";
import { MemoryVectorStore } from "@langchain/classic/vectorstores/memory";
// Fija siempre el modelo: sin él, LangChain usa text-embedding-ada-002
const embeddings = new OpenAIEmbeddings({ model: "text-embedding-3-large" });
const vectorStore = new MemoryVectorStore(embeddings);
await vectorStore.addDocuments(allSplits);
Si escribes new OpenAIEmbeddings() sin opciones, @langchain/openai 1.8.0 usa text-embedding-ada-002, el modelo antiguo. Está en el código fuente del paquete y se confirma al ejecutarlo.
Segunda sorpresa: el tutorial pide text-embedding-3-large y enseña una salida de vectores de longitud 1536. Ejecutado, devuelve 3.072. Si necesitas 1.536, pásalo explícito con dimensions: 1536, que sí funciona.
Y MemoryVectorStore ya no está en langchain. Desde la v1 vive en @langchain/classic, el paquete de compatibilidad.
similaritySearch y similaritySearchWithScore: qué significa el score
similaritySearch devuelve los k documentos más parecidos, 4 por defecto. similaritySearchWithScore devuelve además el número, y ese número cambia de significado según el vector store.
const results = await vectorStore.similaritySearchWithScore(
"When was Nike incorporated?",
3
);
for (const [doc, score] of results) {
console.log(score.toFixed(4), doc.metadata.page);
}
// En MemoryVectorStore el filtro es una función sobre el Document
const vectorStore.similaritySearch(
"When was Nike incorporated?",
2,
(doc) => doc.metadata.page === 3
);
Ejecutado el 5 de noviembre de 2026 sobre los 26 chunks de las seis primeras páginas, para gastar pocos tokens. El primer resultado fue el chunk de la página 3 que dice "NIKE, Inc. was incorporated in 1967", con 0,5836. La consulta irrelevante "receta de paella valenciana" sacó como mejor resultado 0,0110.
Más alto es mejor. MemoryVectorStore calcula similitud coseno, aunque la doc diga que es una distancia.
PGVectorStore hace lo contrario. El issue #11043 de langchainjs, abierto en junio de 2026, lo describe así: el método "returns raw vector distance values (e.g. 0.82, 0.17) rather than normalized similarity scores", y lo llama "inconsistent with other vector store implementations in LangChain.js (e.g. MemoryVectorStore)". La respuesta en el propio issue: es el comportamiento esperado, y se cambia con scoreNormalization: "similarity". El #9782 reportó lo mismo en enero sobre el PGVectorStore de @langchain/community.
En @langchain/pgvector 0.1.0 existe la opción scoreNormalization, con "distance" por defecto y "similarity" como alternativa. Ojo: con coseno, "similarity" devuelve (2 - distancia) / 2, no el coseno. El 0,58 de antes saldría como 0,79 y la paella como 0,51. Mismo sentido, otra escala. Si tienes un umbral, escribe un test que lo fije.
El retriever de LangChain: asRetriever, MMR y batch
Un vector store no es un Runnable. Un retriever sí, así que admite invoke y batch, y se enchufa en cadenas y agentes:
const retriever = vectorStore.asRetriever({
searchType: "mmr",
searchKwargs: { fetchK: 1 },
});
const batchResults = await retriever.batch([
"When was Nike incorporated?",
"Where is Nike headquartered?",
]);
MMR (maximum marginal relevance) primero trae fetchK candidatos y después elige entre ellos los que más se diferencian. Con fetchK: 1, como en el tutorial, solo hay un candidato: cada consulta devolvió un único documento y no hubo nada que diversificar. En tu código, fetchK tiene que ser mayor que k, por ejemplo 20 y 4.
Cuando el retriever pase a ser la tool de búsqueda de un agente, cómo diseñarla con memoria y RAG alrededor lo explica el ebook gratuito El Developer Agéntico, 194 páginas sobre agentes, tools, memoria y RAG.
De MemoryVectorStore a pgvector: qué cambia en producción
En la búsqueda semántica con LangChain, MemoryVectorStore sirve para aprender y para tests. En producción necesitas que los vectores sobrevivan a un reinicio. La guía de integración instala PGVectorStore desde @langchain/pgvector:
import { PGVectorStore } from "@langchain/pgvector";
const pgStore = await PGVectorStore.initialize(embeddings, {
postgresConnectionOptions: {
connectionString: process.env.DATABASE_URL,
},
tableName: "documents",
distanceStrategy: "cosine",
scoreNormalization: "similarity", // más alto es mejor, pero (2 - distancia) / 2: otra escala
});
MemoryVectorStore |
PGVectorStore |
|
|---|---|---|
| Paquete | @langchain/classic 1.0.57 |
@langchain/pgvector 0.1.0 |
| Persistencia | Ninguna: se pierde al reiniciar | Tabla de Postgres |
| Filtro | Función sobre el Document |
Objeto: igualdad, in, notIn, arrayContains y rangos |
| Score por defecto | Similitud coseno, más alto es mejor | Distancia, más bajo es mejor |
| Búsqueda | Recorre todos los vectores | Escaneo secuencial; HNSW opcional con createHnswIndex |
| Riesgo | Reembeber todo el corpus en cada arranque | HNSW sobre vector admite hasta 2.000 dimensiones; text-embedding-3-large da 3.072 |
La última fila es la que rompe despliegues. pgvector indexa vector hasta 2.000 dimensiones y halfvec hasta 4.000, según su README. Con text-embedding-3-large tienes dos salidas: pedir dimensions: 1536 o pasar a halfvec. Por qué HNSW se comporta así lo cuento en cómo funciona una base de datos vectorial con HNSW e IVF.
Postgres también permite combinar vectores con BM25, que rescata códigos, nombres propios y cifras: lo tienes en búsqueda híbrida con embeddings y Supabase.
Cuándo NO usar LangChain para esto
Primero, si tu caso es simple, sobra. Mil documentos, un modelo y un bucle de similitud coseno caben en 30 líneas con el SDK de OpenAI.
Segundo, las roturas entre versiones son reales. El issue #9297 recoge el error ERR_PACKAGE_PATH_NOT_EXPORTED al importar langchain/vectorstores/memory tras la v1. La solución de otro usuario fue "npm i @langchain/classic", y un mantenedor lo cerró remitiendo a la guía de migración. La doc de pgvector, a 5 de noviembre de 2026, dice en una tabla que el paquete es @langchain/pgvector y en una nota que es @langchain/community.
Tercero, MemoryVectorStore no persiste. Cada arranque vuelve a llamar a la API de embeddings con todo el corpus. Con 515 chunks no duele; con 500.000, sí.
Cuarto, la abstracción esconde la semántica. El mismo método, similaritySearchWithScore, devuelve números con sentidos opuestos según la clase.
En Hacker News, en un hilo de 2024 sobre dejar LangChain, un usuario lo resumió: "IMO LangChain provides very high level abstractions that are very useful for prototyping", para añadir después: "I don't think I would run it in production". Yo no iría tan lejos, pero sí lo trataría como una dependencia a fijar con versiones exactas y tests.
Cómo llevar tu búsqueda semántica con LangChain a producción
Fija el modelo y las dimensiones en el código y nunca dependas del valor por defecto. Fija las versiones de los paquetes @langchain/*. Si los metadatos llegan de fuentes distintas, valídalos con un schema antes de indexar.
Y escribe un test con tres consultas reales y el chunk que esperas que salga en cada una. Ese test es lo que te avisa cuando un cambio de parser, de modelo o de vector store mueve la calidad sin lanzar ningún error.
Si quieres llevar este buscador a un producto completo, con especificación primero y Claude Code para implementar, es el recorrido del curso Construye con IA. Y cuando el retriever pase a ser la tool de un agente, sigue con cómo desplegar agentes de LangChain en producción.
Preguntas frecuentes
¿Qué paquetes necesito para la búsqueda semántica con LangChain.js?
Con OpenAI y vectores en memoria: @langchain/core, @langchain/textsplitters, @langchain/openai, @langchain/classic y pdf-parse si cargas PDF. Para producción con Postgres, cambia @langchain/classic por @langchain/pgvector y el driver pg.
¿El chunkSize de RecursiveCharacterTextSplitter se mide en tokens?
No. Por defecto se mide en caracteres, con la longitud del string. Con chunkSize: 1000 los chunks del PDF de Nike salieron con 856 caracteres de media. Si necesitas tokens, el splitter acepta una lengthFunction propia.
¿Un score más alto en similaritySearchWithScore es mejor?
Depende de la clase. En MemoryVectorStore es similitud coseno y más alto es mejor. En PGVectorStore es distancia por defecto y más bajo es mejor, salvo que pongas scoreNormalization: "similarity", y aun así la escala no es la de MemoryVectorStore. Compruébalo con una consulta irrelevante antes de fijar un umbral.
¿Qué modelo de embeddings usa OpenAIEmbeddings por defecto?
En @langchain/openai 1.8.0, text-embedding-ada-002. El tutorial oficial usa text-embedding-3-large, que devuelve 3.072 dimensiones salvo que pidas menos con dimensions.
¿Puedo usar MemoryVectorStore en producción?
Solo si el corpus es pequeño y te da igual reembeberlo en cada arranque. No persiste nada y recorre todos los vectores en cada consulta. Para algo que deba sobrevivir a un despliegue, usa pgvector u otro vector store persistente.
El código de este post se ejecutó el 5 de noviembre de 2026 con las versiones citadas.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Quieres construir con agentes en serio?
Curso «Construye con IA» con Claude Code, de la idea al producto.
Developer con más de 15 años en frontend y creador de Dominicode. Escribe sobre IA aplicada al desarrollo de software.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.