---
name: langchain-langraph
description: >
  LangChain para chains y document loaders; LangGraph para agentes con estado
  y flujos condicionales. Cargar cuando se construya un pipeline RAG, agente
  con herramientas, cadena de prompts multi-paso, o grafo de estados para
  workflows LLM complejos.
version: "1.0.0"
herramientasPermitidas: [Read, Glob]
evolvable: true  # default para skill estandar
exclusiones:
  - "No cargar para llamadas directas a la API de LLM sin pipeline (una sola llamada, sin RAG, sin estado) — para integraciones directas con Anthropic/OpenAI cargar `prompt-engineering` o usar el SDK directamente."
  - "No cargar para RAG con arquitectura propia sin LangChain (vector stores directos con pgvector, embeddings manuales) — para RAG sin LangChain cargar `rag-arquitectura`."
  - "No cargar para pipelines de datos ETL con LLM como paso de transformación masiva — para ETL con LLM cargar `datos-etl`."
  - "No cargar para extracción de datos estructurados de documentos con LLM (function calling, structured outputs) sin estado ni grafo — para extracción estructurada cargar `structured-outputs`."
---
# LangChain y LangGraph

## Cuándo NO cargar

- La tarea es una llamada directa a la API del LLM sin cadena de pasos (una sola invocación): cargar `prompt-engineering` o usar el SDK directamente.
- La implementación de RAG no usa LangChain (pgvector directo, embeddings manuales): cargar `rag-arquitectura`.
- El LLM es un paso dentro de un pipeline ETL masivo (transformación de datos a escala): cargar `datos-etl`.
- La tarea es extracción de datos estructurados con function calling sin estado ni grafo: cargar `structured-outputs`.

## Cuándo usar cada herramienta

```
API directa Claude/OpenAI  → Llamada única, sin estado, transformación simple.
                              Menos dependencias. Máxima simplicidad y control.

LangChain                  → Pipeline de pasos: load → split → embed → retrieve → generate.
                              Los pasos son lineales y predecibles. Document loaders integrados.

LangGraph                  → Flujo con estado, condicionales, loops, agentes autónomos.
                              El siguiente paso depende del resultado del anterior.
```

---

## LangChain: pipeline RAG mínimo funcional

```python
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import PGVector
from langchain.chains import RetrievalQA

# 1. Cargar documentos
loader = PyPDFLoader("manual.pdf")
documentos = loader.load()

# 2. Dividir en chunks
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
chunks = splitter.split_documents(documentos)

# 3. Embeddings y vector store
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = PGVector.from_documents(
    documents=chunks,
    embedding=embeddings,
    connection_string=DATABASE_URL,
    collection_name="manual_chunks",
)

# 4. Chain de QA
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
chain = RetrievalQA.from_chain_type(
    llm=llm,
    retriever=vectorstore.as_retriever(search_kwargs={"k": 4}),
)
respuesta = chain.invoke({"query": "¿Cómo reiniciar el sistema?"})
```

---

## LangChain: carga incremental de documentos

```python
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_community.document_loaders.csv_loader import CSVLoader

# Cargar todos los .txt de un directorio
loader = DirectoryLoader("docs/", glob="**/*.txt", loader_cls=TextLoader)
documentos = loader.load()

# Cargar CSV con metadata
loader_csv = CSVLoader(
    file_path="productos.csv",
    source_column="nombre",  # columna usada como metadata["source"]
    csv_args={"delimiter": ","},
)
documentos_csv = loader_csv.load()
```

---

## LangChain: RAG con Claude (Anthropic)

```python
from langchain_anthropic import ChatAnthropic
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

llm = ChatAnthropic(model="claude-3-5-haiku-20241022", temperature=0)

plantilla = ChatPromptTemplate.from_messages([
    ("system", "Responde únicamente usando el contexto proporcionado. "
               "Si la respuesta no está en el contexto, di 'No lo sé'.\n\n"
               "Contexto:\n{contexto}"),
    ("human", "{pregunta}"),
])

def formatear_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

cadena_rag = (
    {"contexto": retriever | formatear_docs, "pregunta": RunnablePassthrough()}
    | plantilla
    | llm
    | StrOutputParser()
)

respuesta = cadena_rag.invoke("¿Cuáles son los requisitos del sistema?")
```

