En tancar el mòdul 3 vam deixar DocuBot en una situació curiosa: és un sistema robust, amb memòria, econòmicament vigilat... i cec. Respon de meravella quan li donem la documentació correcta a la mà, però fins ara aquesta mà ha estat la nostra: a cada exemple hem enganxat nosaltres els fragments rellevants al bloc <documentacio>. La pregunta pendent era: qui troba, entre centenars de pàgines de la wiki de Nubelia, els dos fragments que responen cada pregunta? Aquesta lliçó construeix la primera peça de la resposta: els embeddings —que a 01-02 vam presentar intuïtivament com a "coordenades de significat"— passen avui d'intuïció a codi. Aprendràs a obtenir-los via API, a mesurar com s'assemblen dos textos amb la similitud cosinus, i a muntar una cerca semàntica funcional (encara que de força bruta) sobre la documentació de Nubelia. I de regal, resoldrem la limitació que vam anunciar a 03-04: el CacheRespostes exacte que no reconeixia preguntes equivalents formulades d'una altra manera.

Contingut

  1. De "coordenades de significat" a vectors reals
  2. Obtenir embeddings via API
  3. Similitud cosinus: mesurar la proximitat entre significats
  4. Cerca semàntica de força bruta sobre la doc de Nubelia
  5. Paraules clau vs. significat: per què guanya la cerca semàntica
  6. Triar un model d'embeddings
  7. Aplicació de regal: el cache semàntic de respostes

De "coordenades de significat" a vectors reals

A 01-02 vam veure la intuïció amb l'exemple gos/gat/factura: un embedding col·loca cada text en un mapa on els significats semblants queden a prop. "Gos" i "gat" són veïns; "factura" viu en un altre barri. Avui concretem aquesta metàfora:

  • Un embedding és una llista de números decimals (un vector) que representa el significat d'un text. Longituds típiques: entre 256 i 3072 números (les seves dimensions).
  • El produeix un model d'embeddings, que és un model diferent del LLM que genera text. No conversa: només converteix text en coordenades.
  • La propietat clau: dos textos amb significat semblant produeixen vectors propers, encara que no comparteixin ni una sola paraula. "No puc entrar al meu compte" i "restablir contrasenya" queden a prop al mapa; "exportar informe" queda lluny de tots dos.

Per a DocuBot això ho canvia tot: si convertim cada fragment de la documentació de Nubelia en un vector, i també la pregunta de l'usuari, trobar la documentació rellevant es redueix a un problema geomètric — buscar els vectors més propers al de la pregunta.

Obtenir embeddings via API

Igual que a 03-01 amb el LLM, fem servir una API gestionada. Tots els proveïdors d'embeddings segueixen el mateix patró: envies una llista de textos, reps una llista de vectors. Aquí ho mostrem amb l'SDK de voyageai com a exemple, però el codi és deliberadament genèric — canviar de proveïdor és canviar tres línies:

# embeddings.py
import os
import voyageai

# La clau d'API SEMPRE per variable d'entorn, com a client.py (03-01)
vo = voyageai.Client(api_key=os.environ["VOYAGE_API_KEY"])

# Parametritzat per env var, seguint el patró de DOCUBOT_MODELO (03-01)
MODEL_EMBEDDINGS = os.environ.get("DOCUBOT_MODELO_EMBEDDINGS", "voyage-3.5")

def embed(textos: list[str], tipus: str = "document") -> list[list[float]]:
    """Converteix una llista de textos en una llista de vectors.

    tipus: "document" per a fragments de documentació,
           "query" per a preguntes d'usuari.
    Alguns models optimitzen l'embedding segons aquest ús.
    """
    resposta = vo.embed(textos, model=MODEL_EMBEDDINGS, input_type=tipus)
    return resposta.embeddings

Desglossament per a qui ho veu per primera vegada:

  • embed() rep una llista, no un text sol: les APIs d'embeddings estan pensades per treballar per lots, i embeddir 50 textos en una crida és molt més barat en latència que 50 crides d'un text. Ho explotarem a la ingesta (04-03).
  • input_type: diversos models distingeixen entre embeddir un document (que serà cercat) i una consulta (que cerca). Si el teu proveïdor no ho suporta, ignora el paràmetre; si ho suporta, fes-lo servir — millora la recuperació.
  • El resultat és una llista de vectors; cada vector és una list[float] de tantes dimensions com tingui el model (p. ex. 1024).
