RAG: Ingesta
Salta a la navegació
Salta a la cerca
Contingut
Ingesta de documents
Anem a fer la ingesta de documents al QDrant (ja sabem fer els chunks). Primer de tot, creem la col·lecció josep_pla (una sola vegada, la primera vegada):
curl -X PUT "http://localhost:6333/collections/josep_pla" \
-H "Content-Type: application/json" \
-d '{
"vectors": {
"size": 1024,
"distance": "Cosine"
}
}'
curl "http://localhost:6333/collections/josep_pla"
Sobre la configuració (Default, 1024, Cosine):
- És correcta per a un RAG amb text en català, amb dues condicions.
- 1024 ha de coincidir exactament amb la dimensió del model d’embeddings. Models multilingües habituals que funcionen bé en català, com BAAI/bge-m3 o intfloat/multilingual-e5-large, generen vectors de 1024 dimensions, així que si fas servir un d’aquests, és correcte. Si algun dia canvies a un model de 768 o 384 dimensions, hauràs d’esborrar la col·lecció i tornar-la a crear amb la mida nova. No es pot modificar. Per comprovar la mida, pots fer len(model.encode("prova")) en Python.
- Cosine és la mètrica adequada per a aquests models, que estan pensats per comparar per similitud de cosinus. El Qdrant normalitza els vectors internament quan fas servir Cosine, així que no cal que els normalitzis tu.
- Default vol dir vector sense nom, i és el que crea la comanda de dalt. És el més senzill quan només tens un tipus de vector per document. Això té una conseqüència al codi Python. A l’hora d’inserir i consultar, has de passar el vector directament com a llista (vector=embedding), no com a diccionari amb nom (vector={"nom": embedding}). Si el teu script ja ho feia així, no cal canviar res.
prompt:
en un altre chat m'has ajudat a fer un script per fer els splits i chunks de documents. Com a resultat tinc un fitxer JSONL amb els chunks. Per exemple, el primer document que tinc és:
fitxer club_tandem.txt.chunks.jsonl:
{"id": "27463c80-037a-5bf7-a5a3-9a5ff9e2efe2", "text": "CLUB TÀNDEM (T1xC4) — «EL QUADERN GRIS» DE JOSEP PLA, ELS DIETARIS I LA CONSTRUCCIÓ DEL JO\n\nConversa entre Juliana Canet (JC), presentadora, i Marina Porras (MP).", "metadata": {"doc_id": "club_tandem.txt", "filename": "club_tandem.txt", "format": "txt", "author": "Juliana Canet, Marina Porras", "title": "EL JO: El quadern gris, de Josep Pla", "year": 2025, "description": "Club Tàndem (T1xC4): La Juliana Canet i la Marina Porras conversen sobre El quadern gris de Josep Pla, els dietaris i la construcció del jo.", "url": "https://www.youtube.com/watch?v=VeteJXJlVtE", "chunk_index": 0, "n_chunks": 64, "char_start": 0, "char_end": 162, "n_chars": 162, "n_words": 27}}
{"id": "566b3589-f274-5702-bd1d-65e3635326bb", "text": "—Juliana Canet: Avui és una cita importantíssima.\n\n—Marina Porras: De fet, podríem dir que és la que més... Sempre ho diem, a cada capítol.\n\n—JC: No, però mira, és veritat que Mercè Rodoreda segurament és la més especial per a les dues, diguéssim, com a cosa conjunta i compartida. Amb Montserrat Roig, jo ja vaig explicar que hi tenia una vinculació especial per pur sentimentalisme. I tu tens molt clar que aquest llibre del qual parlarem avui és el que et va generar, si més no, una impressió més grossa de tots, no?\n\n—MP: Potser Ferrater va una mica empatat com a impressió sentimental, perquè a mi Ferrater m'importa molt i ocupa molt. Però és veritat que no tant El quadern gris, sinó Josep Pla en general, que és el tema d'avui, ocupa un lloc gegant en el meu imaginari. Però és que Josep Pla ocupa un lloc gegant a la literatura catalana del segle XX, al periodisme del segle XX. I és un senyor de qui no et pots escapar.", "metadata": {"doc_id": "club_tandem.txt", "filename": "club_tandem.txt", "format": "txt", "author": "Juliana Canet, Marina Porras", "title": "EL JO: El quadern gris, de Josep Pla", "year": 2025, "description": "Club Tàndem (T1xC4): La Juliana Canet i la Marina Porras conversen sobre El quadern gris de Josep Pla, els dietaris i la construcció del jo.", "url": "https://www.youtube.com/watch?v=VeteJXJlVtE", "chunk_index": 1, "n_chunks": 64, "char_start": 164, "char_end": 1093, "section": "UNA CITA IMPORTANTÍSSIMA", "n_chars": 929, "n_words": 171}}
{"id": "9e689daf-0c54-5066-89b1-25593acc0e5c", "text": "Però és que Josep Pla ocupa un lloc gegant a la literatura catalana del segle XX, al periodisme del segle XX. I és un senyor de qui no et pots escapar.\n\n—JC: Absolutament. Si t'agrada una mica llegir o tens interès a escriure, és impossible no topar-se amb Josep Pla, no llegir-lo i que no et quedi, com dius tu, una impressió. Jo, quan vaig llegir Josep Pla per primera vegada, vaig dir: «És impossible que ningú no m'hagi parlat mai d'aquest senyor abans». Saps? Que no m'hi hagués trobat abans.\n\n—MP: Te'n recordes de per on vas començar?\n\n—JC: Jo crec que per El quadern gris.\n\n—MP: Clar, jo també, eh? Crec que és la gran porta d'entrada, El quadern gris. Avui en parlarem.\n\n—JC: Però abans sí que ens toca explicar que Pla és un senyor que, quan el llegeixes, es nota. I bàsicament jo crec que tothom que fa periodisme, per descomptat, però també tothom que escriu en català, Josep Pla l'ha marcat d'alguna manera. Ni que sigui per ignorar-lo o per voler fer una cosa radicalment contrària. Però és com si ningú s'escapés de la influència i del lloc que ocupa Pla a la literatura catalana.", "metadata": {"doc_id": "club_tandem.txt", "filename": "club_tandem.txt", "format": "txt", "author": "Juliana Canet, Marina Porras", "title": "EL JO: El quadern gris, de Josep Pla", "year": 2025, "description": "Club Tàndem (T1xC4): La Juliana Canet i la Marina Porras conversen sobre El quadern gris de Josep Pla, els dietaris i la construcció del jo.", "url": "https://www.youtube.com/watch?v=VeteJXJlVtE", "chunk_index": 2, "n_chunks": 64, "char_start": 942, "char_end": 2037, "section": "UNA CITA IMPORTANTÍSSIMA", "n_chars": 1095, "n_words": 200}}
...
Ara necessitaria doncs un script que em faci la ingesta de tots els chunks a la meva col·lecció.
Com a paràmetre s'haurà de passar la ruta del fitxer de chunks. El nom de la col·lecció ('josep-pla') la pots posar a la capçalera del document (hardcoded), perquè serà fixa.
El script es dirà ingesta_josep_pla.py
Abans que res, en el servidor, igual que he fet en el petit projecte embeddings:
mkdir -p ~/ingesta && cd ~/ingesta python3 -m venv .venv source .venv/bin/activate pip install --upgrade pip pip install torch --index-url https://download.pytorch.org/whl/cu128 python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.get_device_name(0), torch.cuda.get_arch_list())" pip install sentence-transformers qdrant-client pip freeze > requirements.txt # útil per a la reproduïbilitat del TFM
script ingesta_josep_pla.py:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ingesta_josep_pla.py
Fa la ingesta d'un fitxer de chunks (.jsonl) a la col·lecció de Qdrant.
Ús:
python ingesta_josep_pla.py ruta/al/fitxer.chunks.jsonl
Cada línia del JSONL ha de tenir:
{"id": "<uuid>", "text": "...", "metadata": {...}}
La col·lecció ha d'existir prèviament (creada amb curl, 1024 dimensions, Cosine).
Com que els id dels chunks són UUID deterministes, tornar a executar l'script
amb el mateix fitxer no duplica punts: els sobreescriu (upsert).
"""
# ---------------------------------------------------------------------------
# CONFIGURACIÓ
# ---------------------------------------------------------------------------
COLLECTION_NAME = "josep_pla"
QDRANT_URL = "http://localhost:6333"
QDRANT_API_KEY = None # posa-hi la clau si el Qdrant en té
EMBEDDING_MODEL = "BAAI/bge-m3" # ha de ser el MATEIX model que faràs servir per consultar
VECTOR_SIZE = 1024
# Prefix per als documents. bge-m3 no en necessita ("").
# Si fas servir intfloat/multilingual-e5-large, posa "passage: "
# (i a la consulta, "query: ").
PASSAGE_PREFIX = ""
BATCH_SIZE = 32 # chunks per lot (embeddings + upsert)
# ---------------------------------------------------------------------------
import argparse
import json
import sys
from pathlib import Path
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct
from sentence_transformers import SentenceTransformer
def llegeix_chunks(ruta: Path):
"""Llegeix el JSONL i retorna la llista de chunks vàlids."""
chunks = []
with ruta.open("r", encoding="utf-8") as f:
for n_linia, linia in enumerate(f, start=1):
linia = linia.strip()
if not linia:
continue
try:
chunk = json.loads(linia)
except json.JSONDecodeError as e:
print(f" ⚠ Línia {n_linia}: JSON invàlid ({e}). S'omet.")
continue
if not chunk.get("id") or not chunk.get("text", "").strip():
print(f" ⚠ Línia {n_linia}: sense 'id' o 'text'. S'omet.")
continue
chunks.append(chunk)
return chunks
def comprova_colleccio(client: QdrantClient):
"""Comprova que la col·lecció existeix i que la mida del vector quadra."""
if not client.collection_exists(COLLECTION_NAME):
sys.exit(
f"✖ La col·lecció '{COLLECTION_NAME}' no existeix. "
f"Crea-la primer amb la comanda curl."
)
info = client.get_collection(COLLECTION_NAME)
vectors = info.config.params.vectors
mida = getattr(vectors, "size", None)
if mida is None:
sys.exit(
"✖ La col·lecció té vectors amb nom; aquest script espera "
"un vector sense nom (Default)."
)
if mida != VECTOR_SIZE:
sys.exit(
f"✖ La col·lecció espera vectors de {mida} dimensions, "
f"però l'script està configurat per a {VECTOR_SIZE}."
)
def main():
parser = argparse.ArgumentParser(
description=f"Ingesta de chunks a la col·lecció Qdrant '{COLLECTION_NAME}'."
)
parser.add_argument("fitxer", type=Path, help="Ruta al fitxer .jsonl de chunks")
args = parser.parse_args()
if not args.fitxer.is_file():
sys.exit(f"✖ No trobo el fitxer: {args.fitxer}")
# 1. Connexió i comprovació de la col·lecció
client = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)
comprova_colleccio(client)
# 2. Lectura dels chunks
print(f"→ Llegint {args.fitxer} ...")
chunks = llegeix_chunks(args.fitxer)
if not chunks:
sys.exit("✖ No hi ha cap chunk vàlid al fitxer.")
print(f" {len(chunks)} chunks llegits.")
# 3. Model d'embeddings
print(f"→ Carregant el model {EMBEDDING_MODEL} ...")
model = SentenceTransformer(EMBEDDING_MODEL)
dim_model = model.get_sentence_embedding_dimension()
if dim_model != VECTOR_SIZE:
sys.exit(
f"✖ El model genera vectors de {dim_model} dimensions, "
f"però la col·lecció n'espera {VECTOR_SIZE}."
)
# 4. Embeddings + upsert per lots
total = len(chunks)
pujats = 0
for inici in range(0, total, BATCH_SIZE):
lot = chunks[inici:inici + BATCH_SIZE]
textos = [PASSAGE_PREFIX + c["text"] for c in lot]
vectors = model.encode(
textos,
batch_size=BATCH_SIZE,
normalize_embeddings=True,
show_progress_bar=False,
)
punts = []
for chunk, vector in zip(lot, vectors):
# Payload: el text + totes les metadades al primer nivell,
# així es poden fer filtres directes (p. ex. per doc_id o year).
payload = {"text": chunk["text"], **chunk.get("metadata", {})}
punts.append(
PointStruct(id=chunk["id"], vector=vector.tolist(), payload=payload)
)
client.upsert(collection_name=COLLECTION_NAME, points=punts, wait=True)
pujats += len(punts)
print(f" {pujats}/{total} chunks pujats")
# 5. Resum
n_punts = client.count(COLLECTION_NAME, exact=True).count
print(f"✔ Ingesta acabada: {pujats} chunks de {args.fitxer.name}.")
print(f" La col·lecció '{COLLECTION_NAME}' té ara {n_punts} punts en total.")
if __name__ == "__main__":
main()
python3 ingesta_josep_pla.py chunks/club_tandem.txt.chunks.jsonlls 32/64 chunks pujats 64/64 chunks pujats ✔ Ingesta acabada: 64 chunks de club_tandem.txt.chunks.jsonl. La col·lecció 'josep_pla' té ara 64 punts en total.
$ python3 ingesta_josep_pla.py chunks/Pla_CapCreus.pdf.chunks.jsonl
prompt:
Fes-me una comanda curl per recuperar els punts que continguin la paraula 'Dylan'
Per buscar una paraula exacta dins del text no cal fer servir embeddings. N’hi ha prou amb un filtre sobre el camp text del payload, amb l’endpoint scroll:
curl -X POST "http://localhost:6333/collections/josep_pla/points/scroll" \
-H "Content-Type: application/json" \
-d '{
"filter": {
"must": [
{ "key": "text", "match": { "text": "Dylan" } }
]
},
"limit": 100,
"with_payload": true,
"with_vector": false
}'
{"result":{"points":[{"id":"0e9f2797-80c6-5016-8d8b-40fcc4006fa8","payload":{"text":"I és un tio que no renuncia mai a fer les coses que vol fer.\n\n—JC: Que això dius: bé, clar... Doncs no. Ni tan sols hi estic tan a favor, eh? Jo estic molt a favor de fer renúncies també, saps?\n\n—MP: Ostres, jo és que hi trobo una cosa tan magnètica, en aquesta gent que no cedeix ni un mil·límetre...\n\n—JC: A tu et funciona molt, aquesta gent.\n\n—MP: Ostres, si em funciona! Fins i tot encara que potser s'estiguin equivocant i estiguin fent alguna cosa que els fa molta il·lusió, que els sembla el pas més important de la seva vida, però que tu des de fora veus clarament que és un error. Però és seu, és la seva cosa. I llavors, si rectifiquen, encara té molt més mèrit.\n\n—JC: Sí, i l'autocontrol és molt avorrit.\n\n—MP: És horrible. Bob Dylan és un senyor que no té gens d'autocontrol. Arriba allà, i el documental t'explica tot de testimonis de com van viure l'arribada de Dylan. Hi ha poetes, hi ha la Joan Baez, que diu, en plan: «Clar, jo no havia vist mai ningú així». Ho diu amb aquests ulls de senyora completament enamorada, que és el que va estar...\n\n—JC: Però perquè desprèn... sexe, no?","doc_id":"club_tandem.txt","filename":"club_tandem.txt","format":"txt","author":"Juliana Canet, Marina Porras","title":"EL JO: El quadern gris, de Josep Pla","year":2025,"description":"Club Tàndem (T1xC4): La Juliana Canet i la Marina Porras conversen sobre El quadern gris de Josep Pla, els dietaris i la construcció del jo.","url":"https://www.youtube.com/watch?v=VeteJXJlVtE","chunk_index":31,"n_chunks":64,"char_start":26825,"char_end":27924,"section":"BOB DYLAN: CREAR-SE A UN MATEIX","n_chars":1099,"n_words":209}},...
creat per Joan Quintana Compte, octubre 2026