Búsqueda semántica en un diario: cómo los embeddings de NaturalLanguage encuentran el significado, no las palabras#
Trabajando en Lanternly —una app de diario que tengo actualmente en desarrollo—, choqué rápido con una limitación de la búsqueda tradicional. Una persona escribe una entrada como «no puedo dormir, la misma idea da vueltas en mi cabeza» y, un mes después, busca por la palabra «ansiedad»: no encuentra esa entrada, porque la palabra «ansiedad» nunca aparece literalmente. contains() y los índices de texto completo buscan coincidencias de cadenas, no de significado. Para que un diario realmente «recuerde» a la persona, la búsqueda tiene que entender que «no puedo dormir, la misma idea da vueltas» y «ansiedad antes de dormir» hablan de la misma experiencia. La solución resultó estar mucho más cerca de lo esperado que una API de embeddings en la nube: el framework NaturalLanguage, que Apple lleva años integrando en iOS, ya convierte texto en un vector de significado directamente en el dispositivo, sin una sola petición de red.
Por qué un diario necesita "búsqueda por significado" si ya existe Cmd+F#
Una palabra clave rara vez coincide con cómo una persona recuerda un evento. Un diario no es un registro con etiquetas, es un flujo de texto libre, y las consultas contra él también son libres: «cuándo fue la última vez que me enfadé con mi madre», «entradas sobre el burnout en el trabajo», «días en los que el dinero iba bien». Puede que ninguna de esas entradas contenga una sola palabra de la consulta, pero el significado coincide.
La búsqueda de texto completo (un NSPredicate con CONTAINS[cd], o FTS5 en Core Data/SQLite) resuelve muy bien otro problema: la coincidencia exacta de términos, nombres, fechas. Son herramientas distintas para preguntas distintas, y la búsqueda semántica no sustituye a la de texto completo, sino que la complementa allí donde el usuario busca una vivencia, no una cadena de texto.
Aquí aparece la idea del embedding: convertir el texto en un punto de un espacio de muchas dimensiones, de modo que los textos cercanos en significado queden próximos entre sí, y los que no tienen relación queden lejos. A partir de ahí, toda la búsqueda se reduce a geometría: encontrar los puntos más cercanos al punto de la consulta.
NLEmbedding vs NLContextualEmbedding: lo que Apple ofrece de serie#
NaturalLanguage ofrece dos formas fundamentalmente distintas de obtener un vector.
NLEmbedding — embeddings estáticos de palabras y frases (NLEmbedding.wordEmbedding(for:), NLEmbedding.sentenceEmbedding(for:)). El modelo ya viene con el sistema operativo, los vectores se calculan al instante, pero la misma palabra siempre produce el mismo vector, sin importar el contexto: "llave" en "llave de casa" y "llave" en "la llave del enigma" reciben la misma representación.
NLContextualEmbedding (iOS 17+) — embeddings basados en un modelo transformer (tipo BERT). El vector de un token tiene en cuenta las palabras vecinas, así que la ambigüedad y los matices de significado se capturan con mucha más precisión. El coste: el modelo pesa considerablemente más y sus assets requieren una descarga única al dispositivo mediante requestAssets(completionHandler:) antes de poder llamar a load().
Para un diario, donde los matices del estado de una persona sí importan, elegí NLContextualEmbedding como camino principal, manteniendo NLEmbedding como fallback rápido para idiomas sin modelo contextual.
| Criterio | NLEmbedding (estático) | NLContextualEmbedding | Base de datos vectorial externa (Pinecone / pgvector / Weaviate) |
|---|---|---|---|
| Tipo de modelo | Estático, sin contexto | Transformer (tipo BERT), consciente del contexto | Cualquier modelo de embeddings externo a elección |
| Dónde se ejecuta | En el dispositivo, integrado en el SO | En el dispositivo, assets bajo demanda | Servidor/nube |
| Infraestructura | Ninguna | Ninguna | Servidor de BD, claves de API, facturación |
| Funciona offline | Sí | Sí, tras la primera descarga de assets | No, requiere llamada de red |
| Privacidad | Los datos nunca salen del dispositivo | Los datos nunca salen del dispositivo | El texto se envía a un servidor de terceros |
| Idiomas | Conjunto limitado | ~27 idiomas agrupados por escritura (WWDC23+) | Cualquiera, depende del modelo |
| Precisión en matices | Menor, no distingue polisemia | Mayor, considera el contexto de la frase | Máxima, SOTA + ajuste de dominio |
| Escala de datos | Miles de entradas, recorrido lineal | Miles de entradas, recorrido lineal | Millones+, índices ANN (HNSW, etc.) |
| Cuándo elegirlo | MVP, prototipos rápidos | Datos personales del usuario: diario, notas, correo | Contenido general, muchos usuarios, volúmenes enormes |
Obtener el embedding de una frase en el dispositivo#
A continuación, el camino mínimo funcional: crear un modelo contextual para un idioma, descargar sus assets si hace falta, cargar el modelo y obtener un único vector para toda una frase combinando los vectores de sus tokens.
import NaturalLanguage
/// Loads a contextual (transformer-based) embedding model for a language,
/// downloading the on-device assets on first use if they are not cached yet.
func makeContextualEmbedding(for language: NLLanguage) throws -> NLContextualEmbedding? {
guard let embedding = NLContextualEmbedding(language: language) else {
return nil // No contextual model ships for this language/script
}
if !embedding.hasAvailableAssets {
let group = DispatchGroup()
group.enter()
embedding.requestAssets { _, _ in group.leave() }
group.wait()
}
try embedding.load()
return embedding
}
/// Produces one fixed-length vector for a whole sentence by mean-pooling
/// the subword token vectors that NLContextualEmbedding returns.
func sentenceVector(
for text: String,
embedding: NLContextualEmbedding,
language: NLLanguage
) throws -> [Double] {
let result = try embedding.embeddingResult(for: text, language: language)
var sum = [Double](repeating: 0, count: embedding.dimension)
var tokenCount = 0
result.enumerateTokenVectors(in: text.startIndex..<text.endIndex) { vector, _ in
for i in 0..<vector.count { sum[i] += vector[i] }
tokenCount += 1
return true // keep iterating
}
guard tokenCount > 0 else { return sum }
return sum.map { $0 / Double(tokenCount) }
}NLContextualEmbeddingResult devuelve un vector por cada subword-token, no un vector por frase: es una decisión deliberada de Apple, porque los vectores a nivel de token también se necesitan para tareas más finas (NER, clasificación de tokens). Para buscar entre entradas de un diario basta con promediar (mean pooling): es una forma simple y predecible de obtener un único vector por entrada completa.
Similitud coseno: comparando dos vectores#
En cuanto ambos textos están representados como vectores de la misma dimensión, la pregunta "¿se parecen en significado?" se convierte en "¿qué ángulo hay entre estos vectores?". La similitud coseno no depende de la longitud del texto: una entrada corta y una larga sobre el mismo tema acabarán igualmente cerca.
/// Cosine similarity between two vectors.
/// 1.0 = same direction (same meaning), 0.0 = unrelated, -1.0 = opposite.
func cosineSimilarity(_ a: [Double], _ b: [Double]) -> Double {
guard a.count == b.count, !a.isEmpty else { return 0 }
var dot = 0.0
var normA = 0.0
var normB = 0.0
for i in 0..<a.count {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
guard normA > 0, normB > 0 else { return 0 }
return dot / (normA.squareRoot() * normB.squareRoot())
}El propio NLEmbedding también puede calcular distancia de serie: distance(between:and:distanceType:) con NLDistanceType.cosine. Pero para NLContextualEmbedding, donde tú mismo construyes el vector de la frase mediante pooling, necesitas tu propia implementación de similitud coseno, que es exactamente por qué la función anterior es genérica para cualquier array [Double].
Indexar entradas: calcular los embeddings una sola vez#
El error más común en una primera implementación es recalcular el embedding de la consulta y el de todas las entradas en cada búsqueda. El vector de una entrada solo cambia cuando cambia su texto, así que hay que calcularlo una vez —al guardar— y almacenarlo junto al texto.
/// A diary entry paired with its precomputed semantic vector.
struct IndexedEntry {
let id: UUID
let text: String
let vector: [Double]
}
/// Builds an in-memory semantic index for a set of entries.
/// In a real app this runs once per entry, on save — never on every search.
final class SemanticEntryIndex {
private var embedding: NLContextualEmbedding?
private let language: NLLanguage
private(set) var entries: [IndexedEntry] = []
init(language: NLLanguage = .russian) {
self.language = language
}
func prepare() throws {
embedding = try makeContextualEmbedding(for: language)
}
func index(id: UUID, text: String) throws {
guard let embedding else { return }
let vector = try sentenceVector(for: text, embedding: embedding, language: language)
entries.append(IndexedEntry(id: id, text: text, vector: vector))
}
}En la práctica conviene guardar el vector de cada entrada en SwiftData/Core Data junto al texto (como [Double], o empaquetado en Data), en lugar de recalcularlo en cada arranque de la app; el recálculo solo es necesario cuando el usuario edita el texto de la entrada.
Búsqueda ranked: encontrar entradas "sobre lo mismo"#
El último paso es convertir la consulta del usuario en el mismo tipo de vector y ordenar todas las entradas indexadas por similitud coseno descendente respecto a ella.
/// Returns entries closest in meaning to `query`, best match first.
func semanticSearch(
query: String,
in index: SemanticEntryIndex,
embedding: NLContextualEmbedding,
language: NLLanguage,
limit: Int = 10
) throws -> [(entry: IndexedEntry, score: Double)] {
let queryVector = try sentenceVector(for: query, embedding: embedding, language: language)
return index.entries
.map { entry in (entry: entry, score: cosineSimilarity(queryVector, entry.vector)) }
.sorted { $0.score > $1.score }
.prefix(limit)
.map { $0 }
}Para un diario con unos miles de entradas, este recorrido lineal tarda milisegundos: los índices aproximados (HNSW, IVF) solo tienen sentido cuando el número de entradas crece de verdad, algo de lo que hablo a continuación.
Dónde está el techo: idiomas, la tendencia RAG de 2026 y cuándo hace falta una base vectorial de verdad#
Este enfoque tiene límites reales, y ser honesto sobre ellos importa más que vender la idea como una bala de plata.
Idiomas. NLContextualEmbedding no soporta cualquier idioma: los modelos están agrupados por familias de escritura (Apple lo mostró en la WWDC23 junto con sus modelos multilingües de Create ML basados en BERT), y la cobertura de la escritura latina, la CJK y otros grupos no es uniforme. Conviene comprobar NLContextualEmbedding.languages para un modelo concreto antes de depender de él, y tener listo un camino alternativo para los idiomas sin modelo contextual.
Escala. Un recorrido lineal sobre similitud coseno funciona muy bien hasta unas pocas miles de entradas, el tamaño habitual de un diario personal a lo largo de años. Para millones de entradas y muchos usuarios sí hacen falta índices aproximados y una base de datos vectorial externa (Pinecone, Weaviate, pgvector): es otra clase de problema, con otros compromisos de coste y privacidad.
La tendencia RAG. En 2026 se aprecia un giro claro de los ecosistemas móviles hacia el "local-first": las apps mantienen cada vez más el modelo de embeddings y la búsqueda vectorial directamente en el dispositivo, y envían a la nube solo lo que de verdad requiere conocimiento compartido o sincronización entre usuarios. La memoria personal de un asistente es exactamente el caso donde el enfoque on-device no es un compromiso, sino la arquitectura más correcta: los datos nunca salen del teléfono, y la búsqueda sigue funcionando en modo avión.
Para Lanternly, esta elección fue evidente desde el principio: un diario es, posiblemente, el dato más privado que una persona llega a escribir. Enviarlo a algún sitio solo para habilitar la búsqueda semántica traicionaría la premisa misma de la app. NLEmbedding y NLContextualEmbedding ofrecen búsqueda semántica sin una sola línea de código de servidor, y ese es exactamente el caso en el que una restricción se convierte en la decisión de arquitectura correcta, no en un compromiso.



