LLMs i Intel·ligència Artificial Generativa
Introducció
El juliol de 2017, un equip de Google Brain va publicar un paper titulat "Attention Is All You Need". No ho sabien en aquell moment, pero aquell article canviaria radicalment la informatica, l'economia i la societat. L'arquitectura Transformer que descrivien es la base de tots els models de llenguatge grans (LLMs) actuals: GPT-4o, Claude 3.5 Sonnet, Gemini 1.5, LLaMA 3.1 i desenes mes.
El 2025, els LLMs no son una curiositat de laboratori: son la infraestructura sobre la qual s'estan construint les aplicacions del futur. Entendre com funcionen, com s'usen de manera professional i quines son les seves limitacions es indispensable per a qualsevol professional d'IA.
1. Arquitectura Transformer: de 2017 al 2025
1.1 El paper original
El Transformer original tenia dues parts: encoder (per a comprendre el text) i decoder (per a generar text). Pero el que era revolucionari no era l'arquitectura en si, sino el mecanisme d'atenció que la fonamentava.
Abans del Transformer, els models de NLP usaven RNNs (xarxes recurrents) i LSTMs que processaven el text token per token, d'esquerra a dreta. Aixo tenia dos problemes greus: 1. Gradient vanishing: informacio de tokens llunyans s'anava perdent 2. No paral·lelitzable: calia esperar el token t per processar t+1
El Transformer va resoldre ambdos problems d'un cop: tots els tokens es processen en paral·lel, i el mecanisme d'atencio permet a cada token "mirar" directament qualsevol altre token del context, independentment de la distancia.
1.2 El mecanisme d'atenció en profunditat
L'atenció escalada de producte intern (Scaled Dot-Product Attention) es:
On: - Q (Query): "Que estic buscant?" - K (Key): "Que ofereixo per a la cerca?" - V (Value): "Quina informacio aporten?"
import numpy as np
import math
def scaled_dot_product_attention(Q, K, V, mask=None):
"""
Implementacio de l'atencio escalada.
Args:
Q: Matriu de queries [batch, seq_len, d_k]
K: Matriu de keys [batch, seq_len, d_k]
V: Matriu de values [batch, seq_len, d_v]
mask: Mascara opcional (per a decodificacio autoregressive)
Returns:
output: [batch, seq_len, d_v]
attention_weights: [batch, seq_len, seq_len]
"""
d_k = Q.shape[-1]
# Calcular scores: com de "relacionat" esta cada query amb cada key
scores = np.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k)
# Aplicar mascara si existeix (per a padding o causal masking)
if mask is not None:
scores = scores + (mask * -1e9)
# Softmax per convertir en probabilitats d'atencio
exp_scores = np.exp(scores - scores.max(axis=-1, keepdims=True))
attention_weights = exp_scores / exp_scores.sum(axis=-1, keepdims=True)
# Suma ponderada dels values
output = np.matmul(attention_weights, V)
return output, attention_weights
# Exemple: frase "el model aprèn"
# Cada paraula = un vector de dimensio d_k=4 (simplificat)
seq_len = 3 # 3 paraules
d_k = 4
Q = np.random.randn(1, seq_len, d_k) # [batch=1, seq=3, d_k=4]
K = np.random.randn(1, seq_len, d_k)
V = np.random.randn(1, seq_len, d_k)
output, weights = scaled_dot_product_attention(Q, K, V)
print(f"Forma sortida: {output.shape}") # (1, 3, 4)
print(f"Pesos atencio:\n{weights[0].round(3)}")
1.3 Multi-Head Attention
En lloc d'un sol mecanisme d'atenció, el Transformer usa múltiples caps en paral·lel, cadascun aprenent a "mirar" aspectes diferents del text (sintaxi, semàntica, co-referència, relacions temporals...):
graph TD
INPUT["Seqüencia d'entrada X"] --> LINEAR1["Projecci lineal\n(W_Q, W_K, W_V)"]
INPUT --> LINEAR2["Projecci lineal\n(W_Q, W_K, W_V)"]
INPUT --> LINEAR3["... (h caps)"]
LINEAR1 --> ATT1["Attention Cap 1\n(sintaxi)"]
LINEAR2 --> ATT2["Attention Cap 2\n(semantica)"]
LINEAR3 --> ATT3["Attention Cap h\n(correferencia)"]
ATT1 --> CONCAT["Concatenar sortides"]
ATT2 --> CONCAT
ATT3 --> CONCAT
CONCAT --> PROJ["Projecci final W_O"]
PROJ --> OUTPUT["Sortida Multi-Head Attention"]
1.4 Decoder-only: la clau dels LLMs moderns
Els LLMs moderns (GPT, LLaMA, Mistral, Claude) usen nomes el decoder del Transformer original. El decoder te una diferencia critica: usa masked self-attention (atencio causal), on cada token nomes pot "mirar" els tokens anteriors, mai els posteriors.
Aixo te una implicacio fonamental: els LLMs generen text d'esquerra a dreta, token per token, predient sempre el proper token mes probable donada tota la seqüencia anterior. Tota la "intel·ligencia" del model esta en aprendre quin token segueix mes probablement en milions de contextos.
graph LR
ENTRADA["Prompt:\n'La IA es una'"] --> T1["Token 1:\n'tecnologia'"]
T1 --> T2["Token 2:\n'que'"]
T2 --> T3["Token 3:\n'aprèn'"]
T3 --> CONT["..."]
style ENTRADA fill:#4a4a8a
style T1 fill:#2a7a4a
style T2 fill:#2a7a4a
style T3 fill:#2a7a4a
1.5 Lleis d'escala (Scaling Laws)
Un dels descobriments mes importants dels darrers anys es que el rendiment dels LLMs millora de manera previsible i quantificable en augmentar tres factors:
- N: nombre de parametres del model
- D: nombre de tokens d'entrenament
- C: compute (FLOPs)
El paper de Chinchilla (Hoffmann et al., 2022, DeepMind) va demostrar que molts models grans estaven subentrenats: eren massa grans respecte a la quantitat de dades d'entrenament. La regla "Chinchilla-optimal" estableix que per a N parametres, cal entrenar amb aproximadament 20*N tokens.
Per exemple: - Model de 7B parametres: necessita ~140B tokens d'entrenament - Model de 70B parametres: necessita ~1.4T tokens
LLaMA 3.1 de Meta va anar mes enlla: els seus models de 8B i 70B van ser entrenats amb 15T tokens, superant la rao Chinchilla perque el compute d'inferencia (posterior) es mes baix amb models petits ben entrenats.
2. Tokenització: per que importa
2.1 Que es un token?
Un token NO es exactament una paraula. Els tokenitzadors moderns divideixen el text en subparaules:
- "intel·ligència" pot ser 3-4 tokens: "intel", "·", "lig", "ència"
- "cat" es 1 token en angles
- "gat" pot ser 1 o 2 tokens en models entrenats principalment en angles
- Els codis de programacio solen tenir tokens mes eficients per a paraules reservades
import tiktoken # Tokenitzador d'OpenAI
# Tokenitzador de GPT-4o
enc = tiktoken.encoding_for_model("gpt-4o")
textos = [
"Hello, world!",
"Hola, mon!",
"Intel·ligència Artificial i Big Data",
"def fibonacci(n: int) -> int:",
"🤖 AI"
]
for text in textos:
tokens = enc.encode(text)
print(f"Text: {repr(text)}")
print(f" Tokens ({len(tokens)}): {tokens}")
print(f" Decoded: {[enc.decode([t]) for t in tokens]}")
print()
2.2 Per que importa per a la IA?
-
Cost: les APIs cobren per token, no per paraula. Un text en catala pot costar 20-30% mes que el mateix en angles perque el catala esta menys representat en l'entrenament.
-
Finestra de context: el context maxim d'un model es mesura en tokens. 100K tokens son aproximadament 75.000 paraules en angles, pero potser 60.000 en catala.
-
Comprensio: els models tendeixen a raonar millor en les llengues mes representades al seu entrenament (generalment angles). El catala, tot i ser proper a l'espanyol, esta menys representat.
-
Codificació de numeros: els numeros llargs es tokenitzen digit per digit ("12345" = 5 tokens), cosa que fa que els LLMs siguin dolents en aritmètica per defecte.
3. Prompt Engineering Professional
3.1 Fonaments
El prompt engineering es l'art de comunicar-se eficaçment amb un LLM per obtenir els resultats desitjats. No es una "tactica" temporal fins que els models millorin: a mesura que els models son mes potents, el prompt engineering mes sofisticat permet extreure'n mes valor.
Zero-shot: demanar directament sense exemples
prompt_zero_shot = """Classifica el sentiment del seguent tweet en una de les categories:
POSITIU, NEGATIU, NEUTRAL.
Tweet: "Acabo de comprar el nou iPhone i es increible!"
Categoria:"""
Few-shot: proporcionar exemples per guiar el model
prompt_few_shot = """Classifica el sentiment dels tweets. Exemples:
Tweet: "Avui ha sortit el sol i m'he sentit molt bé"
Sentiment: POSITIU
Tweet: "He perdut el tren i ara arribaré tard a la feina"
Sentiment: NEGATIU
Tweet: "El sopar era acceptable, ni bo ni dolent"
Sentiment: NEUTRAL
Ara classifica:
Tweet: "LangChain 0.3 te una API molt mes clara que les versions anteriors"
Sentiment:"""
3.2 Chain-of-Thought (CoT)
CoT demana al model que "pensi en veu alta" abans de donar la resposta. Millora dramaticament el rendiment en tasques de raonament:
from openai import OpenAI
client = OpenAI()
# Sense CoT (pot fallar en problemes complexos)
resposta_directa = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": "Si un tren va a 120 km/h i un altre va a 80 km/h en la mateixa direccio, quin es el temps que tardara el primer a allunyar-se 50 km del segon?"
}]
)
# Amb CoT
resposta_cot = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": """Resol el seguent problema pas a pas, mostrant tot el raonament:
Si un tren va a 120 km/h i un altre va a 80 km/h en la mateixa direccio,
quin es el temps que tardara el primer a allunyar-se 50 km del segon?
Pensa el problema pas a pas:
1. Quina es la velocitat relativa entre els dos trens?
2. Quina distancia cal recorrer a velocitat relativa?
3. Quin es el temps resultant?"""
}]
)
3.3 System Prompts i Role Prompting
El system prompt estableix el context i el comportament general del model. Es una de les eines mes poderoses del prompt engineering:
system_prompt_expert = """Ets un enginyer senior d'IA a una empresa tecnologica catalana.
Tens 10 anys d'experiencia implementant sistemes de Machine Learning en produccio.
REGLES:
- Respon sempre en catala tecnic, professional pero accessible
- Proporciona exemples de codi funcional quan sigui rellevant
- Indica les limitacions i els riscos de cada solucio
- Si no estas segur d'alguna cosa, digues-ho clarament
- Prefereix solucions simples i mantenibles sobre les complexes
FORMAT DE RESPOSTA:
- Usa Markdown per a codi i llistes
- Inclou sempre un apartat de "Consideracions de produccio"
"""
resposta = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt_expert},
{"role": "user", "content": "Com implementaries un sistema de cache per a cridades a l'API d'OpenAI?"}
]
)
3.4 ReAct: Reasoning + Acting
ReAct es un patró que combina raonament i accions. El model alterna entre "pensar" (Thought) i "actuar" (Action/Observation):
Thought: Necessito saber el preu actual de NVDA per respondre.
Action: cercar_preu("NVDA")
Observation: NVDA tanca a $875.40 (data: 2025-03-14)
Thought: Ara puc calcular el valor de la cartera.
Action: calcular("100 * 875.40")
Observation: 87540.0
Thought: Tinc tota la informacio. Puc respondre.
Answer: El valor actual de 100 accions de NVDA es de $87.540.
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain import hub
from langchain.agents import create_react_agent, AgentExecutor
@tool
def obtenir_preu_accio(ticker: str) -> str:
"""Obte el preu actual d'una accio de borsa."""
# En produccio, cridar a una API financera real
preus = {"NVDA": 875.40, "AAPL": 224.50, "MSFT": 415.80}
if ticker in preus:
return f"{ticker}: ${preus[ticker]}"
return f"No s'ha trobat informacio per a {ticker}"
@tool
def calcular_operacio(expressio: str) -> str:
"""Calcula una operacio matematica basica."""
try:
return str(round(eval(expressio), 4))
except:
return "Error en el calcul"
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
tools = [obtenir_preu_accio, calcular_operacio]
# Prompt ReAct
prompt_react = hub.pull("hwchase17/react")
agent = create_react_agent(llm, tools, prompt_react)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True, max_iterations=5)
resultat = executor.invoke({
"input": "Tinc 50 accions d'NVIDIA i 100 d'Apple. Quin es el valor total de la meva cartera?"
})
print(resultat["output"])
3.5 Structured Outputs
Els models moderns poden retornar JSON garantit, imprescindible per a aplicacions:
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import Optional, Literal
client = OpenAI()
class ExtraccioCVInfo(BaseModel):
"""Extraccio estructurada d'un CV."""
nom_complet: str
anys_experiencia: int
habilitats_ia: list[str]
nivell_python: Literal["basic", "intermedi", "avançat", "expert"]
ha_treballat_amb_llms: bool
ultim_rol: str
educacio_maxima: str
puntuacio_candidat: Optional[float] = Field(None, ge=0, le=10)
cv_text = """
Joan Garcia Puig
Enginyer de dades amb 5 anys d'experiencia.
Habilitats: Python (expert), TensorFlow, PyTorch, LangChain, SQL, Spark.
He implementat sistemes RAG amb OpenAI i LangChain.
Ultim rol: Senior Data Engineer a Telefonica Catalunya.
Grau en Enginyeria Informatica (UdG).
"""
resposta = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "Extreu informacio estructurada del CV proporcionat."
},
{
"role": "user",
"content": cv_text
}
],
response_format=ExtraccioCVInfo
)
info = resposta.choices[0].message.parsed
print(f"Candidat: {info.nom_complet}")
print(f"Experiencia: {info.anys_experiencia} anys")
print(f"Treballa amb LLMs: {info.ha_treballat_amb_llms}")
print(f"Habilitats IA: {', '.join(info.habilitats_ia)}")
4. RAG — Retrieval-Augmented Generation
4.1 Per que RAG?
Els LLMs tenen un problema fonamental: el seu coneixement esta "frozen" en el moment de l'entrenament. GPT-4o no sap res del que ha passat despres de la seva data de tall, ni coneix els documents interns de la teva empresa.
RAG soluciona aixo connectant el LLM amb una base de coneixement actualitzable:
graph TD
USER["Usuari:\n'Quina es la politica de\nvacances de l'empresa?'"] --> EMBED["Embeds la pregunta\namb un model d'embeddings"]
EMBED --> SEARCH["Cerca semantica\nal vector store"]
DOCS["Documents de\nl'empresa (PDFs, wikis)"] --> CHUNK["Dividir en chunks\n(500-1000 tokens)"]
CHUNK --> EMBED2["Crear embeddings\nde cada chunk"]
EMBED2 --> VECTOR["Vector Store\n(Chroma, FAISS, Pinecone)"]
SEARCH --> VECTOR
VECTOR --> RELEVANT["Top-K chunks\nmes rellevants"]
RELEVANT --> PROMPT["Construir prompt:\nContext + Pregunta"]
PROMPT --> LLM["LLM\n(GPT-4o-mini)"]
LLM --> RESPOSTA["Resposta\nfundamentada en documents"]
4.2 Chunking: la decisio critica
Com es divideix el document en fragments (chunks) te un impacte enorme en la qualitat del RAG:
from langchain_text_splitters import (
RecursiveCharacterTextSplitter,
MarkdownHeaderTextSplitter,
TokenTextSplitter
)
from langchain_community.document_loaders import PyPDFLoader, WebBaseLoader
# 1. Chunking recursiu per caracter (el mes usat, bon equilibri)
splitter_recursiu = RecursiveCharacterTextSplitter(
chunk_size=1000, # ~250 paraules
chunk_overlap=200, # 20% de superposicio
length_function=len,
separators=[
"\n\n", # Primer: doble salt de linia (paragrafs)
"\n", # Despres: salt de linia simple
".", # Despres: fi de frase
"!",
"?",
",", # Despres: comes
" ", # Despres: espais
"" # Ultim recurs: caracter a caracter
]
)
# 2. Chunking basat en tokens (per fer servir amb APIs que cobren per token)
splitter_tokens = TokenTextSplitter(
encoding_name="cl100k_base", # Tokenitzador de GPT-4
chunk_size=512, # 512 tokens per chunk
chunk_overlap=50
)
# 3. Chunking semantic per estructura Markdown
headers_to_split_on = [
("#", "titol_h1"),
("##", "titol_h2"),
("###", "titol_h3"),
]
splitter_markdown = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
strip_headers=False
)
# Exemple: carregar un document PDF
loader = PyPDFLoader("manual_empresa.pdf")
documents = loader.load()
chunks = splitter_recursiu.split_documents(documents)
print(f"Document dividit en {len(chunks)} chunks")
print(f"Primer chunk ({len(chunks[0].page_content)} chars): {chunks[0].page_content[:200]}...")
4.3 Vector Stores: comparativa
| Vector Store | Us Recomanat | Pros | Contres |
|---|---|---|---|
| Chroma | Desenvolupament i produccio lleugera | Facil d'usar, Python-native, persistencia local | Limitat a milions de vectors |
| FAISS | Cerca local molt rapida | Velocitat excepcional, de Meta | No distribuit, sense filtratge |
| Pinecone | Produccio a escala (SaaS) | Gestionat, escalable, filtratge metadata | Cost, dependencia de tercers |
| Weaviate | Produccio open-source | Multimodal, filtres potents, GraphQL | Mes complex de configurar |
| Qdrant | Produccio self-hosted | Rapid, filtres avançats, Rust | Menys ecosistema Python |
| pgvector | Si ja uses PostgreSQL | Sense nova infraestructura | Menys eficient per a molt grans |
import chromadb
from chromadb.utils import embedding_functions
import numpy as np
# Chroma per a desenvolupament
client = chromadb.PersistentClient(path="./chroma_db")
# Funcio d'embedding (usa OpenAI o un model local)
openai_ef = embedding_functions.OpenAIEmbeddingFunction(
api_key=os.environ["OPENAI_API_KEY"],
model_name="text-embedding-3-small"
)
# Crear o carregar colleccio
colleccio = client.get_or_create_collection(
name="docs-empresa",
embedding_function=openai_ef,
metadata={"hnsw:space": "cosine"} # Metric de distancia
)
# Afegir documents
documents = [
"La politica de vacances estableix 23 dies laborables anuals.",
"El teletreball es permet fins a 3 dies per setmana.",
"Els beneficis socials inclouen assegurança medica i tiquet restaurant."
]
colleccio.upsert(
documents=documents,
ids=[f"doc_{i}" for i in range(len(documents))],
metadatas=[{"seccio": "RRHH", "data": "2025-01"} for _ in documents]
)
# Cercar
resultats = colleccio.query(
query_texts=["Quants dies de vacances tinc?"],
n_results=2,
where={"seccio": "RRHH"} # Filtre per metadata
)
for doc, distancia in zip(resultats["documents"][0], resultats["distances"][0]):
print(f"Similitud: {1 - distancia:.3f} | {doc[:80]}...")
4.4 Tecnicas avançades de RAG
HyDE (Hypothetical Document Embeddings)
En lloc d'embeds directament la pregunta (que pot ser molt curta), demana al LLM que generi una resposta hipotetica i la embeds:
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
def hyde_retriever(pregunta: str, vectorstore) -> list:
"""Retriever amb HyDE: genera document hipotetic per millorar la cerca."""
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
prompt_hyde = ChatPromptTemplate.from_messages([
("system", "Genera un paragraf informatiu que respondria la seguent pregunta, com si fos un extracte d'un document expert."),
("human", "{pregunta}")
])
cadena_hyde = prompt_hyde | llm | StrOutputParser()
document_hipotetic = cadena_hyde.invoke({"pregunta": pregunta})
# Cerca usant el document hipotetic en lloc de la pregunta original
return vectorstore.similarity_search(document_hipotetic, k=5)
Reranking: despres d'obtenir el top-K de la cerca semantica, usar un model de reranking (cross-encoder) per reordenar els resultats:
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def rerankar_resultats(pregunta: str, documents: list, top_n: int = 3) -> list:
"""Reordena documents usant un cross-encoder mes precis."""
parelles = [(pregunta, doc.page_content) for doc in documents]
puntuacions = reranker.predict(parelles)
documents_puntuats = sorted(
zip(puntuacions, documents),
key=lambda x: x[0],
reverse=True
)
return [doc for _, doc in documents_puntuats[:top_n]]
4.5 Sistema RAG complet amb LangChain i Ollama
"""
Sistema RAG local complet: LangChain + Ollama + Chroma.
Sense cost d'API. Executa tot localment.
Requirements:
langchain==0.3.0
langchain-community==0.3.0
langchain-ollama==0.2.0
chromadb==0.5.0
Docker:
# 1. Primer: iniciar Ollama
docker run -d --name ollama -p 11434:11434 ollama/ollama
docker exec ollama ollama pull llama3.1
docker exec ollama ollama pull nomic-embed-text
# 2. Executar el RAG
docker run --rm --network host \
-v $(pwd):/app -w /app \
python:3.11-slim bash -c "pip install -r requirements.txt && python rag_local.py"
"""
import os
from pathlib import Path
from langchain_ollama import ChatOllama, OllamaEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_community.document_loaders import PyPDFLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
# Configuracio
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
MODEL_LLM = "llama3.1"
MODEL_EMBED = "nomic-embed-text"
DIRECTORI_DOCS = "./documents"
DIRECTORI_CHROMA = "./chroma_local"
def carregar_documents(directori: str) -> list:
"""Carrega tots els documents d'un directori."""
documents = []
for path in Path(directori).glob("**/*"):
if path.suffix == ".pdf":
loader = PyPDFLoader(str(path))
elif path.suffix in [".txt", ".md"]:
loader = TextLoader(str(path), encoding="utf-8")
else:
continue
documents.extend(loader.load())
print(f" Carregat: {path.name} ({len(documents)} documents total)")
return documents
def construir_vectorstore(documents: list) -> Chroma:
"""Divideix els documents i crea el vector store."""
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
chunks = splitter.split_documents(documents)
print(f"Documents dividits en {len(chunks)} chunks")
embeddings = OllamaEmbeddings(model=MODEL_EMBED, base_url=OLLAMA_URL)
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory=DIRECTORI_CHROMA,
collection_name="rag-local"
)
print(f"Vector store creat amb {len(chunks)} vectors")
return vectorstore
def crear_cadena_rag(vectorstore: Chroma) -> any:
"""Crea la cadena RAG completa."""
llm = ChatOllama(model=MODEL_LLM, base_url=OLLAMA_URL, temperature=0)
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})
prompt = ChatPromptTemplate.from_messages([
("system", """Ets un assistent expert que respon preguntes usant EXCLUSIVAMENT
la informacio del context. Si no trobes la resposta al context, ho indiques clarament.
Respon sempre en catala.
CONTEXT:
{context}"""),
("human", "{pregunta}")
])
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
cadena = (
{"context": retriever | format_docs, "pregunta": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
return cadena
def main():
print("=== Sistema RAG Local (LangChain + Ollama) ===\n")
# Carregar o crear vector store
embeddings = OllamaEmbeddings(model=MODEL_EMBED, base_url=OLLAMA_URL)
if Path(DIRECTORI_CHROMA).exists():
print("Carregant vector store existent...")
vectorstore = Chroma(
persist_directory=DIRECTORI_CHROMA,
embedding_function=embeddings
)
else:
print("Creant nou vector store...")
documents = carregar_documents(DIRECTORI_DOCS)
vectorstore = construir_vectorstore(documents)
cadena_rag = crear_cadena_rag(vectorstore)
# Modo interactiu
print("\nAssistent RAG llest! (escriu 'sortir' per acabar)\n")
while True:
pregunta = input("Pregunta: ").strip()
if pregunta.lower() in ["sortir", "exit", "quit"]:
break
if not pregunta:
continue
print("Buscant i raonant...")
resposta = cadena_rag.invoke(pregunta)
print(f"\nResposta: {resposta}\n")
print("-" * 50)
if __name__ == "__main__":
main()
Miniactivitat
Crea un sistema RAG local usant Ollama i Chroma amb un document de la teva eleccio (PDF d'un manual, pàgina web, etc.). Fes almenys 5 preguntes i avalua si les respostes son correctes i estan fonamentades en el document. Anota els casos on el sistema falla (allucinacio, resposta incorrecta o incomplet).
5. Fine-tuning
5.1 Quan fine-tunar?
Fine-tuning no sempre es la millor solucio. Cal triar l'estrategia correcta:
graph TD
PROBLEMA["Necessitat d'adaptacio"] --> Q1{"El problema es\nde coneixement?"}
Q1 -->|"Si (nova info)"| RAG["RAG es millor\n(mes rapid i economi c)"]
Q1 -->|"No (estil/format)"| Q2{"Quantes dades\nd'exemple tens?"}
Q2 -->|"Poques (10-50)"| FEW["Few-shot prompting\nes suficient"]
Q2 -->|"Moltes (1000+)"| Q3{"Quin es el\npressupost?"}
Q3 -->|"Limitat"| LORA["LoRA / QLoRA\n(fine-tuning eficient)"]
Q3 -->|"Alt"| FULL["Full fine-tuning\n(millors resultats)"]
style RAG fill:#2a7a4a
style FEW fill:#2a7a4a
style LORA fill:#4a6a8a
style FULL fill:#7a4a4a
Quan te sentit el fine-tuning: - Tasques molt especifiques amb format de sortida particular (ex: extraccio d'entitats d'un domini especific) - Necessitat de latencia molt baixa (models petits fine-tuned sovint superen models grans en tasques especifiques) - Privacitat: no pots enviar dades a APIs externes - Cost a gran escala: un model petit fine-tuned pot ser 100x mes barat que GPT-4o per a un cas d'us especific
5.2 LoRA i QLoRA
LoRA (Low-Rank Adaptation) afegeix matrius de pes addicionals de rang baix als pesos del model. En lloc de modificar tots els 7 bilions de parametres, nomes entrena una fraccio petita:
from peft import LoraConfig, get_peft_model, TaskType
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments
from trl import SFTTrainer
import torch
# Carregar model base (en 4 bits per estalviar memoria)
from transformers import BitsAndBytesConfig
config_quant = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
model = AutoModelForCausalLM.from_pretrained(
"mistralai/Mistral-7B-Instruct-v0.3",
quantization_config=config_quant,
device_map="auto"
)
tokenizer = AutoTokenizer.from_pretrained("mistralai/Mistral-7B-Instruct-v0.3")
# Configurar LoRA
lora_config = LoraConfig(
r=16, # Rang de les matrius LoRA (mes baix = menys params)
lora_alpha=32, # Factor d'escala
target_modules=["q_proj", "v_proj", "k_proj", "o_proj"], # Capes a adaptar
lora_dropout=0.05,
task_type=TaskType.CAUSAL_LM
)
model_lora = get_peft_model(model, lora_config)
model_lora.print_trainable_parameters()
# Output: trainable params: 6,815,744 || all params: 3,758,063,616 || trainable: 0.18%
# Nomes entrena el 0.18% dels parametres!
# Configurar entrenament
training_args = TrainingArguments(
output_dir="./model-fine-tuned",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-4,
fp16=False,
bf16=True,
save_strategy="epoch",
logging_steps=10,
report_to="mlflow"
)
# SFT (Supervised Fine-Tuning)
trainer = SFTTrainer(
model=model_lora,
args=training_args,
train_dataset=dataset_entrenament,
tokenizer=tokenizer,
max_seq_length=2048,
)
trainer.train()
model_lora.save_pretrained("./model-fine-tuned-lora")
6. Agents d'IA
6.1 Arquitectura dels agents
Un agent d'IA es un sistema que usa un LLM com a "cervell" per planificar i executar tasques complexes que requereixen multiples passos:
graph TD
GOAL["Objectiu de l'usuari"] --> LLM_BRAIN["LLM com a planificador"]
LLM_BRAIN --> TOOLS["Eines disponibles"]
TOOLS --> T1["Cerca web"]
TOOLS --> T2["Calculadora"]
TOOLS --> T3["Executar codi Python"]
TOOLS --> T4["Llegir fitxers"]
TOOLS --> T5["API externa"]
LLM_BRAIN --> MEMORY["Memoria"]
MEMORY --> M1["Buffer de conversa"]
MEMORY --> M2["Vector store (long-term)"]
LLM_BRAIN --> PLANNING["Planificacio"]
PLANNING --> ACT["Accio"]
ACT --> OBS["Observacio del resultat"]
OBS --> LLM_BRAIN
LLM_BRAIN --> ANSWER["Resposta final a l'usuari"]
6.2 Agent complet amb LangGraph
from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.tools import tool
from typing import TypedDict, Annotated
import operator
import subprocess
import json
# Eines per a l'agent
@tool
def executar_python(codi: str) -> str:
"""Executa codi Python i retorna el resultat.
ATENCIO: en produccio, usar un sandbox segur (ex: e2b.dev)."""
try:
resultat = subprocess.run(
["python3", "-c", codi],
capture_output=True, text=True, timeout=10
)
if resultat.returncode == 0:
return f"STDOUT: {resultat.stdout}"
else:
return f"ERROR: {resultat.stderr}"
except subprocess.TimeoutExpired:
return "ERROR: Temps d'execucio superat (10s)"
@tool
def cercar_web(consulta: str) -> str:
"""Cerca informacio a internet. (Simulat per a la demo)"""
# En produccio: usar Tavily API, Serper API o similar
return f"Resultats simulats per a '{consulta}': [Informacio actualitzada del 2025...]"
@tool
def llegir_fitxer(ruta: str) -> str:
"""Llegeix el contingut d'un fitxer."""
try:
return open(ruta, encoding="utf-8").read()
except FileNotFoundError:
return f"ERROR: Fitxer '{ruta}' no trobat."
# Estat de l'agent
class EstatAgent(TypedDict):
missatges: Annotated[list, operator.add]
# Configurar LLM
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
tools = [executar_python, cercar_web, llegir_fitxer]
llm_amb_tools = llm.bind_tools(tools)
# Nodes
def node_agent(estat: EstatAgent) -> dict:
resposta = llm_amb_tools.invoke(estat["missatges"])
return {"missatges": [resposta]}
# Construir el graf
builder = StateGraph(EstatAgent)
builder.add_node("agent", node_agent)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")
# Compilar amb memoria persistent
memoria = MemorySaver()
agent = builder.compile(checkpointer=memoria)
# Configuracio de la sessio
config = {"configurable": {"thread_id": "sessio-joan-garcia-001"}}
# Executar l'agent
def xatejar_amb_agent(missatge: str) -> str:
resultat = agent.invoke(
{"missatges": [HumanMessage(content=missatge)]},
config=config
)
return resultat["missatges"][-1].content
# Us
print(xatejar_amb_agent(
"Escriu un script Python que calculi els primers 10 nombres de Fibonacci i executa'l."
))
print(xatejar_amb_agent(
"Ara guarda el resultat en un fitxer fibonacci.txt."
))
# L'agent recorda el context de la conversa gracies a la memoria
7. Models Multimodals
7.1 GPT-4o: text, imatge i audio integrats
GPT-4o ("o" d'omni) es el primer model d'OpenAI nativament multimodal: no es un model de text al que s'afegeix un classificador d'imatges, sino que processa text, imatge i audio en un model unificat:
from openai import OpenAI
import base64
from pathlib import Path
client = OpenAI()
# Analisi d'imatge
def analitzar_imatge(ruta_imatge: str, pregunta: str) -> str:
with open(ruta_imatge, "rb") as f:
dades_imatge = base64.b64encode(f.read()).decode("utf-8")
ext = Path(ruta_imatge).suffix[1:].lower()
mime = {"jpg": "jpeg", "jpeg": "jpeg", "png": "png", "gif": "gif"}.get(ext, "jpeg")
resposta = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": pregunta},
{
"type": "image_url",
"image_url": {
"url": f"data:image/{mime};base64,{dades_imatge}",
"detail": "high" # "low" per imatges simples (menys tokens)
}
}
]
}
],
max_tokens=500
)
return resposta.choices[0].message.content
# Us
descripcio = analitzar_imatge("arquitectura_sistema.png",
"Descriu aquesta arquitectura de sistema. Identifica els components i les seves relacions.")
print(descripcio)
# Transcripcio d'audio amb Whisper
def transcriure_audio(ruta_audio: str, idioma: str = "ca") -> str:
with open(ruta_audio, "rb") as f:
transcripcio = client.audio.transcriptions.create(
model="whisper-1",
file=f,
language=idioma,
response_format="verbose_json"
)
return transcripcio.text
8. Limitacions actuals dels LLMs
Ser un professional d'IA implica conèixer les limitacions tant com les capacitats:
8.1 Al·lucinació
Els LLMs generen text estadísticament probable, no necessàriament veraç. Poden inventar fets, cites, URLs o references bibliografiques amb total confiança:
Causes: el model optimitza per a coherencia lingüistica, no per a veracitat factual. Quan no sap la resposta, "inventa" una que sembli plausible.
Mitigacions: RAG (fundamentar en documents reals), temperature baixa (0), chains-of-thought, verificacio de fonts, structured outputs.
8.2 Finestra de context
Tot i que els models moderns accepten finestres de 128K-2M tokens, hi ha un fenomen de "lost in the middle": els models perden atencio als documents que estan al mig del context. Documents al principi i al final del context reben mes atencio.
8.3 Raonament quantitatiu
Els LLMs son dolents en aritmetica i probabilitat directa. La solucio es usar tool use per deleguar els calculs a un intèrpret Python real.
8.4 Seguretat i jailbreaking
Els models poden ser manipulats via prompt injection: demanar-los que ignoren les seves instruccions originals mitjançant instruccions ocultes en contingut extern (ex: un document malicious que el RAG indexa).
Mitigacio: sanititzar les entrades, usar guardrails (LLM Guard, Nemo Guardrails), separar clarament les instruccions del contingut.
Exercici pràctic
AC5073/04 — Agent RAG amb eines
Construeix un agent amb LangGraph que:
- Llegeixi i indexi com a minin 3 documents en catala (PDFs, texts o pagines web)
- Tingui una eina de cerca al vector store
- Tingui una eina de calculadora per a preguntes numeriques
- Mantingui memoria de la conversa entre preguntes
- S'executi completament amb Docker (contenidor per a l'app + contenidor Ollama)
Demostra que l'agent respon correctament a preguntes que requereixen: - Recuperar informacio d'un document - Fer un calcul basat en la informacio recuperada - Respondre a una pregunta de follow-up usant la memoria
Preguntes de reflexio
- Quina diferencia hi ha entre RAG i fine-tuning per a adaptar un LLM al coneixement especific d'una empresa? Quan triaries cada opcio?
- Explica el fenomen "lost in the middle" i com afecta el disseny de sistemes RAG.
- Per que els LLMs amb Chain-of-Thought son millors en raonament que sense? Quin es el mecanisme subjacent?
- Un client et demana que construeixis un sistema RAG per a una empresa juridica. Quines consideracions de seguretat i privacitat has de tenir en compte?