vectors = embed(["restablir contrasenya", "no puc entrar al meu compte", "exportar informe a CSV"])
print(len(vectors))       # 3 textos -> 3 vectors
print(len(vectors[0]))    # p. ex. 1024 dimensions
print(vectors[0][:5])     # [0.0132, -0.0871, 0.0443, ...] — números sense significat individual

Un detall important: cap número del vector no significa res per si sol. El significat és a la posició global del vector respecte dels altres. Per això l'única operació útil és comparar-los.

Similitud cosinus: mesurar la proximitat entre significats

Necessitem un número que digui "com s'assemblen" dos vectors. L'estàndard és la similitud cosinus, i la intuïció geomètrica és simple: imagina cada vector com una fletxa que surt de l'origen del mapa de significats. La similitud cosinus mesura l'angle entre les dues fletxes:

  • Fletxes que apunten en la mateixa direcció (angle 0°) → similitud 1.0: significats gairebé idèntics.
  • Fletxes perpendiculars (90°) → similitud 0.0: significats sense relació.
  • Fletxes oposades → similitud -1.0 (rar a la pràctica amb textos reals).

L'interessant és que mesura direcció, no longitud: un paràgraf llarg i una frase curta sobre el mateix tema apunten cap al mateix lloc. La implementació cap en deu línies i no necessita més matemàtica que multiplicar i sumar:

# embeddings.py (continuació)
import math

def similitud_cosinus(a: list[float], b: list[float]) -> float:
    """Similitud entre dos vectors: 1.0 = mateixa direcció, 0.0 = sense relació."""
    producte = sum(x * y for x, y in zip(a, b))          # producte escalar
    norma_a = math.sqrt(sum(x * x for x in a))           # longitud de la fletxa a
    norma_b = math.sqrt(sum(x * x for x in b))           # longitud de la fletxa b
    return producte / (norma_a * norma_b)
  • El producte escalar (sum(x*y)) creix quan els dos vectors tenen valors grans a les mateixes posicions — és a dir, quan "apunten cap al mateix costat".
  • Dividir per les dues normes (longituds) elimina l'efecte de la mida: només queda l'angle.
  • En producció faries servir numpy per velocitat, però aquesta versió pura deixa clar que no hi ha màgia.
v = embed(["no puc entrar al meu compte",
           "com restablir la contrasenya d'accés",
           "límits de tasques per projecte al pla Pro"])

print(similitud_cosinus(v[0], v[1]))  # p. ex. 0.83 — molt relacionats
print(similitud_cosinus(v[0], v[2]))  # p. ex. 0.24 — poc relacionats

Els valors exactes depenen del model (cada model té la seva "escala" típica), però l'ordre relatiu és fiable: allò relacionat puntua més alt que allò no relacionat. Retén aquesta idea — reapareixerà quan calibrem llindars.

Cerca semàntica de força bruta sobre la doc de Nubelia

Ja tenim les dues peces. La cerca semàntica més simple possible — força bruta — consisteix a: embeddir tots els fragments una vegada, embeddir la pregunta, calcular la similitud contra tots els fragments i quedar-se amb els k millors (top-k):

# cerca.py — cerca semàntica de força bruta
from embeddings import embed, similitud_cosinus

# Mini-corpus de la documentació de Nubelia (a 04-03 vindrà de la wiki real)
FRAGMENTS = [
    "Per restablir la teva contrasenya, ves a Configuració > Seguretat i prem 'Restablir'. Rebràs un correu amb un enllaç vàlid durant 24 hores.",
    "Els informes de projecte es poden exportar a CSV i Excel des del menú Informes > Exportar. L'exportació a PDF no està suportada.",
    "El pla Pro permet fins a 500 tasques per projecte i 25 membres per espai de treball.",
    "Per convidar un membre, l'administrador ha d'anar a Espai > Membres > Convidar i introduir el seu correu corporatiu.",
    "L'API de Nubelia fa servir autenticació per token Bearer. Genera el teu token a Configuració > Desenvolupadors.",
    "Si el teu compte queda bloquejat després de diversos intents fallits, espera 15 minuts o contacta amb l'administrador de l'espai.",
]

# Pas 1 (una sola vegada): embeddir tot el corpus
VECTORS = embed(FRAGMENTS, tipus="document")

def cercar(pregunta: str, k: int = 3) -> list[tuple[float, str]]:
    """Retorna els k fragments més propers en significat a la pregunta."""
    v_pregunta = embed([pregunta], tipus="query")[0]         # Pas 2: embeddir la pregunta
    puntuats = [
        (similitud_cosinus(v_pregunta, v_frag), frag)        # Pas 3: comparar amb TOTS
        for v_frag, frag in zip(VECTORS, FRAGMENTS)
    ]
    puntuats.sort(reverse=True)                              # Pas 4: ordenar de més gran a més petit
    return puntuats[:k]                                      # Pas 5: quedar-se amb els k millors