---

## LangChain: streaming de respuestas

```python
# Usar streaming para mejorar la UX percibida en respuestas largas
async for trozo in cadena_rag.astream("¿Qué es el contrato de soporte?"):
    print(trozo, end="", flush=True)

# En FastAPI con SSE
from fastapi.responses import StreamingResponse

async def generar_stream(pregunta: str):
    async for trozo in cadena_rag.astream(pregunta):
        yield f"data: {trozo}\n\n"

@router.get("/chat/stream")
async def chat_stream(pregunta: str):
    return StreamingResponse(generar_stream(pregunta), media_type="text/event-stream")
```

---

## LangGraph: agente con herramientas

```python
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
from typing import TypedDict, Annotated
import operator

class EstadoAgente(TypedDict):
    mensajes: Annotated[list, operator.add]
    contexto: str
    iteraciones: int

def nodo_razonamiento(estado: EstadoAgente) -> dict:
    respuesta = llm_con_herramientas.invoke(estado["mensajes"])
    return {"mensajes": [respuesta], "iteraciones": estado["iteraciones"] + 1}

def debe_continuar(estado: EstadoAgente) -> str:
    ultimo = estado["mensajes"][-1]
    if estado["iteraciones"] >= 10:
        return END  # Límite de seguridad — evita loops infinitos
    if hasattr(ultimo, "tool_calls") and ultimo.tool_calls:
        return "herramientas"
    return END

grafo = StateGraph(EstadoAgente)
grafo.add_node("razonar", nodo_razonamiento)
grafo.add_node("herramientas", ToolNode(herramientas))
grafo.set_entry_point("razonar")
grafo.add_conditional_edges("razonar", debe_continuar)
grafo.add_edge("herramientas", "razonar")

# Compilar una sola vez — singleton
agente = grafo.compile()
```

---

## LangGraph: estado con persistencia (human-in-the-loop)

```python
from langgraph.checkpoint.memory import MemorySaver
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver

# En memoria — útil para desarrollo
checkpointer_dev = MemorySaver()

# En producción con PostgreSQL
async with AsyncPostgresSaver.from_conn_string(DATABASE_URL) as checkpointer:
    agente = grafo.compile(
        checkpointer=checkpointer,
        interrupt_before=["herramientas"],  # pausa antes de ejecutar herramientas
    )

# Continuar una conversación existente
config = {"configurable": {"thread_id": "sesion-usuario-123"}}
resultado = await agente.ainvoke({"mensajes": [mensaje_usuario]}, config=config)
```

---

## LangGraph: nodo de RAG integrado en grafo

```python
from langchain_core.messages import HumanMessage, AIMessage

def nodo_recuperar(estado: EstadoAgente) -> dict:
    """Recupera contexto relevante del vector store."""
    ultima_pregunta = estado["mensajes"][-1].content
    docs = retriever.invoke(ultima_pregunta)
    contexto = "\n\n".join(d.page_content for d in docs)
    return {"contexto": contexto}

def nodo_generar(estado: EstadoAgente) -> dict:
    """Genera respuesta usando el contexto recuperado."""
    prompt = f"Contexto:\n{estado['contexto']}\n\nPregunta: {estado['mensajes'][-1].content}"
    respuesta = llm.invoke(prompt)
    return {"mensajes": [AIMessage(content=respuesta.content)]}

grafo_rag = StateGraph(EstadoAgente)
grafo_rag.add_node("recuperar", nodo_recuperar)
grafo_rag.add_node("generar", nodo_generar)
grafo_rag.set_entry_point("recuperar")
grafo_rag.add_edge("recuperar", "generar")
grafo_rag.add_edge("generar", END)
pipeline_rag = grafo_rag.compile()
```

