RAG: embeddings en català

De wikijoan
La revisió el 11:15, 16 oct 2026 per Joan (discussió | contribucions) (→‎Exemple més complet)
(dif) ← Versió més antiga | Versió actual (dif) | Versió més nova → (dif)
Salta a la navegació Salta a la cerca

Primera prova: paraphrase-multilingual-MiniLM-L12-v2

Aquests embeddings, que no són els millors per al català, els he fet funcionar amb el portàtil.

Si volem fer embeddings de textos en català, podem utilitzar models multilingües com sentence-transformers. Per exemple, paraphrase-multilingual-MiniLM-L12-v2 funciona amb català i és força lleuger.

Primer instal·lem la llibreria:

pip install sentence-transformers

I ja podem fer els embeddings d'unes frases d'exemple:

from sentence_transformers import SentenceTransformer

# Model multilingüe compatible amb català
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")

texts = [
    "Barcelona és la capital de Catalunya.",
    "El català és una llengua romànica.",
    "Avui fa un dia molt assolellat."
]

# Generar els embeddings
embeddings = model.encode(texts)

print("Nombre de textos:", len(embeddings))
print("Dimensions de cada embedding:", embeddings.shape[1])

print(embeddings[0])

Resposta:

Nombre de textos: 3

Dimensions de cada embedding: 384