for sim, frag in cercar("no puc entrar al meu compte", k=2):
    print(f"{sim:.3f}  {frag[:60]}...")

Sortida típica:

0.781  Per restablir la teva contrasenya, ves a Configuració > Se...
0.702  Si el teu compte queda bloquejat després de diversos inten...

Fixa't en el que acaba de passar: la pregunta "no puc entrar al meu compte" no comparteix cap paraula clau amb "restablir contrasenya" ni amb "compte bloquejat després d'intents fallits", i tot i així la cerca ha trobat exactament els dos fragments que un humà de suport hauria triat. Això és el que cap cerca per paraules no fa bé.

Dues observacions abans de continuar:

  • El pas 1 (embeddir el corpus) es fa una vegada i es reutilitza; només la pregunta s'embedeix a cada cerca. Tot i així, mantenir els vectors en una llista de Python té problemes seriosos d'escala i persistència — aquest és exactament el tema de la propera lliçó (04-02).
  • Aquí els "fragments" ens els han donat fets. Decidir com trossejar centenars de pàgines reals en fragments bons és un art amb nom propi, chunking, i té la seva pròpia lliçó (04-03).

Paraules clau vs. significat: per què guanya la cerca semàntica

L'alternativa clàssica és la cerca per paraules clau (la de grep, la d'un cercador de wiki bàsic): trobar documents que continguin les mateixes paraules que la consulta. Comparem:

Aspecte Cerca per paraules clau Cerca semàntica
Què compara Paraules literals (o les seves arrels) Significat (vectors)
"no puc entrar" → doc de "restablir contrasenya" ❌ No la troba (zero paraules en comú) ✅ La troba (significats veïns)
Sinònims ("factura" vs "rebut") ❌ Fallen si no hi ha diccionaris manuals ✅ Resolts de sèrie
Errates i variacions ("contrasena") ❌ Fràgil ✅ Força robusta
Termes exactes rars ("error NB-4012", noms d'endpoint) ✅ Excel·lent ⚠️ Pot diluir-los entre allò "semblant"
Cost computacional Molt baix Requereix model d'embeddings
Explicabilitat ("per què ha sortit això?") Alta (la paraula hi és o no hi és) Baixa (proximitat en un espai abstracte)

La taula revela una cosa important: la semàntica no és estrictament superior en tot. Per a codis d'error exactes o noms de funcions de l'API de Nubelia, la cerca literal és difícil de batre. Per això molts sistemes madurs fan servir cerca híbrida (combinar totes dues) — en tenim prou de saber que existeix; per a DocuBot, la semàntica pura cobreix la gran majoria de preguntes de suport, que arriben escrites amb les paraules de l'usuari, no amb les de la documentació.

Triar un model d'embeddings

Un error freqüent és assumir que el model d'embeddings "ve amb" el LLM. Són decisions independents: DocuBot pot generar respostes amb el model mitjà triat a 01-03 i embeddir amb un model d'un altre proveïdor. Els criteris d'elecció:

Criteri Què mirar Rellevància per a DocuBot
Dimensions 256–3072 típiques. Més dimensions ≈ més matís, però més emmagatzematge i còmput per comparació Amb centenars de pàgines, 512–1024 és un punt dolç
Multilingüe Rendeix igual en català que en anglès? Crític: la doc de Nubelia és en català, però hi ha pàgines tècniques en anglès i usuaris que pregunten barrejant tots dos
Cost Es factura per tokens embeddits; ordres de magnitud més barat que la generació La ingesta inicial és la despesa gran; embeddir cada pregunta és gairebé menyspreable davant del cost de la resposta del LLM (03-04)
Finestra d'entrada Quant de text accepta per fragment Marcarà la mida màxima de chunk a 04-03
Qualitat de recuperació Benchmarks públics (p. ex. MTEB) com a orientació, i sobretot la teva avaluació La mesurarem amb el nostre propi conjunt a 04-05

I una regla d'or que convé tatuar-se: els vectors de models diferents no són comparables entre si. Si demà canvies de model d'embeddings, tots els vectors del corpus queden obsolets i cal re-embeddir-ho tot. Per això MODEL_EMBEDDINGS és una constant amb env var, i per això la ingesta de 04-03 serà re-executable.

Aplicació de regal: el cache semàntic de respostes

A 03-04 vam construir CacheRespostes: un cache exacte per hash SHA-256 de la pregunta normalitzada, amb TTL. I en vam anunciar la limitació: "com restableixo la contrasenya?" i "he oblidat el meu password, què faig?" generen hashos diferents → dues crides al LLM per a la mateixa resposta. Amb el que hem après avui, la solució és directa: cachejar també el vector de cada pregunta i, abans de cridar el LLM, comprovar si alguna pregunta cachejada és prou semblant:

# cache_semantic.py — evolució del CacheRespostes de 03-04
import time
from embeddings import embed, similitud_cosinus

class CacheSemantic:
    """Cache de respostes que reconeix preguntes equivalents per significat."""

    def __init__(self, llindar: float = 0.92, ttl_segons: int = 3600):
        self.llindar = llindar
        self.ttl = ttl_segons
        self.entrades = []  # llista de (vector, resposta, timestamp)

    def obtenir(self, pregunta: str) -> dict | None:
        v = embed([pregunta], tipus="query")[0]
        ara = time.time()
        millor_sim, millor_resposta = 0.0, None
        for vector, resposta, ts in self.entrades:
            if ara - ts > self.ttl:            # entrada caducada: la ignorem
                continue
            sim = similitud_cosinus(v, vector)
            if sim > millor_sim:
                millor_sim, millor_resposta = sim, resposta
        if millor_sim >= self.llindar:          # només si supera el llindar
            return millor_resposta
        return None                             # cache miss -> caldrà cridar el LLM

    def desar(self, pregunta: str, resposta: dict):
        v = embed([pregunta], tipus="query")[0]
        self.entrades.append((v, resposta, time.time()))

    def invalidar(self):
        """En republicar documentació, igual que a 03-04: es buida tot."""
        self.entrades = []

Punts de disseny que mereixen atenció:

  • El llindar és la decisió crítica. Amb 0.92 exigim preguntes gairebé equivalents. Si el baixes a 0.80, estalvies més crides però t'arrisques a servir la resposta de "com convido un membre?" a qui va preguntar "com elimino un membre?" — preguntes temàticament properes però amb respostes oposades. Un fals positiu en un cache és pitjor que cent misses: l'usuari rep una resposta incorrecta amb tota la confiança. Calibra'l amb el conjunt d'avaluació de 02-04, començant conservador.
  • Continua havent-hi cost: cada consulta al cache embedeix la pregunta (una crida barata a l'API d'embeddings). L'estalvi net ve d'evitar la crida cara al LLM. Amb els preus relatius de preus.py (03-04), el compte surt molt a favor.
  • La cerca dins del cache és força bruta — recorre totes les entrades. Per a un cache de centenars de preguntes és perfectament vàlid; és el mateix patró de cercar() d'aquesta lliçó. (I sí: si el cache creixés a milions d'entrades, necessitaria el mateix que el corpus — una base vectorial. Tot connecta.)
  • La invalidació es manté: quan Nubelia publica documentació nova, el cache es buida, exactament com vam decidir a 03-04.