---

## Estrategias de chunking y por qué importan

| Tipo | Splitter | chunk_size | overlap | Cuándo usar |
|------|----------|------------|---------|-------------|
| Texto general | RecursiveCharacterTextSplitter | 1000 | 200 | Default seguro |
| Docs técnicos con headers | MarkdownHeaderTextSplitter | — | — | Preserva jerarquía |
| Código fuente | RecursiveCharacterTextSplitter (lenguaje) | 500 | 50 | AST-aware |
| Contratos/legal | RecursiveCharacterTextSplitter | 1500 | 300 | Párrafos largos |

```python
import tiktoken

def contar_tokens(texto: str, modelo: str = "gpt-4o-mini") -> int:
    """Contar tokens reales, no caracteres."""
    enc = tiktoken.encoding_for_model(modelo)
    return len(enc.encode(texto))

# Verificar que los chunks no superan la ventana de contexto del modelo
for chunk in chunks:
    tokens = contar_tokens(chunk.page_content)
    assert tokens <= 8000, f"Chunk demasiado grande: {tokens} tokens"
```

---

## Herramientas personalizadas para agentes

```python
from langchain_core.tools import tool
from pydantic import BaseModel, Field

class BuscarProductoInput(BaseModel):
    nombre: str = Field(description="Nombre o descripción del producto a buscar")
    limite: int = Field(default=5, description="Número máximo de resultados")

@tool(args_schema=BuscarProductoInput)
def buscar_producto(nombre: str, limite: int = 5) -> list[dict]:
    """Busca productos en el catálogo de la empresa por nombre o descripción."""
    # lógica real de búsqueda
    return [{"id": 1, "nombre": nombre, "precio": 100.0}]

# Asociar herramientas al LLM
herramientas = [buscar_producto]
llm_con_herramientas = llm.bind_tools(herramientas)
```

---

## Reranking — mejora de precisión post-retrieval

El semantic search devuelve los k vectores más cercanos, pero cercanía
vectorial no es lo mismo que relevancia semántica. El reranking aplica un
segundo modelo especializado para reordenar los resultados:

```
Semantic search (top-10) → Reranker → Reordenados por relevancia real (top-3)
```

### Implementación con threshold adaptativo

```python
import cohere

co = cohere.ClientV2()

def buscar_con_reranking(
    query: str,
    resultados_raw: list[str],
    threshold: float = 0.9,
    min_resultados: int = 3,
) -> list[dict]:
    """Reranking con threshold que baja si hay pocos resultados."""
    reranked = co.rerank(
        query=query,
        documents=resultados_raw,
        model="rerank-v3.5",
        top_n=10,
    )

    filtrados = [
        {"text": resultados_raw[r.index], "score": r.relevance_score}
        for r in reranked.results
        if r.relevance_score >= threshold
    ]

    # Threshold adaptativo: garantiza mínimo de resultados
    while len(filtrados) < min_resultados and threshold > 0.3:
        threshold -= 0.05
        filtrados = [
            {"text": resultados_raw[r.index], "score": r.relevance_score}
            for r in reranked.results
            if r.relevance_score >= threshold
        ]

    return filtrados
```

### Cuándo usar reranking

| Situación | Reranking necesario |
|-----------|-------------------|
| Prototipo / demo | No — semantic search directo es suficiente |
| Corpus > 10K documentos | Sí — aumenta precisión significativamente |
| Queries ambiguos | Sí — reranker entiende semántica mejor que distancia vectorial |
| Alta precisión requerida | Sí — reduce falsos positivos en top-k |

---

## MUST DO / MUST NOT DO

### Obligatorio
- Definir límite de iteraciones en CADA agente LangGraph (`iteraciones >= 10 → END`)
- Usar streaming (`astream`) para respuestas largas — mejora UX percibida
- Sanitizar inputs del usuario antes de incluirlos en prompts
- Evaluar con RAGAS antes de desplegar RAG en producción
- Compilar el grafo una sola vez como singleton del módulo
- Contar tokens con `tiktoken` antes de procesar corpus grandes

