Diferència entre revisions de la pàgina «QDrant. Bases de dades semàntiques»
m (→QDrant) |
m (→PyMuPDF) |
||
| (Hi ha 9 revisions intermèdies del mateix usuari que no es mostren) | |||
| Línia 1: | Línia 1: | ||
| + | __TOC__ | ||
=QDrant= | =QDrant= | ||
| − | * [https://qdrant-qdrant-18.mintlify.app/installation] | + | * [https://qdrant-qdrant-18.mintlify.app/installation https://qdrant-qdrant-18.mintlify.app/installation] |
<pre> | <pre> | ||
| Línia 18: | Línia 19: | ||
</pre> | </pre> | ||
| − | + | Run with default settings: | |
| + | <pre> | ||
| + | docker run -p 6333:6333 qdrant/qdrant | ||
| + | </pre> | ||
| + | ==Connexió des del portàtil== | ||
| + | Normalment treballo per SSH des del portàtil (headless server). Per tant, vull connectar-me des del portàtil. La IP fixa del servidor és 192.168.1.184. | ||
| + | |||
| + | L'opció recomanada és fer un túnel SSH | ||
| + | <pre> | ||
| + | El teu portàtil -> Connexió SSH -> Servidor 192.168.1.184 | ||
| + | Navegador: localhost:6333 <- Túnel xifrat pel port 6333 <- Qdrant escolta al port 6333 | ||
| + | </pre> | ||
| + | Des del portàtil ja ens connectàvem per ssh (sense password), però ara li afegim el port 6333 | ||
| + | <pre> | ||
| + | ssh -L 6333:127.0.0.1:6333 joan@192.168.1.184 | ||
| + | </pre> | ||
| + | |||
| + | I ara ja funciona des del portàtil: | ||
| + | * http://localhost:6333/dashboard#/welcome | ||
| + | ==Seguim amb el quickstart== | ||
| + | * http://localhost:6333/dashboard#/tutorial/quickstart | ||
| + | o bé la versió oline, on s'utililtza ''curl'': | ||
| + | * https://qdrant-qdrant-18.mintlify.app/quickstart | ||
Afegim una nova col·lecció: | Afegim una nova col·lecció: | ||
| Línia 35: | Línia 58: | ||
} | } | ||
| + | </pre> | ||
| + | En aquest cas hem clicat RUN, però internament la manera d'executar seria: | ||
| + | <pre> | ||
| + | curl -X PUT 'http://localhost:6333/collections/test_collection' \ | ||
| + | -H 'Content-Type: application/json' \ | ||
| + | --data-raw '{ | ||
| + | "vectors": { | ||
| + | "size": 4, | ||
| + | "distance": "Dot" | ||
| + | } | ||
| + | }' | ||
</pre> | </pre> | ||
| + | |||
Afegim punts a la col·lecció: | Afegim punts a la col·lecció: | ||
| Línia 82: | Línia 117: | ||
</pre> | </pre> | ||
| + | o bé: | ||
| + | <pre> | ||
| + | curl -L -X PUT 'http://localhost:6333/collections/test_collection/points?wait=true' \ | ||
| + | -H 'Content-Type: application/json' \ | ||
| + | --data-raw '{ | ||
| + | "points": [ | ||
| + | {"id": 1, "vector": [0.05, 0.61, 0.76, 0.74], "payload": {"city": "Berlin"}}, | ||
| + | {"id": 2, "vector": [0.19, 0.81, 0.75, 0.11], "payload": {"city": ["Berlin", "London"]}}, | ||
| + | {"id": 3, "vector": [0.36, 0.55, 0.47, 0.94], "payload": {"city": ["Berlin", "Moscow"]}}, | ||
| + | {"id": 4, "vector": [0.18, 0.01, 0.85, 0.80], "payload": {"city": ["London", "Moscow"]}}, | ||
| + | {"id": 5, "vector": [0.24, 0.18, 0.22, 0.44], "payload": {"count": [0]}}, | ||
| + | {"id": 6, "vector": [0.35, 0.08, 0.11, 0.44]} | ||
| + | ] | ||
| + | }' | ||
| + | </pre> | ||
Step 3: Run a search query | Step 3: Run a search query | ||
| Línia 126: | Línia 176: | ||
"time": 0.000833372 | "time": 0.000833372 | ||
} | } | ||
| + | </pre> | ||
| + | o bé: | ||
| + | <pre> | ||
| + | curl -L -X POST 'http://localhost:6333/collections/test_collection/points/search' \ | ||
| + | -H 'Content-Type: application/json' \ | ||
| + | --data-raw '{ | ||
| + | "vector": [0.2, 0.1, 0.9, 0.7], | ||
| + | "top": 3 | ||
| + | }' | ||
| + | </pre> | ||
| + | resultat: | ||
| + | <pre> | ||
| + | {"result":[{"id":4,"version":1,"score":1.362},{"id":1,"version":1,"score":1.273},{"id":3,"version":1,"score":1.208}],"status":"ok","time":0.000478823} | ||
</pre> | </pre> | ||
| − | + | ==Persistència== | |
| + | '''Persistència''': que és guardin les dades | ||
<pre> | <pre> | ||
| Línia 134: | Línia 198: | ||
-v $(pwd)/qdrant_storage:/qdrant/storage:z \ | -v $(pwd)/qdrant_storage:/qdrant/storage:z \ | ||
-v $(pwd)/qdrant_snapshots:/qdrant/snapshots:z \ | -v $(pwd)/qdrant_snapshots:/qdrant/snapshots:z \ | ||
| − | -v $(pwd)/config/production.yaml:/qdrant/config/production.yaml:z \ | + | -v $(pwd)/qdrant/config/production.yaml:/qdrant/config/production.yaml:z \ |
qdrant/qdrant | qdrant/qdrant | ||
</pre> | </pre> | ||
primer he de crear el fitxer production.yaml amb la configuració mínima com es comenta en el tutorial. | primer he de crear el fitxer production.yaml amb la configuració mínima com es comenta en el tutorial. | ||
| + | * https://qdrant-qdrant-18.mintlify.app/quickstart | ||
| + | |||
| + | Understanding the volume mounts: | ||
| + | * /qdrant/storage - Where Qdrant persists all your data (mount as volume to prevent data loss) | ||
| + | * /qdrant/snapshots - Where Qdrant stores collection snapshots for backup/restore | ||
| + | * /qdrant/config/production.yaml - Custom configuration file (override values from the reference config) | ||
| + | |||
| + | * http://localhost:6333/dashboard | ||
<pre> | <pre> | ||
| + | mkdir -p ~/qdrant/config | ||
| + | nano ~/qdrant/config/production.yaml | ||
| + | </pre> | ||
| + | Contingut mínim: | ||
| + | </pre> | ||
| + | log_level: INFO | ||
| + | |||
| + | service: | ||
| + | host: 127.0.0.1 | ||
| + | http_port: 6333 | ||
| + | grpc_port: 6334 | ||
| + | |||
| + | </pre> | ||
| + | Però el ''production.yaml'' del tutorial és: | ||
| + | <pre> | ||
| + | service: | ||
| + | # Required: Enable API key authentication | ||
| + | api_key: your_secret_api_key_here | ||
| + | |||
| + | # Optional: Enable TLS | ||
| + | enable_tls: true | ||
| + | |||
| + | host: 0.0.0.0 | ||
| + | http_port: 6333 | ||
| + | grpc_port: 6334 | ||
| + | |||
| + | storage: | ||
| + | # Persist data to disk | ||
| + | storage_path: ./storage | ||
| + | snapshots_path: ./snapshots | ||
| + | |||
| + | # Reduce RAM usage by storing payloads on disk | ||
| + | on_disk_payload: true | ||
| + | |||
| + | # WAL configuration | ||
| + | wal: | ||
| + | wal_capacity_mb: 32 | ||
| + | wal_segments_ahead: 0 | ||
| + | </pre> | ||
| + | |||
2026-06-03T15:56:36.213394Z ERROR qdrant: Error while starting REST server: TLS file could not be opened: ./tls/cert.pem | 2026-06-03T15:56:36.213394Z ERROR qdrant: Error while starting REST server: TLS file could not be opened: ./tls/cert.pem | ||
| Línia 168: | Línia 280: | ||
| − | Puc executar la rest api en | + | Puc executar la rest api en '''línia de comanda'''. Per ex: |
| + | Creem la col·lecció: | ||
| + | <pre> | ||
| + | curl -X PUT \ | ||
| + | http://localhost:6333/collections/terraforming \ | ||
| + | -H 'Content-Type: application/json' \ | ||
| + | -d '{ | ||
| + | "vectors": { | ||
| + | "size": 4, | ||
| + | "distance": "Dot" | ||
| + | } | ||
| + | }' | ||
| + | </pre> | ||
| + | |||
| + | Configuració de la col·lecció, incloent-hi la mida dels vectors i la mètrica de distància: | ||
<pre> | <pre> | ||
curl -X GET \ | curl -X GET \ | ||
| Línia 179: | Línia 305: | ||
{"result":{"status":"green","optimizer_status":"ok","indexed_vectors_count":0,"points_count":6,"segments_count":8,"config":{"params":{"vectors":{"size":4,"distance":"Dot"},"shard_number":1,"replication_factor":1,"write_consistency_factor":1,"on_disk_payload":true},"hnsw_config":{"m":16,"ef_construct":100,"full_scan_threshold":10000,"max_indexing_threads":0,"on_disk":false},"optimizer_config":{"deleted_threshold":0.2,"vacuum_min_vector_number":1000,"default_segment_number":0,"max_segment_size":null,"memmap_threshold":null,"indexing_threshold":10000,"flush_interval_sec":5,"max_optimization_threads":null,"prevent_unoptimized":null},"wal_config":{"wal_capacity_mb":32,"wal_segments_ahead":0,"wal_retain_closed":1},"quantization_config":null},"payload_schema":{"life":{"data_type":"bool","points":6}},"update_queue":{"length":0}},"status":"ok","time":0.001508578} | {"result":{"status":"green","optimizer_status":"ok","indexed_vectors_count":0,"points_count":6,"segments_count":8,"config":{"params":{"vectors":{"size":4,"distance":"Dot"},"shard_number":1,"replication_factor":1,"write_consistency_factor":1,"on_disk_payload":true},"hnsw_config":{"m":16,"ef_construct":100,"full_scan_threshold":10000,"max_indexing_threads":0,"on_disk":false},"optimizer_config":{"deleted_threshold":0.2,"vacuum_min_vector_number":1000,"default_segment_number":0,"max_segment_size":null,"memmap_threshold":null,"indexing_threshold":10000,"flush_interval_sec":5,"max_optimization_threads":null,"prevent_unoptimized":null},"wal_config":{"wal_capacity_mb":32,"wal_segments_ahead":0,"wal_retain_closed":1},"quantization_config":null},"payload_schema":{"life":{"data_type":"bool","points":6}},"update_queue":{"length":0}},"status":"ok","time":0.001508578} | ||
</pre> | </pre> | ||
| + | |||
| + | Inserim dades d'exemple: | ||
| + | <pre> | ||
| + | curl -X PUT \ | ||
| + | http://localhost:6333/collections/terraforming/points \ | ||
| + | -H 'Content-Type: application/json' \ | ||
| + | -d '{ | ||
| + | "points": [ | ||
| + | { | ||
| + | "id": 1, | ||
| + | "vector": [0.1, 0.2, 0.3, 0.4], | ||
| + | "payload": {"land": "forest", "color": "green", "life": true, "humidity": 40} | ||
| + | }, | ||
| + | { | ||
| + | "id": 2, | ||
| + | "vector": [0.2, 0.3, 0.4, 0.5], | ||
| + | "payload": {"land": "lake", "color": "blue", "life": true, "humidity": 100} | ||
| + | }, | ||
| + | { | ||
| + | "id": 3, | ||
| + | "vector": [0.3, 0.4, 0.5, 0.6], | ||
| + | "payload": {"land": "steppe", "color": "green", "life": false, "humidity": 25} | ||
| + | }, | ||
| + | { | ||
| + | "id": 4, | ||
| + | "vector": [0.4, 0.5, 0.6, 0.7], | ||
| + | "payload": {"land": "desert", "color": "red", "life": false, "humidity": 5} | ||
| + | }, | ||
| + | { | ||
| + | "id": 5, | ||
| + | "vector": [0.5, 0.6, 0.7, 0.8], | ||
| + | "payload": {"land": "marsh", "color": "black", "life": true, "humidity": 90} | ||
| + | }, | ||
| + | { | ||
| + | "id": 6, | ||
| + | "vector": [0.6, 0.7, 0.8, 0.9], | ||
| + | "payload": {"land": "cavern", "color": "black", "life": false, "humidity": 15} | ||
| + | } | ||
| + | ] | ||
| + | }' | ||
| + | </pre> | ||
| + | Comprovem que les dades hi són: | ||
| + | <pre> | ||
| + | curl -X POST \ | ||
| + | http://localhost:6333/collections/terraforming/points/scroll \ | ||
| + | -H 'Content-Type: application/json' \ | ||
| + | -d '{ | ||
| + | "limit": 10, | ||
| + | "with_payload": true, | ||
| + | "with_vector": true | ||
| + | }' | ||
| + | </pre> | ||
| + | Resultat: | ||
| + | <pre> | ||
| + | {"result":{"points":[{"id":1,"payload":{"land":"forest","color":"green","life":true,"humidity":40},"vector":[0.1,0.2,0.3,0.4]},{"id":2,"payload":{"land":"lake","color":"blue","life":true,"humidity":100},"vector":[0.2,0.3,0.4,0.5]},{"id":3,"payload":{"land":"steppe","color":"green","life":false,"humidity":25},"vector":[0.3,0.4,0.5,0.6]},{"id":4,"payload":{"land":"desert","color":"red","life":false,"humidity":5},"vector":[0.4,0.5,0.6,0.7]},{"id":5,"payload":{"land":"marsh","color":"black","life":true,"humidity":90},"vector":[0.5,0.6,0.7,0.8]},{"id":6,"payload":{"land":"cavern","color":"black","life":false,"humidity":15},"vector":[0.6,0.7,0.8,0.9]}],"next_page_offset":null},"status":"ok","time":0.000492869} | ||
| + | </pre> | ||
| + | |||
| + | Provem un filtre: | ||
| + | <pre> | ||
| + | curl -X POST \ | ||
| + | http://localhost:6333/collections/terraforming/points/scroll \ | ||
| + | -H 'Content-Type: application/json' \ | ||
| + | -d '{ | ||
| + | "filter": { | ||
| + | "must": [ | ||
| + | { | ||
| + | "key": "color", | ||
| + | "match": {"value": "black"} | ||
| + | } | ||
| + | ] | ||
| + | }, | ||
| + | "limit": 10, | ||
| + | "with_payload": true | ||
| + | }' | ||
| + | </pre> | ||
| + | Resultat: | ||
| + | <pre> | ||
| + | {"result":{"points":[{"id":5,"payload":{"land":"marsh","color":"black","life":true,"humidity":90}},{"id":6,"payload":{"land":"cavern","color":"black","life":false,"humidity":15}}],"next_page_offset":null},"status":"ok","time":0.000402086} | ||
| + | </pre> | ||
| + | Com que estic amb persistència, la propera vegada que arrenqui el QDrant les dades hi seran. | ||
| + | |||
| + | =QDrant amb GPU support= | ||
| + | Abans que res, hem d'instal·lar el NVIDIA Container Toolkit, és a dir, les eines per tal de que la GPU sàpiga treballar amb els contenidors. | ||
| + | <pre> | ||
| + | sudo apt-get update | ||
| + | sudo apt-get install -y ca-certificates curl gnupg | ||
| + | |||
| + | curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \ | ||
| + | | sudo gpg --dearmor --yes \ | ||
| + | -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg | ||
| + | |||
| + | curl -s -L \ | ||
| + | https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \ | ||
| + | | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \ | ||
| + | | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list | ||
| + | |||
| + | sudo apt-get update | ||
| + | sudo apt-get install -y nvidia-container-toolkit | ||
| + | </pre> | ||
| + | |||
| + | Configurem docker: | ||
| + | <pre> | ||
| + | sudo nvidia-ctk runtime configure --runtime=docker | ||
| + | </pre> | ||
| + | Aquesta ordre configura Docker perquè pugui utilitzar el runtime NVIDIA. Després, reinicia Docker: | ||
| + | <pre> | ||
| + | sudo systemctl restart docker | ||
| + | </pre> | ||
| + | Comprova que Docker veu la GPU: (Hauries de veure nvidia entre els runtimes disponibles.) | ||
| + | <pre> | ||
| + | $ docker info | grep -i runtime | ||
| + | Runtimes: nvidia runc io.containerd.runc.v2 | ||
| + | Default Runtime: runc | ||
| + | </pre> | ||
| + | |||
| + | <pre> | ||
| + | # Pull GPU-enabled image | ||
| + | docker pull qdrant/qdrant:gpu-nvidia-latest | ||
| + | |||
| + | I ara ja podem utilitzar QDrant aprofitant-nos de la GPU: | ||
| + | # Run with GPU access | ||
| + | docker run -p 6333:6333 \ | ||
| + | --gpus all \ | ||
| + | qdrant/qdrant:gpu-nvidia-latest | ||
| + | </pre> | ||
| + | |||
| + | I amb persistència: (reaprofito els punts de storage, suposant que no hi ha problema) | ||
| + | <pre> | ||
| + | docker run -p 6333:6333 -p 6334:6334 \ | ||
| + | --gpus all \ | ||
| + | -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:gpu-nvidia-latest | ||
| + | </pre> | ||
| + | |||
| + | =PyMuPDF= | ||
| + | |||
| + | '''NOTA''': interessa extreure el text dels pdfs, i fer-ne chunks. Però les fonts documentals que tindré no seran només pdfs, sinó txt, md,... I després hauré de fer els embeddings dels chunks. Embeddings per al català. I el que s'ingestarà al QDrant seran els embeddings (és a dir, números) | ||
| + | |||
| + | '''PyMuPDF''' (paquet ''pymupdf'', importat com fitz) és una de les millors opcions per extreure text de PDFs en Python: és ràpid, estable i acostuma a preservar millor l'estructura que moltes alternatives. | ||
| + | |||
| + | Necessitem pymupdf perquè hem de fer la ingesta dels documents pdf a la base de dades semàntica. | ||
| + | |||
| + | <pre> | ||
| + | pip install pymupdf | ||
| + | </pre> | ||
| + | |||
| + | Exemple bàsic: extreure tot el text (funciona) | ||
| + | <pre> | ||
| + | import fitz # PyMuPDF | ||
| + | |||
| + | doc = fitz.open("sample.pdf") | ||
| + | |||
| + | text_complet = "" | ||
| + | |||
| + | for pagina in doc: | ||
| + | text_complet += pagina.get_text() | ||
| + | |||
| + | doc.close() | ||
| + | |||
| + | print(text_complet) | ||
| + | </pre> | ||
| + | |||
| + | Extreure pàgina per pàgina | ||
| + | |||
| + | Això és útil en un RAG perquè pots guardar metadades com el número de pàgina. | ||
| + | |||
| + | <pre> | ||
| + | import fitz | ||
| + | |||
| + | doc = fitz.open("sample.pdf") | ||
| + | |||
| + | for num_pagina, pagina in enumerate(doc, start=1): | ||
| + | text = pagina.get_text() | ||
| + | print(f"=== Pàgina {num_pagina} ===") | ||
| + | print(text[:500]) | ||
| + | </pre> | ||
| + | |||
| + | Obtenir text amb coordenades | ||
| + | |||
| + | Si més endavant vols reconstruir capítols, notes al peu o columnes: | ||
| + | |||
| + | <pre> | ||
| + | import fitz | ||
| + | |||
| + | doc = fitz.open("sample.pdf") | ||
| + | |||
| + | pagina = doc[0] | ||
| + | |||
| + | blocs = pagina.get_text("blocks") | ||
| + | |||
| + | for bloc in blocs: | ||
| + | x0, y0, x1, y1, text, *_ = bloc | ||
| + | print(text) | ||
| + | </pre> | ||
| + | |||
| + | Format estructurat (molt útil) | ||
| + | |||
| + | <pre> | ||
| + | import fitz | ||
| + | import json | ||
| + | |||
| + | doc = fitz.open("sample.pdf") | ||
| + | |||
| + | estructura = doc[0].get_text("dict") | ||
| + | |||
| + | print(json.dumps(estructura, indent=2)[:2000]) | ||
| + | </pre> | ||
| + | |||
| + | Crear chunks per al RAG | ||
| + | |||
| + | Una aproximació senzilla: | ||
| + | |||
| + | <pre> | ||
| + | import fitz | ||
| + | |||
| + | doc = fitz.open("sample.pdf") | ||
| + | |||
| + | documents = [] | ||
| + | |||
| + | for page_num, page in enumerate(doc): | ||
| + | text = page.get_text() | ||
| + | |||
| + | documents.append({ | ||
| + | "text": text, | ||
| + | "metadata": { | ||
| + | "source": "sample.pdf", | ||
| + | "page": page_num + 1 | ||
| + | } | ||
| + | }) | ||
| + | <pre> | ||
| + | <pre> | ||
| + | >>> documents[0]['text'] | ||
| + | </pre> | ||
| + | |||
| + | (tots els exemples funcionen) | ||
| + | |||
| + | Per tant, ja sé fer els chunks per al RAG | ||
| + | |||
| + | |||
| + | <pre> | ||
| + | Per al teu projecte d'Història de la Ciència | ||
| + | |||
| + | Jo faria: | ||
| + | |||
| + | PyMuPDF → extracció | ||
| + | |||
| + | Detectar si cal OCR | ||
| + | |||
| + | Guardar metadades: | ||
| + | |||
| + | -llibre | ||
| + | -autor | ||
| + | -any | ||
| + | -pàgina | ||
| + | -idioma | ||
| + | -Fer chunks de 500–800 tokens | ||
| + | -Embeddings -> quan facis textos en català necessitaré els embeddings del català | ||
| + | -Qdrant | ||
| + | </pre> | ||
| + | |||
| + | A l'hora de fer el chunking, es pot utilitzar llamaindex, perquè té opcions avançades com respectar frases i paràgrafs. | ||
| + | |||
| + | Opció 2: LlamaIndex | ||
| + | |||
| + | LlamaIndex aporta diverses estratègies de chunking: | ||
| + | |||
| + | SentenceSplitter -> Respecta frases i paràgrafs. | ||
| + | |||
| + | <pre> | ||
| + | from llama_index.core.node_parser import SentenceSplitter | ||
| + | |||
| + | splitter = SentenceSplitter( | ||
| + | chunk_size=800, | ||
| + | chunk_overlap=100, | ||
| + | ) | ||
| + | </pre> | ||
| + | |||
| + | Això acostuma a funcionar millor que tallar exactament cada N caràcters. | ||
| + | |||
| + | |||
| + | Opció 3: LangChain Text Splitters | ||
| + | |||
| + | |||
| + | |||
| + | LangChain Text Splitters -> Una alternativa lleugera si no vols adoptar tot LlamaIndex. | ||
| + | |||
| + | Per exemple: | ||
| + | |||
| + | <pre> | ||
| + | from langchain_text_splitters import RecursiveCharacterTextSplitter | ||
| + | </pre> | ||
| + | |||
| + | |||
| + | Aquest és molt popular perquè: | ||
| + | |||
| + | * intenta respectar paràgrafs | ||
| + | * després frases | ||
| + | * després espais (només talla "a la força" si cal) | ||
| + | |||
| + | Per a molts projectes és suficient. | ||
| + | |||
| + | Buscant una solució lleugera (que seria un dels meus objectius), puc utilitzar langchain text splitter | ||
| + | |||
| + | <pre> | ||
| + | pip install langchain-text-splitters | ||
| + | |||
| + | El més recomanat: ''RecursiveCharacterTextSplitter'' -> És el splitter que acostuma a donar millors resultats generals. | ||
| + | |||
| + | <pre> | ||
| + | from langchain_text_splitters import RecursiveCharacterTextSplitter | ||
| + | |||
| + | splitter = RecursiveCharacterTextSplitter( | ||
| + | chunk_size=1000, | ||
| + | chunk_overlap=150 | ||
| + | ) | ||
| + | |||
| + | chunks = splitter.split_text(text) | ||
| + | </pre> | ||
| + | |||
| + | On: | ||
| + | |||
| + | * chunk_size=1000: mida aproximada del fragment | ||
| + | * chunk_overlap=150: solapament entre fragments | ||
| + | |||
| + | <pre> | ||
| + | for i, chunk in enumerate(chunks): | ||
| + | print(f"Chunk {i}") | ||
| + | print(chunk[:200]) | ||
| + | </pre> | ||
| + | |||
| + | |||
| + | <pre> | ||
| + | Per què "Recursive"? | ||
| + | |||
| + | Intenta dividir respectant aquesta jerarquia: | ||
| + | |||
| + | Paràgrafs | ||
| + | ↓ | ||
| + | Línies | ||
| + | ↓ | ||
| + | Espais | ||
| + | ↓ | ||
| + | Caràcters | ||
| + | </pre> | ||
| + | |||
| + | |||
| + | És interessant guardar les metadades (recomanat per RAG), per ex, el número de pàgina. | ||
| + | |||
| + | |||
| + | Si tens pàgines de PyMuPDF: | ||
| + | |||
| + | <pre> | ||
| + | documents = [ | ||
| + | { | ||
| + | "text": page_text, | ||
| + | "page": page_num | ||
| + | } | ||
| + | ] | ||
| + | </pre> | ||
| + | |||
| + | per ex: | ||
| + | |||
| + | <pre> | ||
| + | documents = [ | ||
| + | { | ||
| + | "text": "kjh jhlkj hl", | ||
| + | "page": 1 | ||
| + | } | ||
| + | ] | ||
| + | </pre> | ||
| + | |||
| + | pots convertir-les en Documents: | ||
| + | |||
| + | <pre> | ||
| + | from langchain_core.documents import Document | ||
| + | |||
| + | docs = [ | ||
| + | Document( | ||
| + | page_content=page_text, | ||
| + | metadata={"page": page_num} | ||
| + | ) | ||
| + | ] | ||
| + | |||
| + | docs = [ | ||
| + | Document( | ||
| + | page_content="gshdgsj ajj dgsahjgd ahj gdhagd ha gdhsa ", | ||
| + | metadata={"page": 1} | ||
| + | ) | ||
| + | ] | ||
| + | |||
| + | chunks = splitter.split_documents(docs) | ||
| + | |||
| + | print(chunks[0].page_content) | ||
| + | print(chunks[0].metadata) | ||
| + | </pre> | ||
| + | |||
| + | '''Exemple complet amb PyMuPDF''': | ||
| + | |||
| + | <pre> | ||
| + | import fitz | ||
| + | |||
| + | from langchain_core.documents import Document | ||
| + | from langchain_text_splitters import RecursiveCharacterTextSplitter | ||
| + | |||
| + | doc = fitz.open("sample.pdf") | ||
| + | |||
| + | documents = [] | ||
| + | |||
| + | for page_num, page in enumerate(doc, start=1): | ||
| + | documents.append( | ||
| + | Document( | ||
| + | page_content=page.get_text(), | ||
| + | metadata={ | ||
| + | "source": "sample.pdf", | ||
| + | "page": page_num | ||
| + | } | ||
| + | ) | ||
| + | ) | ||
| + | |||
| + | |||
| + | splitter = RecursiveCharacterTextSplitter( | ||
| + | chunk_size=1000, | ||
| + | chunk_overlap=150 | ||
| + | ) | ||
| + | |||
| + | chunks = splitter.split_documents(documents) | ||
| + | |||
| + | print(f"Chunks creats: {len(chunks)}") | ||
| + | print(chunks[0].metadata) | ||
| + | </pre> | ||
| + | |||
| + | (TODO) | ||
| + | |||
| + | * I ara que tinc tots els chunks d'un document, em falta fer la ingesta del document al QDrant. -> compte! la ingesta que farem al QDrant són els embeddings. | ||
| + | * I fer consultes sobre el document | ||
| + | |||
| + | {{Autor}}, octubre 2026 | ||
Revisió de 22:48, 15 oct 2026
Contingut
QDrant
docker pull qdrant/qdrant docker run -p 6333:6333 qdrant/qdrant ... Access web UI at http://localhost:6333/dashboard
Qdrant is now available at:
REST API: http://localhost:6333
Web UI: http://localhost:6333/dashboard
Run with default settings:
docker run -p 6333:6333 qdrant/qdrant
Connexió des del portàtil
Normalment treballo per SSH des del portàtil (headless server). Per tant, vull connectar-me des del portàtil. La IP fixa del servidor és 192.168.1.184.
L'opció recomanada és fer un túnel SSH
El teu portàtil -> Connexió SSH -> Servidor 192.168.1.184 Navegador: localhost:6333 <- Túnel xifrat pel port 6333 <- Qdrant escolta al port 6333
Des del portàtil ja ens connectàvem per ssh (sense password), però ara li afegim el port 6333
ssh -L 6333:127.0.0.1:6333 joan@192.168.1.184
I ara ja funciona des del portàtil:
Seguim amb el quickstart
o bé la versió oline, on s'utililtza curl:
Afegim una nova col·lecció:
PUT collections/star_charts
{
"vectors": {
"size": 4,
"distance": "Dot"
}
}
En aquest cas hem clicat RUN, però internament la manera d'executar seria:
curl -X PUT 'http://localhost:6333/collections/test_collection' \
-H 'Content-Type: application/json' \
--data-raw '{
"vectors": {
"size": 4,
"distance": "Dot"
}
}'
Afegim punts a la col·lecció:
PUT collections/star_charts/points
{
"points": [
{
"id": 1,
"vector": [0.05, 0.61, 0.76, 0.74],
"payload": {
"colony": "Mars"
}
},
{
"id": 2,
"vector": [0.19, 0.81, 0.75, 0.11],
"payload": {
"colony": "Jupiter"
}
},
{
"id": 3,
"vector": [0.36, 0.55, 0.47, 0.94],
"payload": {
"colony": "Venus"
}
},
{
"id": 4,
"vector": [0.18, 0.01, 0.85, 0.80],
"payload": {
"colony": "Moon"
}
},
{
"id": 5,
"vector": [0.24, 0.18, 0.22, 0.44],
"payload": {
"colony": "Pluto"
}
}
]
}
o bé:
curl -L -X PUT 'http://localhost:6333/collections/test_collection/points?wait=true' \
-H 'Content-Type: application/json' \
--data-raw '{
"points": [
{"id": 1, "vector": [0.05, 0.61, 0.76, 0.74], "payload": {"city": "Berlin"}},
{"id": 2, "vector": [0.19, 0.81, 0.75, 0.11], "payload": {"city": ["Berlin", "London"]}},
{"id": 3, "vector": [0.36, 0.55, 0.47, 0.94], "payload": {"city": ["Berlin", "Moscow"]}},
{"id": 4, "vector": [0.18, 0.01, 0.85, 0.80], "payload": {"city": ["London", "Moscow"]}},
{"id": 5, "vector": [0.24, 0.18, 0.22, 0.44], "payload": {"count": [0]}},
{"id": 6, "vector": [0.35, 0.08, 0.11, 0.44]}
]
}'
Step 3: Run a search query
POST collections/star_charts/points/search
{
"vector": [0.2, 0.1, 0.9, 0.7],
"limit": 3,
"with_payload": true
}
resultat:
{
"result": [
{
"id": 4,
"version": 1,
"score": 1.362,
"payload": {
"colony": "Moon"
}
},
{
"id": 1,
"version": 1,
"score": 1.273,
"payload": {
"colony": "Mars"
}
},
{
"id": 3,
"version": 1,
"score": 1.208,
"payload": {
"colony": "Venus"
}
}
],
"status": "ok",
"time": 0.000833372
}
o bé:
curl -L -X POST 'http://localhost:6333/collections/test_collection/points/search' \
-H 'Content-Type: application/json' \
--data-raw '{
"vector": [0.2, 0.1, 0.9, 0.7],
"top": 3
}'
resultat:
{"result":[{"id":4,"version":1,"score":1.362},{"id":1,"version":1,"score":1.273},{"id":3,"version":1,"score":1.208}],"status":"ok","time":0.000478823}
Persistència
Persistència: que és guardin les dades
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
primer he de crear el fitxer production.yaml amb la configuració mínima com es comenta en el tutorial.
Understanding the volume mounts:
- /qdrant/storage - Where Qdrant persists all your data (mount as volume to prevent data loss)
- /qdrant/snapshots - Where Qdrant stores collection snapshots for backup/restore
- /qdrant/config/production.yaml - Custom configuration file (override values from the reference config)
mkdir -p ~/qdrant/config nano ~/qdrant/config/production.yaml
Contingut mínim:
log_level: INFO
service:
host: 127.0.0.1 http_port: 6333 grpc_port: 6334
Però el production.yaml del tutorial és:
service:
# Required: Enable API key authentication
api_key: your_secret_api_key_here
# Optional: Enable TLS
enable_tls: true
host: 0.0.0.0
http_port: 6333
grpc_port: 6334
storage:
# Persist data to disk
storage_path: ./storage
snapshots_path: ./snapshots
# Reduce RAM usage by storing payloads on disk
on_disk_payload: true
# WAL configuration
wal:
wal_capacity_mb: 32
wal_segments_ahead: 0
2026-06-03T15:56:36.213394Z ERROR qdrant: Error while starting REST server: TLS file could not be opened: ./tls/cert.pem
Error: TLS file could not be opened: ./tls/cert.pem
Caused by:
failed to open file `./tls/cert.pem`: No such file or directory (os error 2
-> en el fitxer de configuració he de deshabilitar tls
Ja puc entrar però ara em demana una api key:
This instance of Qdrant might be protected by an API Key. If so, please enter your API Key to continue.
la api key es deshabilita en el fitxer de configuració
Ja funciona el tutorial, i ara ja tinc persistència: és a dir, puc tancar el docker i tornar-lo a obrir, i les dades encara hi són. Recordar que tenim dos volums que s'han creat.
Per ex:
GET collections/terraforming
Puc executar la rest api en línia de comanda. Per ex:
Creem la col·lecció:
curl -X PUT \
http://localhost:6333/collections/terraforming \
-H 'Content-Type: application/json' \
-d '{
"vectors": {
"size": 4,
"distance": "Dot"
}
}'
Configuració de la col·lecció, incloent-hi la mida dels vectors i la mètrica de distància:
curl -X GET \ "http://localhost:6333/collections/terraforming" \ -H "Content-Type: application/json"
Resultat:
{"result":{"status":"green","optimizer_status":"ok","indexed_vectors_count":0,"points_count":6,"segments_count":8,"config":{"params":{"vectors":{"size":4,"distance":"Dot"},"shard_number":1,"replication_factor":1,"write_consistency_factor":1,"on_disk_payload":true},"hnsw_config":{"m":16,"ef_construct":100,"full_scan_threshold":10000,"max_indexing_threads":0,"on_disk":false},"optimizer_config":{"deleted_threshold":0.2,"vacuum_min_vector_number":1000,"default_segment_number":0,"max_segment_size":null,"memmap_threshold":null,"indexing_threshold":10000,"flush_interval_sec":5,"max_optimization_threads":null,"prevent_unoptimized":null},"wal_config":{"wal_capacity_mb":32,"wal_segments_ahead":0,"wal_retain_closed":1},"quantization_config":null},"payload_schema":{"life":{"data_type":"bool","points":6}},"update_queue":{"length":0}},"status":"ok","time":0.001508578}
Inserim dades d'exemple:
curl -X PUT \
http://localhost:6333/collections/terraforming/points \
-H 'Content-Type: application/json' \
-d '{
"points": [
{
"id": 1,
"vector": [0.1, 0.2, 0.3, 0.4],
"payload": {"land": "forest", "color": "green", "life": true, "humidity": 40}
},
{
"id": 2,
"vector": [0.2, 0.3, 0.4, 0.5],
"payload": {"land": "lake", "color": "blue", "life": true, "humidity": 100}
},
{
"id": 3,
"vector": [0.3, 0.4, 0.5, 0.6],
"payload": {"land": "steppe", "color": "green", "life": false, "humidity": 25}
},
{
"id": 4,
"vector": [0.4, 0.5, 0.6, 0.7],
"payload": {"land": "desert", "color": "red", "life": false, "humidity": 5}
},
{
"id": 5,
"vector": [0.5, 0.6, 0.7, 0.8],
"payload": {"land": "marsh", "color": "black", "life": true, "humidity": 90}
},
{
"id": 6,
"vector": [0.6, 0.7, 0.8, 0.9],
"payload": {"land": "cavern", "color": "black", "life": false, "humidity": 15}
}
]
}'
Comprovem que les dades hi són:
curl -X POST \
http://localhost:6333/collections/terraforming/points/scroll \
-H 'Content-Type: application/json' \
-d '{
"limit": 10,
"with_payload": true,
"with_vector": true
}'
Resultat:
{"result":{"points":[{"id":1,"payload":{"land":"forest","color":"green","life":true,"humidity":40},"vector":[0.1,0.2,0.3,0.4]},{"id":2,"payload":{"land":"lake","color":"blue","life":true,"humidity":100},"vector":[0.2,0.3,0.4,0.5]},{"id":3,"payload":{"land":"steppe","color":"green","life":false,"humidity":25},"vector":[0.3,0.4,0.5,0.6]},{"id":4,"payload":{"land":"desert","color":"red","life":false,"humidity":5},"vector":[0.4,0.5,0.6,0.7]},{"id":5,"payload":{"land":"marsh","color":"black","life":true,"humidity":90},"vector":[0.5,0.6,0.7,0.8]},{"id":6,"payload":{"land":"cavern","color":"black","life":false,"humidity":15},"vector":[0.6,0.7,0.8,0.9]}],"next_page_offset":null},"status":"ok","time":0.000492869}
Provem un filtre:
curl -X POST \
http://localhost:6333/collections/terraforming/points/scroll \
-H 'Content-Type: application/json' \
-d '{
"filter": {
"must": [
{
"key": "color",
"match": {"value": "black"}
}
]
},
"limit": 10,
"with_payload": true
}'
Resultat:
{"result":{"points":[{"id":5,"payload":{"land":"marsh","color":"black","life":true,"humidity":90}},{"id":6,"payload":{"land":"cavern","color":"black","life":false,"humidity":15}}],"next_page_offset":null},"status":"ok","time":0.000402086}
Com que estic amb persistència, la propera vegada que arrenqui el QDrant les dades hi seran.
QDrant amb GPU support
Abans que res, hem d'instal·lar el NVIDIA Container Toolkit, és a dir, les eines per tal de que la GPU sàpiga treballar amb els contenidors.
sudo apt-get update sudo apt-get install -y ca-certificates curl gnupg curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \ | sudo gpg --dearmor --yes \ -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg curl -s -L \ https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \ | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \ | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list sudo apt-get update sudo apt-get install -y nvidia-container-toolkit
Configurem docker:
sudo nvidia-ctk runtime configure --runtime=docker
Aquesta ordre configura Docker perquè pugui utilitzar el runtime NVIDIA. Després, reinicia Docker:
sudo systemctl restart docker
Comprova que Docker veu la GPU: (Hauries de veure nvidia entre els runtimes disponibles.)
$ docker info | grep -i runtime Runtimes: nvidia runc io.containerd.runc.v2 Default Runtime: runc
# Pull GPU-enabled image
docker pull qdrant/qdrant:gpu-nvidia-latest
I ara ja podem utilitzar QDrant aprofitant-nos de la GPU:
# Run with GPU access
docker run -p 6333:6333 \
--gpus all \
qdrant/qdrant:gpu-nvidia-latest
I amb persistència: (reaprofito els punts de storage, suposant que no hi ha problema)
docker run -p 6333:6333 -p 6334:6334 \
--gpus all \
-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:gpu-nvidia-latest
PyMuPDF
NOTA: interessa extreure el text dels pdfs, i fer-ne chunks. Però les fonts documentals que tindré no seran només pdfs, sinó txt, md,... I després hauré de fer els embeddings dels chunks. Embeddings per al català. I el que s'ingestarà al QDrant seran els embeddings (és a dir, números)
PyMuPDF (paquet pymupdf, importat com fitz) és una de les millors opcions per extreure text de PDFs en Python: és ràpid, estable i acostuma a preservar millor l'estructura que moltes alternatives.
Necessitem pymupdf perquè hem de fer la ingesta dels documents pdf a la base de dades semàntica.
pip install pymupdf
Exemple bàsic: extreure tot el text (funciona)
import fitz # PyMuPDF
doc = fitz.open("sample.pdf")
text_complet = ""
for pagina in doc:
text_complet += pagina.get_text()
doc.close()
print(text_complet)
Extreure pàgina per pàgina
Això és útil en un RAG perquè pots guardar metadades com el número de pàgina.
import fitz
doc = fitz.open("sample.pdf")
for num_pagina, pagina in enumerate(doc, start=1):
text = pagina.get_text()
print(f"=== Pàgina {num_pagina} ===")
print(text[:500])
Obtenir text amb coordenades
Si més endavant vols reconstruir capítols, notes al peu o columnes:
import fitz
doc = fitz.open("sample.pdf")
pagina = doc[0]
blocs = pagina.get_text("blocks")
for bloc in blocs:
x0, y0, x1, y1, text, *_ = bloc
print(text)
Format estructurat (molt útil)
import fitz
import json
doc = fitz.open("sample.pdf")
estructura = doc[0].get_text("dict")
print(json.dumps(estructura, indent=2)[:2000])
Crear chunks per al RAG
Una aproximació senzilla:
import fitz
doc = fitz.open("sample.pdf")
documents = []
for page_num, page in enumerate(doc):
text = page.get_text()
documents.append({
"text": text,
"metadata": {
"source": "sample.pdf",
"page": page_num + 1
}
})
<pre>
<pre>
>>> documents[0]['text']
(tots els exemples funcionen)
Per tant, ja sé fer els chunks per al RAG
Per al teu projecte d'Història de la Ciència Jo faria: PyMuPDF → extracció Detectar si cal OCR Guardar metadades: -llibre -autor -any -pàgina -idioma -Fer chunks de 500–800 tokens -Embeddings -> quan facis textos en català necessitaré els embeddings del català -Qdrant
A l'hora de fer el chunking, es pot utilitzar llamaindex, perquè té opcions avançades com respectar frases i paràgrafs.
Opció 2: LlamaIndex
LlamaIndex aporta diverses estratègies de chunking:
SentenceSplitter -> Respecta frases i paràgrafs.
from llama_index.core.node_parser import SentenceSplitter
splitter = SentenceSplitter(
chunk_size=800,
chunk_overlap=100,
)
Això acostuma a funcionar millor que tallar exactament cada N caràcters.
Opció 3: LangChain Text Splitters
LangChain Text Splitters -> Una alternativa lleugera si no vols adoptar tot LlamaIndex.
Per exemple:
from langchain_text_splitters import RecursiveCharacterTextSplitter
Aquest és molt popular perquè:
- intenta respectar paràgrafs
- després frases
- després espais (només talla "a la força" si cal)
Per a molts projectes és suficient.
Buscant una solució lleugera (que seria un dels meus objectius), puc utilitzar langchain text splitter
pip install langchain-text-splitters
El més recomanat: ''RecursiveCharacterTextSplitter'' -> És el splitter que acostuma a donar millors resultats generals.
<pre>
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=150
)
chunks = splitter.split_text(text)
On:
- chunk_size=1000: mida aproximada del fragment
- chunk_overlap=150: solapament entre fragments
for i, chunk in enumerate(chunks):
print(f"Chunk {i}")
print(chunk[:200])
Per què "Recursive"? Intenta dividir respectant aquesta jerarquia: Paràgrafs ↓ Línies ↓ Espais ↓ Caràcters
És interessant guardar les metadades (recomanat per RAG), per ex, el número de pàgina.
Si tens pàgines de PyMuPDF:
documents = [
{
"text": page_text,
"page": page_num
}
]
per ex:
documents = [
{
"text": "kjh jhlkj hl",
"page": 1
}
]
pots convertir-les en Documents:
from langchain_core.documents import Document
docs = [
Document(
page_content=page_text,
metadata={"page": page_num}
)
]
docs = [
Document(
page_content="gshdgsj ajj dgsahjgd ahj gdhagd ha gdhsa ",
metadata={"page": 1}
)
]
chunks = splitter.split_documents(docs)
print(chunks[0].page_content)
print(chunks[0].metadata)
Exemple complet amb PyMuPDF:
import fitz
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
doc = fitz.open("sample.pdf")
documents = []
for page_num, page in enumerate(doc, start=1):
documents.append(
Document(
page_content=page.get_text(),
metadata={
"source": "sample.pdf",
"page": page_num
}
)
)
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=150
)
chunks = splitter.split_documents(documents)
print(f"Chunks creats: {len(chunks)}")
print(chunks[0].metadata)
(TODO)
- I ara que tinc tots els chunks d'un document, em falta fer la ingesta del document al QDrant. -> compte! la ingesta que farem al QDrant són els embeddings.
- I fer consultes sobre el document
creat per Joan Quintana Compte, octubre 2026