[ 0.45152336 -0.17739134  0.33369076  0.32314456  0.01625137 -0.10211392
  0.02397913 -0.00964131 -0.02689971  0.1543721  -0.31138584 -0.53306985
 -0.3249653   0.06214958 -0.01033912 -0.36389765  0.1459078  -0.44203445
...

I això ens serveix per comparar textos, segons la seva similaritat:

from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity

model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")

texts = [
    "M'agrada molt anar a la muntanya.",
    "Disfruto molt fent excursions per la muntanya.",
    "Avui he menjat una paella."
]

embeddings = model.encode(texts)

similarity = cosine_similarity(embeddings)

print(similarity)

Resposta:

[[1.0000001  0.9182879  0.11477765]
 [0.9182879  1.0000001  0.11056139]
 [0.11477765 0.11056139 1.0000006 ]]

Podem veure com els resultats són els esperats. Les dues primeres frases tenen similaritat (parlen de muntanya), i no s'assemblen amb la tercera frase. És usual utilitzar el cosinus per calcular la similaritat.

Per a un projecte de RAG, cerca semàntica o una base de dades vectorial, també podem utilitzar models més moderns i específicament multilingües, i comparar quins funcionen millor per català. Però si amb aquest que m'he instal·lat ja obtenim bons resultats, doncs ja estaria.

Embeddings per al català

Anem a fer els embeddings i una primera prova d'inserció dels embeddings al QDrant.

prompt:

Estic fent el TFM del màster de Tecnologies de Llenguatge de la UNED. En el TFM faré un RAG sobre l'obra de Josep Pla.
Ja sé fer els chunks dels documents, i ara crec que aquests chunks els he de convertir a embeddings (català) i que en el QDrant hauré d'inserir aquests embeddings, i no pas els chunks en format text. És així la cosa?

R: ...

Cada element que insereixes a Qdrant és un punt amb tres parts:

  • id: un identificador únic (enter o UUID).
  • vector: l’embedding del chunk. És el que Qdrant fa servir per buscar per similitud.
  • payload: un JSON lliure on guardes el text original del chunk i les metadades (obra, capítol, any, pàgina, etc.).

El text és imprescindible. En un RAG, quan arriba una pregunta, la converteixes en embedding, Qdrant et retorna els punts més semblants, i el que passes al LLM com a context és el text d’aquests chunks, no pas els vectors. Si només guardes vectors, no podràs recuperar el contingut. Les metadades també et serviran per filtrar (per exemple, “només El quadern gris”) i per citar les fonts a la resposta.

Un exemple mínim amb python:

from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-m3")  # multilingüe, funciona bé en català
client = QdrantClient("localhost", port=6333)

client.create_collection(
    collection_name="josep_pla",
    vectors_config=VectorParams(
        size=model.get_sentence_embedding_dimension(),  # 1024 per a bge-m3
        distance=Distance.COSINE,
    ),
)

chunks = [
    {"text": "...", "obra": "El quadern gris", "data": "1918-03-12"},
    # ...
]

vectors = model.encode([c["text"] for c in chunks], normalize_embeddings=True)

client.upsert(
    collection_name="josep_pla",
    points=[
        PointStruct(id=i, vector=v.tolist(), payload=c)
        for i, (v, c) in enumerate(zip(vectors, chunks))
    ],
)

# Consulta
q = model.encode("Què pensa Pla de Palafrugell?", normalize_embeddings=True)
hits = client.query_points("josep_pla", query=q.tolist(), limit=5).points
context = [h.payload["text"] for h in hits]

Alguns aspectes a tenir en compte per al TFM:

  • El mateix model per als chunks i per a les preguntes. Si canvies de model, has de tornar a indexar-ho tot. La mida del vector de la col·lecció ha de coincidir amb la dimensió del model.
  • Prefixos d’alguns models. La família multilingual-e5 requereix prefixos ("query: " per a la pregunta i "passage: " per als chunks). BGE-M3 no en necessita.
  • Models per al català. BGE-M3 i intfloat/multilingual-e5-large són bones opcions multilingües. També val la pena mirar els models de sentence embeddings del Projecte AINA (BSC), que estan entrenats específicament amb català. Comparar-ne dos o tres amb un petit conjunt de preguntes de prova et donaria una secció d’avaluació interessant.
  • Cerca híbrida (opcional). Qdrant admet vectors densos i dispersos (sparse) en el mateix punt. Combinar l’embedding semàntic amb un de tipus BM25 ajuda molt amb noms propis i topònims, que en Pla n’hi ha a dojo.

prompt:

Per a embeddings de català, quin model em recomanes:
paraphrase-multilingual-MiniLM-L12-v2 o bé BAAI/bge-m3. O algun altre?

R: ...

Per què és millor bge-m3:

  • Està entrenat específicament per a recuperació multilingüe i funciona bé en català.
  • Accepta fins a 8.192 tokens, així que no et limita la mida dels chunks.
  • Genera vectors densos i dispersos alhora. Pots fer cerca híbrida a Qdrant amb un sol model, cosa que va molt bé per a noms propis i topònims (Calella, Llofriu, Cadaqués…).
  • Inconvenient: és més pesat (uns 570M paràmetres, 1024 dimensions). Amb GPU va de pressa. Amb CPU, indexar tota l’obra de Pla trigarà hores, però només ho fas un cop.

prompt:

Vull executar el codi mínim que m'has proposat en el meu servidor amb una NVIDIA RTX 5060 Ti 16GB. Suposo que és millor fer-ho en un venv.
M'expliques pas a pas com fer-ho?

R: ...

3. Crea i activa el venv

mkdir -p ~/embeddings && cd ~/embeddings
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip

4. Instal·la PyTorch amb CUDA 12.8

pip install torch --index-url https://download.pytorch.org/whl/cu128

Comprova que detecta la GPU:

python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.get_device_name(0), torch.cuda.get_arch_list())"

2.11.0+cu128 True NVIDIA GeForce RTX 5060 Ti ['sm_75', 'sm_80', 'sm_86', 'sm_90', 'sm_100', 'sm_120']

5. Instal·la la resta de llibreries

pip install sentence-transformers qdrant-client
pip freeze > requirements.txt   # útil per a la reproduïbilitat del TFM

6. Engega Qdrant amb Docker (això ja ho tinc fet)

De moment no faré servir el Docker amb opció GPU perquè presumiblement la GPU la necessitaré per altres tasques.


En una altra consola del servidor, arrenquem el docker. Recordem que per obrir la sessió per SSH ho he de fer amb la comanda (túnel SSH):