### Prohibido
- NUNCA incluir datos sensibles (PII, tokens, contraseñas) en embeddings sin cifrar
- NUNCA usar temperatura alta (> 0.3) para tareas que requieren precisión factual
- NUNCA hacer embedding en loops uno a uno — usar `embed_documents(lista)` en lote
- NUNCA recompilar el grafo LangGraph por cada request HTTP
- NUNCA confiar en outputs LLM sin validar contra schema Pydantic cuando son estructurados
- NUNCA ejecutar código generado por un LLM sin sandbox

---

## Tabla de referencias

| Tema | Archivo |
|------|---------|
| Vectorstores: pgvector, Chroma, Pinecone, Weaviate | recursos/vectorstores.md |
| Evaluación RAG: RAGAS, hallucination detection | recursos/evaluacion-rag.md |
| Modelo de madurez RAG: Crawl → Walk → Run | recursos/rag-maturity-model.md |

## Gotchas / Errores comunes no obvios

**El grafo LangGraph recompilado en cada request HTTP no reutiliza el `checkpointer` y pierde la persistencia de estado entre llamadas**: si `grafo.compile(checkpointer=checkpointer)` se llama dentro de una función de endpoint (en lugar de como singleton del módulo), cada request crea un nuevo checkpointer en memoria y el estado de la conversación anterior se pierde. Causa: el checkpointer de `MemorySaver` almacena el estado en el objeto Python, no en disco. Fix: compilar el grafo una sola vez al iniciar la aplicación y guardarlo como variable de módulo. Para persistencia real entre reinicios, usar `AsyncPostgresSaver` con la connection string del proyecto.

**`RecursiveCharacterTextSplitter` con `chunk_overlap=200` produce chunks duplicados en documentos con secciones repetitivas (encabezados de página, footers de contrato)**: si el documento tiene un encabezado de 150 caracteres que se repite en cada página y el overlap es 200, ese encabezado aparece en el inicio de cada chunk de las páginas adyacentes, contaminando los embeddings con texto irrelevante. Causa: el splitter no tiene conciencia semántica de la estructura del documento. Fix: para documentos con headers/footers repetitivos, preprocesar el texto eliminando los patrones repetitivos con regex antes de pasar al splitter, o usar `MarkdownHeaderTextSplitter` que respeta la jerarquía semántica del documento.

**`LangChain` llama a `embed_documents([])` con una lista vacía cuando el loader no encuentra archivos, causando un error en algunos proveedores de embeddings (Cohere, local) que no aceptan listas vacías**: si el `DirectoryLoader` no encuentra archivos que coincidan con el glob, retorna `[]`, y la cadena RAG falla en el paso de embedding con un error críptico del proveedor en lugar de un mensaje claro. Causa: LangChain no valida que la lista de documentos sea no vacía antes de llamar al proveedor de embeddings. Fix: agregar `if not documentos: raise ValueError("No se encontraron documentos para indexar")` después de `loader.load()` para detectar el problema antes de llegar al proveedor.

**El threshold adaptativo del reranker puede devolver resultados irrelevantes cuando el corpus es pequeño y todos los documentos tienen puntuaciones bajas**: si el corpus tiene 50 documentos sobre un dominio específico y la query es sobre un tema diferente, todos los scores serán menores a 0.3. El threshold adaptativo baja indefinidamente hasta `0.3` y devuelve los `min_resultados` documentos menos malos, que son igualmente irrelevantes. Causa: el threshold adaptativo garantiza un mínimo de resultados pero no garantiza relevancia. Fix: agregar una condición de parada basada en el score absoluto mínimo aceptable: si todos los scores de `reranked.results` son menores a `0.2`, devolver lista vacía y responder al usuario "No encontré información relevante sobre este tema en el corpus disponible".