Errors Comuns i Consells

  • Comparar vectors de models diferents. Dona números sense sentit, sense error visible. Fixa el model amb MODEL_EMBEDDINGS i tracta'l com a part de l'esquema de les teves dades.
  • Interpretar la similitud com a probabilitat universal. Un 0.75 no vol dir "75% rellevant": l'escala depèn del model. Compara sempre dins del mateix model, i calibra llindars empíricament amb el teu conjunt d'avaluació.
  • Oblidar input_type (en models que ho suporten): embeddir preguntes com si fossin documents degrada la recuperació de manera silenciosa.
  • Embeddir text a text en bucle. Les APIs accepten lots; embeddir d'un en un multiplica la latència i de vegades el cost. Reserva el bucle per a la pregunta de l'usuari (que arriba d'una en una, inevitablement).
  • Llindar de cache semàntic massa generós. Comença en 0.90+, mesura falsos positius amb casos del tipus "preguntes semblants amb resposta diferent" (els adversarials suaus de 02-04 són bons candidats) i baixa només amb evidència.
  • Consell: desa al costat de cada vector el text que el va originar. Un vector sense el seu text és irrecuperable — no hi ha operació "des-embeddir".

Exercicis

  1. Top-k amb llindar mínim. Modifica cercar() perquè accepti un paràmetre llindar_minim i descarti els fragments amb similitud per sota, encara que això deixi menys de k resultats. Prova-la amb la pregunta "puc pagar amb criptomonedes?" (que no té resposta al mini-corpus) i observa què retorna.
  2. Keyword vs. semàntica. Implementa una funció cercar_keywords(pregunta, k) que puntuï cada fragment per nombre de paraules en comú amb la pregunta (ignorant majúscules i paraules de menys de 4 lletres). Compara-la amb cercar() per a les preguntes "no puc entrar al meu compte" i "token de l'API": quina guanya en cada cas i per què?
  3. Calibrar el cache semàntic. Amb CacheSemantic, desa la resposta a "com restableixo la contrasenya?" i prova de recuperar-la amb: (a) "he oblidat el meu password, què faig?", (b) "com canvio la contrasenya?", (c) "com convido un usuari?". Prova llindars 0.80, 0.90 i 0.95 i anota en una taula quines combinacions encerten i quines produeixen falsos positius.

Solucions

  1. N'hi ha prou de filtrar abans de tallar:
def cercar(pregunta: str, k: int = 3, llindar_minim: float = 0.0):
    v_pregunta = embed([pregunta], tipus="query")[0]
    puntuats = [
        (similitud_cosinus(v_pregunta, v), f)
        for v, f in zip(VECTORS, FRAGMENTS)
    ]
    puntuats.sort(reverse=True)
    filtrats = [(s, f) for s, f in puntuats if s >= llindar_minim]
    return filtrats[:k]

Amb "puc pagar amb criptomonedes?" i un llindar raonable (p. ex. 0.5), la llista queda buida o gairebé: no hi ha documentació rellevant, i retornar una llista buida és més honest que retornar soroll. Guarda't aquesta idea — a 04-04 serà la peça que dispararà la frase anti-al·lucinació de SYSTEM_DOCUBOT ("No trobo aquesta informació a la documentació disponible.") quan la recuperació no trobi res digne.

  1. Una versió mínima:
def cercar_keywords(pregunta: str, k: int = 3):
    paraules = {p for p in pregunta.lower().split() if len(p) >= 4}
    puntuats = []
    for frag in FRAGMENTS:
        frag_paraules = set(frag.lower().split())
        puntuats.append((len(paraules & frag_paraules), frag))
    puntuats.sort(reverse=True)
    return puntuats[:k]

Resultat esperat: per a "no puc entrar al meu compte", cercar_keywords empata gairebé tot a 0-1 coincidències (només "compte" ajuda, i apareix en diversos fragments) mentre que la semàntica encerta de ple. Per a "token de l'API", la keyword guanya o empata: "token" i "API" són termes exactes que apareixen literalment al fragment correcte. Conclusió: la semàntica domina en llenguatge natural d'usuari; la literal continua sent forta en terminologia exacta.

  1. Resultats típics (els teus variaran segons el model):
Pregunta de prova Sim. típica 0.80 0.90 0.95
(a) "he oblidat el meu password, què faig?" ~0.88 ✅ hit ❌ miss ❌ miss
(b) "com canvio la contrasenya?" ~0.93 ✅ hit ✅ hit ❌ miss
(c) "com convido un usuari?" ~0.55 ✅ correcte (miss) ✅ correcte (miss) ✅ correcte (miss)

Amb 0.95 el cache gairebé no estalvia; amb 0.80 encerta més però el marge de seguretat davant de parells "semblants amb resposta diferent" s'estreny. 0.90 sol ser el compromís inicial raonable — i la xifra definitiva ha de sortir de mesurar sobre el conjunt d'avaluació, no d'aquest exercici.

Conclusió

Avui els embeddings han deixat de ser una metàfora de 01-02 per convertir-se en tres funcions que caben en una pantalla: embed() converteix text en coordenades de significat, similitud_cosinus() mesura l'angle entre dues d'aquestes coordenades, i cercar() troba els top-k fragments de la documentació de Nubelia més propers a qualsevol pregunta — inclosa "no puc entrar", que no comparteix ni una paraula amb la resposta que necessita. Hem vist per què això supera la cerca per paraules clau (i quan no), quins criteris pesen en triar model d'embeddings (independent del LLM, amb els seus vectors incompatibles entre models), i hem cobrat el regal promès a 03-04: CacheSemantic, que reconeix preguntes equivalents per significat amb un llindar calibrat amb cura. Però la nostra cerca té dues vergonyes que hem escombrat sota la catifa: compara la pregunta contra tots els vectors a cada consulta, i aquests vectors viuen en una llista de Python que desapareix en reiniciar el procés — amb centenars de pàgines de documentació real, cap de les dues coses no és acceptable. Resoldre totes dues és la feina de les bases de dades vectorials, i és exactament el que construïm a la lliçó següent (04-02).

© Copyright 2026. Tots els drets reservats