ssh -L 6333:127.0.0.1:6333 joan@192.168.1.184

I ara sí que podem arrencar el docker

docker run -p 6333:6333 -p 6334:6334 \
    -v $(pwd)/qdrant_storage:/qdrant/storage:z \
    -v $(pwd)/qdrant_snapshots:/qdrant/snapshots:z \
    -v $(pwd)/qdrant/config/production.yaml:/qdrant/config/production.yaml:z \
    qdrant/qdrant

i puc accedir al docker des del portàtil amb:

El -v fa que les dades persisteixin encara que aturis el contenidor. Pots comprovar que funciona amb curl http://localhost:6333 o obrint http://IP-DEL-SERVIDOR:6333/dashboard al navegador. script prova_rag.py:

És el codi d’abans amb tres millores: fa servir la GPU en mitja precisió (fp16, més ràpid i amb menys memòria), no peta si la col·lecció ja existeix, i codifica per lots.

import torch
from qdrant_client import QdrantClient
from qdrant_client.models import VectorParams, Distance, PointStruct
from sentence_transformers import SentenceTransformer

COLLECTION = "josep_pla"

# Model a la GPU en fp16
model = SentenceTransformer(
    "BAAI/bge-m3",
    device="cuda",
    model_kwargs={"torch_dtype": torch.float16},
)
dim = model.get_sentence_embedding_dimension()

client = QdrantClient("localhost", port=6333)

if not client.collection_exists(COLLECTION):
    client.create_collection(
        collection_name=COLLECTION,
        vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
    )

# Substitueix-ho pels teus chunks reals
chunks = [
    {"text": "Palafrugell és un poble de la costa empordanesa...", "obra": "El quadern gris"},
    {"text": "El vent de tramuntana bufa amb força tot l'hivern...", "obra": "Les hores"},
]

vectors = model.encode(
    [c["text"] for c in chunks],
    batch_size=32,
    normalize_embeddings=True,
    show_progress_bar=True,
)

client.upsert(
    collection_name=COLLECTION,
    points=[
        PointStruct(id=i, vector=v.tolist(), payload=c)
        for i, (v, c) in enumerate(zip(vectors, chunks))
    ],
)

# Consulta de prova
pregunta = "Què diu Pla de Palafrugell?"
q = model.encode(pregunta, normalize_embeddings=True)
hits = client.query_points(COLLECTION, query=q.tolist(), limit=5).points

for h in hits:
    print(f"{h.score:.3f}  [{h.payload['obra']}]  {h.payload['text'][:80]}")

Davant de la pregunta: "Què diu Pla de Palafrugell?", obtenim les respostes classificades per score:

0.582  [El quadern gris]  Palafrugell és un poble de la costa empordanesa...
0.285  [Les hores]  El vent de tramuntana bufa amb força tot l'hivern...

Per tant, ara ja estem en la situació de fer la ingesta dels nostres documents.

Exemple més complet

script prova_rag_50.py':

  • Indexa 50 fragments repartits en 10 temes (paisatge, cuina, mar, periodisme, Cadaqués…) en una col·lecció nova, prova_pla_50. Així no es barreja amb la de la prova anterior. Cada vegada que l’executes, la col·lecció es recrea des de zero.
  • Llança 18 preguntes de les quals sabem quin és el fragment correcte. Per a cada pregunta mostra els 3 primers resultats amb la puntuació, i marca amb → el fragment que hauria de sortir.
  • Al final calcula tres mètriques: encert al top-1, Recall@3 i MRR@5. Són les mateixes que podràs fer servir a l’avaluació del TFM.
  • Les preguntes fan servir paraules diferents de les del fragment. Per exemple, “conreus de raïm” per trobar “vinyes”, o “capital francesa” per trobar “París”. Si el sistema les encerta, demostra que busca per significat i no per coincidència de paraules.
