Embeddings i cerca vectorial
- Què és un embedding
- Com s’aprenen els embeddings
- Mètriques de similitud
- Cerca semàntica pas a pas
- Decisions pràctiques
- Classificar amb els veïns
- Cerca aproximada de veïns (ANN)
- Cerca semàntica en producció
- Connexions
Els embeddings i la cerca vectorial són una de les bases dels sistemes de recomanació moderns, el RAG i la cerca semàntica. La idea central és simple: convertir objectes en vectors perquè sigui possible comparar-los per significat i no només per paraules exactes.
Què és un embedding
Un embedding és un vector de nombres reals de dimensió fixa que representa un objecte, com ara un text, un usuari, un producte o una imatge. La propietat important és que objectes semànticament semblants queden propers en l’espai vectorial.
Representació dispersa vs. densa
La manera tradicional de representar text era amb vectors dispersos (sparse): un vector de tants components com paraules existeixin al vocabulari, on gairebé tots els valors són zero. Aquesta representació és fàcil d’entendre, però no captura bé la similitud de significat.
Exemple: si el vocabulari té 50.000 paraules, la paraula “gat” seria:
\[ v_{\text{gat}} = [0, 0, \ldots, 0, 1, 0, \ldots, 0] \quad (\text{50.000 components, un sol 1}) \]
Problemes d’aquesta representació:
- No captura cap relació entre paraules (“gat” i “felí” queden igual de lluny que “gat” i “cotxe”).
- Ocupa molt d’espai, i els càlculs de similitud sobre milions de vectors dispersos són lents.
Un embedding dens representa la mateixa paraula amb un vector de dimensió molt menor (típicament 256–4.096 components) on tots els valors són significatius:
\[ v_{\text{gat}} = [0.23, -0.81, 0.45, \ldots, 0.12] \quad (\text{768 components}) \]
La propietat clau és que la geometria del vector captura semàntica. Vectors propers corresponen a objectes amb significat similar.
Intuïció geomètrica
graph LR
subgraph "Espai d'embeddings (2D simplificat)"
GAT["gat 🐱"]
GOS["gos 🐶"]
FELI["felí 🐈"]
COTXE["cotxe 🚗"]
MOTO["moto 🏍️"]
end
GAT ---|"proper"| FELI
GAT ---|"proper"| GOS
COTXE ---|"proper"| MOTO
“Gat”, “gos” i “felí” seran propers entre ells. “Cotxe” i “moto” seran propers entre ells però lluny dels animals. Aquesta estructura emergeix automàticament de l’entrenament, així que no cal definir-la a mà.
Com s’aprenen els embeddings
Un model d’embeddings és una xarxa neuronal entrenada per optimitzar una funció de pèrdua. La intuïció és que el model ha d’aprendre a posar junts els exemples semblants i a separar els exemples diferents.
Pèrdua contrastiva
La pèrdua contrastiva treballa amb triplets: un exemple de referència (anchor), un exemple semblant (positiu) i un de diferent (negatiu). L’objectiu és que el positiu quedi més a prop de la referència que el negatiu:
\[ \mathcal{L} = \max(0,; d(a, p) - d(a, n) + \text{marge}) \]
- d: una mètrica de distància (per exemple, la distància L2 que veurem més avall)
- a: l’exemple de referència (anchor)
- p: l’exemple positiu, n: el negatiu
- marge: la distància mínima que volem entre el positiu i el negatiu
En paraules senzilles: la pèrdua és zero quan el positiu ja és prou més a prop de la referència que el negatiu, i creix quan no ho és. L’entrenament mou els vectors fins que els exemples semblants queden junts.
Exemple: si \(d(a, p) = 0.3\), \(d(a, n) = 0.5\) i el marge és 0.4, la pèrdua és \(\max(0, 0.3 - 0.5 + 0.4) = 0.2\). El negatiu encara no és prou lluny, i l’entrenament el separarà més.
Com es construeixen els parells? Depèn del domini:
| Domini | Parells positius | Parells negatius |
|---|---|---|
| Text | Dues frases del mateix document | Frases de documents no relacionats |
| Recomanació | (usuari, ítem que ha consumit) | (usuari, ítem que no ha vist) |
| Cerca | (consulta, document rellevant) | (consulta, document irrellevant) |
Els negatius es solen obtenir per mostreig aleatori del conjunt de dades (in-batch negatives): els altres exemples del mateix batch d’entrenament actuen com a negatius implícits.
Softmax sobre catàleg (skip-gram)
El model word2vec (skip-gram) va popularitzar un objectiu diferent: donada una paraula, predir quines paraules apareixen al seu voltant. Per encertar-ho, el model ha de donar vectors semblants a paraules que apareixen en contextos semblants. L’objectiu equival a maximitzar una softmax sobre tot el vocabulari:
\[ P(\text{context} \mid \text{paraula}) = \frac{e^{v_{\text{context}} \cdot v_{\text{paraula}}}}{\sum_{w} e^{v_w \cdot v_{\text{paraula}}}} \]
- v: el vector (embedding) de cada paraula
- v_context · v_paraula: el producte escalar entre els dos vectors, que fa de puntuació
- El denominador suma sobre totes les paraules w del vocabulari
En paraules senzilles: és una classificació multiclasse on cada paraula del vocabulari és una classe. Per donar probabilitat alta a les paraules del context, el model ha de fer que els seus vectors apuntin en una direcció semblant. Els pesos apresos durant l’entrenament són els embeddings.
El mateix principi s’aplica als sistemes de recomanació, substituint “paraules” per “ítems” i “context” per “ítems consumits junts”.
Models d’embeddings actuals
A la pràctica, gairebé mai no entrenem un model d’embeddings des de zero: en fem servir un de preentrenat, entrenat amb aquestes mateixes idees sobre volums enormes de text. N’hi ha de codi obert, que es poden executar al propi ordinador amb biblioteques com sentence-transformers, i n’hi ha de servits per API. El benchmark de referència per comparar-los és MTEB (Massive Text Embedding Benchmark), que té classificacions separades per a tasques com la recuperació de documents.
Mètriques de similitud
Un cop tenim dos vectors \(u\) i \(v\), cal mesurar com de similars són. Hi ha tres opcions principals i cadascuna és útil en un context diferent.
Similitud del cosinus
\[ \cos(u, v) = \frac{u \cdot v}{|u| \cdot |v|} \]
- u · v: el producte escalar dels dos vectors
- ‖u‖: la norma (longitud) del vector, \(\sqrt{u_1^2 + u_2^2 + \cdots}\)
Mesura l’angle entre els dos vectors, independentment de la seva magnitud. Val 1 si apunten en la mateixa direcció, 0 si són perpendiculars i -1 si apunten en direccions oposades. És la mètrica més habitual en cerca semàntica de text.
Quan usar-la: cerca semàntica de text, quan la magnitud del vector no té significat (l’important és la direcció).
Producte escalar (dot product)
\[ u \cdot v = \sum_i u_i v_i \]
Combina angle i magnitud. Vectors llargs (alta norma) obtenen puntuacions més altes fins i tot si l’angle no és millor. Això introdueix un biaix de popularitat: ítems representats amb vectors de gran norma tendeixen a aparèixer més.
Quan usar-lo: sistemes de recomanació, on la magnitud pot capturar la popularitat o rellevància global d’un ítem.
Distància L2 (euclidiana)
\[ d(u, v) = |u - v| = \sqrt{\sum_i (u_i - v_i)^2} \]
La distància espacial directa entre dos punts. Aquí, a diferència de les dues anteriors, més petit vol dir més semblant.
Quan usar-la: atributs d’imatge, quan l’espai vectorial és calibrat i la distància absoluta té significat.
El truc de la normalització
Si normalitzem tots els vectors a norma unitària (\(|v| = 1\)), la similitud del cosinus i el producte escalar produeixen el mateix resultat, perquè el denominador del cosinus val 1:
\[ \cos(u, v) = u \cdot v \quad \text{si } |u| = |v| = 1 \]
I la distància L2 hi queda lligada directament. Desenvolupant el quadrat:
\[ |u - v|^2 = |u|^2 + |v|^2 - 2, u \cdot v = 2 - 2 \cos(u, v) \]
En paraules senzilles: amb vectors normalitzats, les tres mètriques ordenen els resultats igual. Com més gran és el cosinus, més petita és la distància. Això té una conseqüència pràctica: pots normalitzar els vectors i fer servir la mètrica que ofereixi el teu índex o la teva base de dades, perquè el rànquing serà el mateix. El producte escalar és el més ràpid de calcular, i per això molts sistemes normalitzen els vectors abans d’indexar-los.
Cerca semàntica pas a pas
Vegem tot el procés amb un exemple petit: un cercador sobre les preguntes freqüents d’una botiga en línia. Farem servir intfloat/multilingual-e5-small, un model de codi obert que funciona en català i en un centenar d’idiomes més, que produeix vectors de 384 dimensions i que és prou petit per executar-se en un portàtil.
# pip install sentence-transformers
import numpy as np
from sentence_transformers import SentenceTransformer
docs = [
"Per canviar la contrasenya, ves a Perfil > Seguretat.",
"Els enviaments a la península arriben en 48 hores.",
"Pots retornar un producte durant els 30 dies següents a la compra.",
"Si t'hem cobrat dues vegades, l'import duplicat es retorna en 5 dies.",
"Acceptem targeta, Bizum i transferència bancària.",
"L'aplicació funciona en Android 10 o superior.",
"Per esborrar el compte, escriu-nos des del formulari de contacte.",
"Les comandes de més de 50 € tenen l'enviament gratuït.",
]
model = SentenceTransformer("intfloat/multilingual-e5-small")
# 1. Indexar: un vector normalitzat per document
D = model.encode(["passage: " + d for d in docs], normalize_embeddings=True)
print(D.shape) # (8, 384): 8 documents, 384 dimensions
# 2. Consultar: el vector de la pregunta, amb el mateix model
q = model.encode(["query: m'han cobrat la comanda dos cops"], normalize_embeddings=True)[0]
# 3. Comparar: com que estan normalitzats, el producte escalar és el cosinus
scores = D @ q
# 4. Ordenar i quedar-se amb els 3 millors
for i in np.argsort(-scores)[:3]:
print(f"{scores[i]:.3f} {docs[i]}")
0.833 Si t'hem cobrat dues vegades, l'import duplicat es retorna en 5 dies.
0.821 Acceptem targeta, Bizum i transferència bancària.
0.819 Per esborrar el compte, escriu-nos des del formulari de contacte.
El primer resultat és el correcte, tot i que no comparteix gairebé cap paraula amb la pregunta (“cobrat dues vegades” contra “cobrat dos cops”). Això és la cerca semàntica.
Hi ha tres detalls que convé entendre:
D @ qés la fórmula del cosinus. És una multiplicació de matriu per vector: calcula el producte escalar de la consulta amb els 8 documents alhora. Pots comprovar que dona el mateix que la fórmula completa:u @ v / (np.linalg.norm(u) * np.linalg.norm(v)).- Els prefixos
passage:iquery:són una convenció d’aquest model concret, que s’ha entrenat així per distingir documents de preguntes. Altres models no en fan servir, o en fan servir uns altres. Cal llegir la documentació del model que triïs. - Les puntuacions són relatives. Aquí totes queden entre 0.82 i 0.83. Amb un altre model podrien anar de 0.2 a 0.6. El que importa és l’ordre, no el valor: un llindar fix com “és rellevant si passa de 0.8” no es pot traslladar d’un model a un altre.
Comprovar que funciona
Provar una pregunta i mirar si el resultat sembla bo no és avaluar. Cal un petit conjunt de preguntes de prova, cadascuna amb el document que hauria de trobar, i una mètrica com Recall@k: el percentatge de preguntes on el document correcte apareix entre els k primers resultats.
def search(query, k=3):
q = model.encode(["query: " + query], normalize_embeddings=True)[0]
return np.argsort(-(D @ q))[:k]
tests = [ # (pregunta, índex del document correcte)
("m'han cobrat la comanda dos cops", 3),
("com puc tornar una samarreta", 2),
("quant triga a arribar el paquet", 1),
("puc pagar amb Bizum?", 4),
("he oblidat la clau d'accés", 0),
("vull donar-me de baixa", 6),
]
for k in (1, 3):
hits = sum(expected in search(q, k) for q, expected in tests)
print(f"Recall@{k}: {hits}/{len(tests)}")
Recall@1: 4/6
Recall@3: 6/6
El document correcte sempre apareix entre els tres primers, però només 4 vegades de 6 en primera posició. Si el sistema mostra només un resultat, això és un problema; si en mostra tres o en passa tres a un LLM, no tant. Amb 20 o 30 preguntes de prova ja pots comparar models, mides de fragment o prefixos amb números, i no per sensació. És la mateixa idea que separar un conjunt de test en aprenentatge supervisat.
Decisions pràctiques
| Decisió | Què cal tenir en compte |
|---|---|
| Quin model | Que funcioni en l’idioma de les dades (per al català, un model multilingüe). Compara a MTEB, però valida amb les teves preguntes de prova |
| Local o API | Un model local no envia dades a fora i no té cost per crida. Una API sol donar més qualitat i no cal cap maquinari |
| Longitud màxima | Cada model té un límit d’entrada (512 tokens en l’exemple). El text que el passa es talla sense avís: els fragments han de cabre-hi |
| Dimensió | Més dimensions poden capturar més matisos, però ocupen més memòria i fan la cerca més lenta |
| El mateix model sempre | Documents i consultes s’han de codificar amb el mateix model. Si canvies de model, cal tornar a calcular tots els vectors |
| Normalitzar | Normalitza els vectors (normalize_embeddings=True) i fes servir el producte escalar |
| Què es guarda | Per a cada vector, un identificador, el text original i les metadades (data, idioma, categoria) per poder filtrar i mostrar resultats |
| Codificar per lots | encode accepta una llista de textos i els processa per lots. Fer-ho d’un en un és molt més lent |
Classificar amb els veïns
La mateixa idea de similitud serveix per classificar. Per saber de quina classe és un exemple nou, busquem els K exemples més semblants que ja tenim etiquetats i fem que votin: la classe més repetida guanya. Si dels 5 tiquets de suport més semblants a un de nou, 4 són de “facturació”, classifiquem el nou com a “facturació”.
Aquest mètode s’anomena K veïns més propers (K-nearest neighbors, KNN). No construeix cap model: només guarda els exemples i els compara. K es tria amb el conjunt de validació: amb K = 1 el resultat depèn massa d’un sol exemple, i amb una K molt gran es perden els detalls locals. Quan les característiques no són embeddings sinó columnes amb unitats diferents, cal escalar-les abans.
L’explicació completa, amb exemples i codi, és a K-veïns més propers.
Cerca aproximada de veïns (ANN)
La cerca de veïns més propers consisteix a trobar, per a un vector de consulta, els vectors del catàleg que queden més a prop segons una mètrica de similitud. A l’exemple anterior l’hem feta de la manera més directa: comparant la consulta amb tots els documents. És la cerca exacta.
El problema computacional
La cerca exacta fa un producte escalar per document, i cada producte escalar té tantes multiplicacions com dimensions. El cost creix proporcionalment al nombre de vectors \(n\) i a la dimensió \(d\): és \(O(n \cdot d)\).
Aquests són els temps mesurats amb NumPy en un portàtil, per a una consulta amb vectors de 768 dimensions:
| Vectors | Temps per consulta | Memòria dels vectors |
|---|---|---|
| 10.000 | ~1 ms | 31 MB |
| 100.000 | ~10 ms | 307 MB |
| 1.000.000 | ~100 ms | 3 GB |
La memòria es calcula directament: vectors × dimensions × 4 bytes (cada número és un float32). Per a un milió de vectors, \(10^6 \times 768 \times 4 \approx 3\) GB.
Conclusió pràctica: fins a uns centenars de milers de vectors, la cerca exacta amb NumPy és prou ràpida i no cal res més. Amb milions de vectors i moltes consultes per segon, 100 ms per consulta i 3 GB de memòria ja són un problema. La solució és acceptar una petita pèrdua de precisió a canvi d’una reducció dràstica del temps: la cerca aproximada de veïns (approximate nearest neighbors, ANN).
HNSW (Hierarchical Navigable Small World)
HNSW construeix un graf multicapa on cada vector es connecta amb els seus veïns propers. Les capes superiors contenen pocs nodes i connexions llargues (navegació ràpida); les capes inferiors contenen tots els nodes i connexions curtes (refinament precís).
graph TD
subgraph "Capa 2 (navegació global)"
A2["A"] --- C2["C"]
C2 --- F2["F"]
end
subgraph "Capa 1"
A1["A"] --- B1["B"]
B1 --- C1["C"]
C1 --- E1["E"]
E1 --- F1["F"]
end
subgraph "Capa 0 (tots els nodes)"
A0["A"] --- B0["B"]
B0 --- C0["C"]
C0 --- D0["D"]
D0 --- E0["E"]
E0 --- F0["F"]
end
Cerca: entrar per la capa superior, saltar al veí més proper a la consulta mentre se n’acosti, baixar de capa i repetir. És com buscar una adreça: primer la ciutat, després el barri, després el carrer. El cost creix aproximadament com \(O(\log n)\) en lloc de \(O(n)\): multiplicar per 10 el catàleg només afegeix uns quants salts.
Avantatges: recall molt alt i latència molt baixa. Inconvenient: cal memòria addicional per guardar el graf, a més dels vectors.
IVF (Inverted File Index)
IVF divideix l’espai en \(k\) clústers amb K-means. Cada vector s’assigna al centroide més proper. En el moment de la cerca, primer es compara la consulta amb els \(k\) centroides i després només s’exploren els \(n_{\text{probe}}\) clústers més propers.
Exemple: amb un milió de vectors en \(k = 1000\) clústers i \(n_{\text{probe}} = 10\), la consulta es compara amb 1.000 centroides i amb uns 10.000 vectors, en lloc d’un milió: unes 90 vegades menys feina.
Avantatge: menys memòria que HNSW. Inconvenient: si la consulta queda a la frontera entre dos clústers, els veritables veïns poden estar en un clúster no explorat (recall menor). Augmentar \(n_{\text{probe}}\) millora el recall a canvi de velocitat.
Quantització de producte (PQ)
La quantització de producte (product quantization, PQ) no canvia la manera de buscar, sinó la d’emmagatzemar: comprimeix cada vector perquè ocupi molt menys memòria, a canvi de perdre una mica de precisió.
Funciona en tres passos:
- Divideix cada vector en \(m\) trossos. Per exemple, un vector de 768 dimensions en 96 trossos de 8 dimensions.
- Per a cada posició de tros, aprèn amb K-means un petit diccionari de 256 centroides.
- Substitueix cada tros per l’índex del seu centroide més proper. Com que hi ha 256 opcions, cada índex cap en 1 byte.
Exemple: el vector original ocupa \(768 \times 4 = 3072\) bytes. Comprimit, ocupa 96 bytes, 32 vegades menys. Un milió de vectors passa de 3 GB a menys de 100 MB.
Sovint es combina amb IVF (IVFPQ): IVF redueix els candidats que cal comparar i PQ redueix la memòria dels vectors guardats.
Comparativa i eines
| Algorisme | Recall | Latència | Memòria | Cas d’ús típic |
|---|---|---|---|---|
| Exacta | Perfecte | Creix amb n | Vectors sencers | Fins a uns centenars de milers de vectors |
| HNSW | Alt | Molt baixa | Alta | Serveis en temps real |
| IVF | Mitjà-alt | Baixa | Mitjana | Catàlegs grans, menys RAM |
| IVFPQ | Mitjà | Baixa | Baixa | Centenars de milions de vectors |
FAISS (Meta) és la biblioteca de referència: implementa la cerca exacta, HNSW, IVF, PQ i les seves combinacions, per a CPU i GPU. hnswlib és una implementació lleugera de HNSW. Amb FAISS, passar de la cerca exacta a HNSW és canviar una línia:
# pip install faiss-cpu
import faiss
dim = D.shape[1]
exact = faiss.IndexFlatIP(dim) # IP = inner product (producte escalar)
exact.add(D)
hnsw = faiss.IndexHNSWFlat(dim, 32, faiss.METRIC_INNER_PRODUCT) # 32 veïns per node
hnsw.add(D)
scores, ids = hnsw.search(q.reshape(1, -1), 3) # els 3 més propers
print(ids) # [[3 4 6]]: el mateix resultat que amb NumPy
Cerca semàntica en producció
L’exemple anterior conté el nucli de qualsevol cercador semàntic. Un sistema real hi afegeix unes quantes etapes.
Pipeline típic
- Ingesta: es recullen documents, fragments, productes o entrades de coneixement.
- Fragmentació (chunking): els documents llargs es divideixen en fragments més petits.
- Embedding: cada fragment es transforma en un vector.
- Indexació: els vectors s’emmagatzemen en un índex ANN o en un vector store.
- Consulta: la pregunta de l’usuari també es transforma en un embedding.
- Recuperació: es busquen els vectors més propers a la consulta.
- Reranking: un segon model o una heurística reordena els candidats més rellevants.
Fragmentació de documents
La fragmentació (chunking) consisteix a dividir documents llargs en fragments prou petits perquè cada embedding representi una idea coherent. Un fragment massa gran barreja massa temes en un sol vector, i un de massa petit perd context. A més, cap fragment no pot superar la longitud màxima del model.
Un criteri pràctic és fer coincidir els fragments amb l’estructura natural del document:
- Markdown: un fragment per secció o subsecció, i si una secció és massa llarga, dividir-la per paràgrafs.
- HTML o articles: dividir per títols i paràgrafs abans que per longitud fixa.
- PDFs o textos sense estructura: dividir per paràgrafs i, si cal, aplicar un solapament petit entre fragments consecutius.
Per exemple, en un document Markdown:
# Guia de suport
## Facturació
Paràgraf 1...
Paràgraf 2...
## Enviaments
Paràgraf 3...
Paràgraf 4...
Una partició raonable seria:
- fragment 1:
## Facturació+ els seus paràgrafs - fragment 2:
## Enviaments+ els seus paràgrafs
Si una secció és massa llarga, es pot dividir en fragments més petits mantenint el títol o una mica de context al principi de cadascun.
Per què no n’hi ha prou amb els embeddings
Hi ha tres motius pels quals una bona cerca no s’atura en la cerca vectorial:
- Paraules exactes: algunes consultes contenen codis, noms propis o termes molt específics que un sistema semàntic pot diluir.
- Ambigüitat: un únic embedding de consulta pot recuperar resultats massa generals si no hi ha reranking.
- Context de negoci: a vegades cal prioritzar resultats recents, del mateix idioma o del mateix producte. Això es fa filtrant per metadades, i per això cal guardar-les amb cada vector.
Cerca híbrida
La cerca híbrida barreja dos senyals:
- BM25 o cerca per paraules clau per a coincidències exactes.
- Embeddings per a coincidències per significat.
És especialment útil quan la consulta pot ser de dos tipus alhora. Per exemple, error 4012 necessita coincidència exacta, mentre que “l’app es tanca quan pago” necessita coincidència semàntica.
Les dues cerques es fan per separat i després se’n barregen les llistes: cada resultat rep més punts com més amunt surt a cadascuna, i s’ordena per la suma. El mètode més habitual és la Reciprocal Rank Fusion (RRF), que dona \( 1/(60 + \text{posició}) \) per llista.
Reranking
El reranking pren un conjunt petit de candidats recuperats amb la cerca vectorial i els reordena amb un model més car però més precís, que llegeix la consulta i cada document junts. Això permet:
- recuperar ràpidament centenars de candidats
- aplicar després el model precís només sobre aquests candidats
En molts sistemes, aquest pas marca més diferència en la qualitat final que triar un embedding una mica millor.
Mètriques d’avaluació
A més del Recall@k que hem fet servir a l’exemple:
- Precision@k: quants dels k primers resultats són rellevants.
- MRR (mean reciprocal rank): com de dalt apareix el primer resultat correcte. Si apareix en primera posició val 1, en segona 1/2, en tercera 1/3, i es fa la mitjana de totes les preguntes de prova.
- Avaluació humana: revisió manual sobre consultes reals.
Cal distingir dues coses: la puntuació de recuperació (score) és el valor que rep cada resultat quan es compara amb la consulta, i serveix per ordenar-los. Una mètrica d’avaluació resumeix si el sistema funciona bé en un conjunt de preguntes de prova. La qualitat no depèn només de l’embedding: també depèn de la fragmentació, de l’índex, del reranking i dels filtres, i només una mètrica ho pot dir.
Connexions
- Integració i execució: porta aquests fonaments a la pràctica dins de pipelines RAG, on els fragments recuperats s’afegeixen al prompt d’un LLM. També presenta els vector stores (Chroma, Qdrant, Weaviate), bases de dades que guarden vectors i metadades de manera persistent, filtren i escalen. Amb pocs vectors, un fitxer amb la matriu de NumPy ja és suficient. Un vector store compensa quan cal persistència, filtres, actualitzacions freqüents o diversos processos accedint alhora.
- Sistemes de recomanació: l’arquitectura two-tower usa embeddings apresos per a usuaris i ítems; la fase de retrieval s’implementa amb ANN sobre els vectors d’ítems precomputats.