TEXTOS = {
    "paisatge": [
        "La tramuntana neteja el cel de l'Empordà i deixa les muntanyes retallades amb una nitidesa gairebé dolorosa.",
        "A l'hivern, quan bufa el vent del nord durant dies seguits, la gent de la plana es torna irritable i dorm malament.",
        "La plana empordanesa és un mosaic de camps de blat, rengles de xiprers i masos escampats entre turons suaus.",
        "Els aiguamolls prop de la desembocadura del Fluvià s'omplen d'ocells migratoris a la primavera.",
        "La llum de setembre té una qualitat daurada i serena que fa semblar els pobles més antics del que són.",
    ],
    "palafrugell": [
        "Al poble, el mercat del diumenge era el centre de la vida social: s'hi venia de tot i s'hi comentaven totes les notícies.",
        "Les fàbriques de suro donaven feina a gran part de la població i l'olor de l'escorça bullida impregnava els carrers.",
        "A Calella, les barques es varaven a la sorra i els pescadors reparaven les xarxes asseguts a l'ombra de les voltes.",
        "Les havaneres es cantaven a la taverna del port amb rom cremat, recordant els viatges a Cuba.",
        "El casino del poble era el lloc on els senyors jugaven a cartes i discutien de política fins a la matinada.",
    ],
...

PREGUNTES = [
    ("Quin efecte té el vent del nord sobre l'estat d'ànim de la gent?", [1]),
    ("Quina indústria donava treball a Palafrugell?", [6]),
    ("Quin plat barreja carn i marisc?", [10]),
    ("Com es pescaven les sardines de nit?", [16]),
    ("Què feia quan era a la capital francesa?", [21]),
    ("On anava a llegir tranquil·lament a Barcelona?", [25]),
    ("Com es tancaven els negocis a les fires de pagès?", [33]),
    ("Quina és la principal dificultat de l'ofici d'escriure?", [35]),
    ("Per què és útil escriure un diari personal?", [36]),
    ("Què va passar amb els conreus de raïm a la costa?", [42]),
    ("Per què venien artistes a Cadaqués?", [44]),
    ("Quines cançons se sentien a la taverna dels mariners?", [8]),
    ("Com endevinen els pescadors si farà mal temps?", [15]),
    ("Què en pensava, de la carrera universitària?", [27]),
    ("Què hi havia a les places durant les festes d'estiu?", [48]),
    ("Qui entrava mercaderia d'amagat per les cales?", [19]),
    ("Per què els joves abandonaven el camp?", [34]),
    ("Com es miren els vells els somnis que tenien de joves?", [49]),
]
$ python3 prova_rag_50.py

...
✓  Quin efecte té el vent del nord sobre l'estat d'ànim de la gent?
   → 0.646  [paisatge] A l'hivern, quan bufa el vent del nord durant dies seguits, la gent de la plana es to
     0.470  [vida_quotidiana] La vellesa porta una certa serenitat i fa veure les ambicions de joventut amb ironia.
     0.437  [paisatge] La tramuntana neteja el cel de l'Empordà i deixa les muntanyes retallades amb una nit

✓  Quina indústria donava treball a Palafrugell?
   → 0.439  [palafrugell] Les fàbriques de suro donaven feina a gran part de la població i l'olor de l'escorça 
     0.342  [pagesia] La sequera d'aquell estiu va arruïnar la collita i molts joves van marxar a treballar
     0.331  [palafrugell] A Calella, les barques es varaven a la sorra i els pescadors reparaven les xarxes ass

✓  Quin plat barreja carn i marisc?
   → 0.470  [cuina] La cuina empordanesa combina el mar i la muntanya: el pollastre amb llagosta n'és l'e
     0.395  [cuina] A la tardor, els bolets omplen les cuines: rovellons a la brasa amb all i julivert.
     0.392  [cuina] Un bon suquet de peix necessita peix de roca fresc, patates tallades a rodanxes i una
...
============================================================
Preguntes:        18
Encert al top-1:  18/18  (100%)
Recall@3:         18/18  (100%)
MRR@5:           1.000

creat per Joan Quintana Compte, octubre 2026