Ir al contenido

Inferencia LLM en el Borde: Ejecutando Modelos de Lenguaje Grandes Localmente con llama.cpp y MLX

· 13 min read · default
llminferenceedge-computinglocal-aimachine-learningaidevops

La economía y el panorama de privacidad de la inferencia LLM han cambiado drásticamente. Ejecutar un modelo de lenguaje capaz en tu propio hardware ya no es una curiosidad de investigación. Es una opción práctica para desarrollo, cargas de trabajo de producción y aplicaciones sensibles a la privacidad. Modelos que rivalizan con GPT-3.5 en capacidad pueden ejecutarse en un portátil. Modelos que se acercan a la calidad de GPT-4 pueden ejecutarse en una sola estación de trabajo con GPUs de consumo.

Esta guía cubre toda la pila de inferencia LLM local: el formato de modelo GGUF, llama.cpp para ejecución multiplataforma en CPU y GPU, Apple MLX para rendimiento nativo en Apple Silicon, Ollama para gestión simplificada de modelos, vLLM y SGLang para servicio en producción, estrategias de cuantización que intercambian precisión por memoria, y arquitecturas de aplicación prácticas. Al final, tendrás el conocimiento para elegir la herramienta adecuada para tu hardware específico, carga de trabajo y requisitos de calidad.

El Caso para la Inferencia LLM Local

La inferencia mediante API en la nube es conveniente pero viene con costos reales que se acumulan con el tiempo.

Costos financieros. Una carga de trabajo moderada de 10 millones de tokens por día cuesta aproximadamente $30-75/día con APIs comerciales. La misma carga de trabajo en una máquina local con una RTX 4090 cuesta solo la electricidad después de la compra inicial del hardware. A escala, el período de amortización del hardware se mide en semanas.

Latencia. Las APIs en la nube añaden tiempo de ida y vuelta de red, retrasos por colas y límites de tasa. La inferencia local comienza a generar tokens inmediatamente. Para aplicaciones interactivas como completado de código o interfaces de chat, la diferencia entre 200ms de tiempo hasta el primer token (local) y 800ms (API en la nube) es notable.

Privacidad y cumplimiento. Las industrias reguladas (salud, finanzas, legal, gobierno) a menudo no pueden enviar datos a APIs de terceros. La inferencia local mantiene todos los datos en las instalaciones. Sin acuerdos de procesamiento de datos, sin preocupaciones de pista de auditoría, sin riesgo de contaminación de datos de entrenamiento.

Disponibilidad. Los modelos locales funcionan sin conexión, durante interrupciones de API y sin conectividad a internet. Para despliegues en el borde, operaciones de campo o entornos aislados, esto no es opcional.

Personalización. El despliegue local te da control total sobre el modelo, incluyendo ajuste fino, prompts de sistema personalizados sin restricciones del proveedor, y la capacidad de ejecutar modelos experimentales o de nicho no disponibles a través de APIs comerciales.

Entendiendo GGUF: El Formato Universal de Modelos

GGUF (GPT-Generated Unified Format) se ha convertido en el formato estándar para el despliegue local de LLM. Desarrollado por el proyecto llama.cpp, reemplazó al formato GGML anterior y proporciona un archivo autocontenido que incluye pesos del modelo, datos del tokenizador y metadatos.

Propiedades clave de los archivos GGUF:

Header:
  - Magic number and version
  - Model architecture (llama, mistral, phi, etc.)
  - Hyperparameters (layers, heads, embedding dimension)
  - Tokenizer type and vocabulary
  - Quantization method per tensor

Tensor data:
  - Weights stored in specified quantization format
  - Memory-mapped for efficient loading
  - Single file contains everything needed for inference

Los archivos GGUF están disponibles en Hugging Face con nomenclatura estandarizada:

# Naming convention: model-name-size-quantization.gguf
# Examples:
Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf     # 4-bit, medium quality
Meta-Llama-3.1-8B-Instruct-Q5_K_M.gguf     # 5-bit, good quality
Meta-Llama-3.1-8B-Instruct-Q8_0.gguf       # 8-bit, near-lossless
Meta-Llama-3.1-70B-Instruct-Q4_K_M.gguf    # Large model, 4-bit

La naturaleza autocontenida de GGUF significa que el despliegue es copiar un solo archivo. Sin entornos Python, sin instalación de dependencias, sin configuración de tokenizador. Esta simplicidad es una gran ventaja para el despliegue y distribución en producción.

llama.cpp en Profundidad

llama.cpp es el proyecto fundamental para la inferencia LLM local. Escrito en C/C++ con dependencias mínimas, se ejecuta en prácticamente cualquier hardware: CPUs x86, CPUs ARM, GPUs NVIDIA (CUDA), GPUs AMD (ROCm), Apple Silicon (Metal), e incluso Vulkan para soporte GPU más amplio.

Compilación desde Código Fuente

# Clone the repository
git clone https://github.com/ggerganov/llama.cpp.git
cd llama.cpp

# CPU-only build
cmake -B build
cmake --build build --config Release -j$(nproc)

# CUDA build (NVIDIA GPUs)
cmake -B build -DGGML_CUDA=ON
cmake --build build --config Release -j$(nproc)

# Metal build (Apple Silicon) - enabled by default on macOS
cmake -B build -DGGML_METAL=ON
cmake --build build --config Release -j$(nproc)

# ROCm build (AMD GPUs)
cmake -B build -DGGML_HIP=ON
cmake --build build --config Release -j$(nproc)

# Vulkan build (cross-platform GPU)
cmake -B build -DGGML_VULKAN=ON
cmake --build build --config Release -j$(nproc)

Niveles de Cuantización

La cuantización reduce la precisión del modelo para disminuir los requisitos de memoria y aumentar la velocidad de inferencia. llama.cpp soporta múltiples niveles de cuantización:

Cuantización Bits/Peso Memoria (7B) Memoria (70B) Impacto en Calidad
F16 16.0 14.0 GB 140 GB Base
Q8_0 8.5 7.5 GB 75 GB Despreciable
Q6_K 6.6 5.8 GB 58 GB Mínimo
Q5_K_M 5.7 5.0 GB 50 GB Muy pequeño
Q4_K_M 4.8 4.2 GB 42 GB Pequeño
Q4_0 4.5 3.9 GB 39 GB Moderado
Q3_K_M 3.9 3.4 GB 34 GB Notable
Q2_K 3.4 2.9 GB 29 GB Significativo
IQ2_XS 2.3 2.1 GB 21 GB Grande

Q4_K_M es el punto óptimo para la mayoría de usuarios. Proporciona buena calidad con uso de memoria razonable. Q5_K_M vale la memoria extra si la tienes. Por debajo de Q3_K_M, la calidad se degrada notablemente para tareas de razonamiento complejo.

Ejecutando Inferencia

# Basic interactive chat
./build/bin/llama-cli \
  -m models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf \
  --chat-template llama3 \
  -c 8192 \
  -ngl 99 \
  --temp 0.7

# Explanation of key flags:
# -m          Model file path
# -c 8192     Context length (tokens)
# -ngl 99     GPU layers (99 = offload everything to GPU)
# --temp 0.7  Sampling temperature

# Batch processing from a prompt file
./build/bin/llama-cli \
  -m models/model.gguf \
  -f prompt.txt \
  -n 512 \
  --no-display-prompt

# Quantize a model yourself
./build/bin/llama-quantize \
  models/original-f16.gguf \
  models/quantized-q4km.gguf \
  Q4_K_M

Descarga a GPU

La descarga a GPU mueve capas del transformador de la RAM de CPU a la VRAM de GPU para cómputo más rápido. La bandera -ngl controla cuántas capas descargar:

# Full GPU offloading (fastest, requires enough VRAM)
./build/bin/llama-cli -m model.gguf -ngl 99

# Partial offloading (split between GPU and CPU)
# Useful when model does not fit entirely in VRAM
./build/bin/llama-cli -m model.gguf -ngl 20

# Multi-GPU (split across GPUs)
./build/bin/llama-cli -m model.gguf -ngl 99 \
  --split-mode layer \
  --tensor-split 0.5,0.5

La diferencia de rendimiento entre inferencia en CPU y GPU es dramática. Un modelo 7B Q4_K_M en un CPU moderno genera aproximadamente 15-25 tokens/segundo. El mismo modelo completamente descargado a una RTX 4090 genera 100-150 tokens/segundo.

Servidor llama.cpp: API Compatible con OpenAI

llama.cpp incluye un servidor que expone una API compatible con OpenAI, haciéndolo un reemplazo directo de api.openai.com en aplicaciones existentes:

# Start the server
./build/bin/llama-server \
  -m models/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf \
  --host 0.0.0.0 \
  --port 8080 \
  -c 8192 \
  -ngl 99 \
  --parallel 4 \
  --cont-batching

# Key server flags:
# --parallel 4      Handle 4 concurrent requests
# --cont-batching   Enable continuous batching for throughput
# --metrics         Enable Prometheus metrics endpoint
# --api-key KEY     Require API key for requests

El uso del cliente es idéntico al SDK de OpenAI:

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:8080/v1",
    api_key="not-needed"  # Or your configured key
)

response = client.chat.completions.create(
    model="local-model",
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "Explain the PagedAttention algorithm."}
    ],
    temperature=0.7,
    max_tokens=1024,
    stream=True
)

for chunk in response:
    if chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="")

# Embeddings endpoint
embedding = client.embeddings.create(
    model="local-model",
    input="This is a test sentence for embedding."
)
print(f"Embedding dimension: {len(embedding.data[0].embedding)}")

El servidor también soporta inferencia por lotes para cargas de trabajo optimizadas por rendimiento:

# High-throughput batch processing
curl http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [{"role": "user", "content": "Summarize this text..."}],
    "temperature": 0.3,
    "max_tokens": 256
  }'

Framework Apple MLX

El framework MLX de Apple está diseñado específicamente para Apple Silicon, explotando la arquitectura de memoria unificada donde CPU y GPU comparten la misma memoria física. Esto elimina la sobrecarga de copia de memoria que afecta las configuraciones de GPU discretas.

Configuración y Uso Básico

# Install MLX and the LM toolkit
pip install mlx mlx-lm

# Download and run a model
mlx_lm.generate \
  --model mlx-community/Meta-Llama-3.1-8B-Instruct-4bit \
  --prompt "Explain how unified memory benefits LLM inference" \
  --max-tokens 512

# Start an OpenAI-compatible server
mlx_lm.server \
  --model mlx-community/Meta-Llama-3.1-8B-Instruct-4bit \
  --port 8080

API Python de MLX

import mlx.core as mx
from mlx_lm import load, generate

# Load model
model, tokenizer = load("mlx-community/Meta-Llama-3.1-8B-Instruct-4bit")

# Generate text
response = generate(
    model,
    tokenizer,
    prompt="Write a Python function to parse JSON logs",
    max_tokens=512,
    temp=0.7
)
print(response)

# Streaming generation
for token in generate(
    model, tokenizer,
    prompt="Explain containerization",
    max_tokens=256,
    stream=True
):
    print(token, end="", flush=True)

Cuantización de Modelos para MLX

# Convert a Hugging Face model to MLX format with quantization
mlx_lm.convert \
  --hf-path meta-llama/Meta-Llama-3.1-8B-Instruct \
  --mlx-path ./mlx-llama-3.1-8b-4bit \
  --quantize \
  --q-bits 4 \
  --q-group-size 64

# Fine-tune with LoRA
mlx_lm.lora \
  --model mlx-community/Meta-Llama-3.1-8B-Instruct-4bit \
  --data ./training_data \
  --batch-size 4 \
  --lora-layers 16 \
  --iters 1000

La ventaja de memoria unificada de MLX se vuelve dramática con modelos más grandes. En un MacBook Pro con 64GB de memoria unificada, puedes ejecutar un modelo de 70B parámetros con cuantización de 4 bits (aproximadamente 42GB) que requeriría una GPU dedicada con 48GB+ de VRAM en una configuración tradicional. Los chips de la serie M acceden a esta memoria con un ancho de banda superior a 200 GB/s (M3 Max), habilitando velocidades de inferencia competitivas.

Benchmarks MLX vs llama.cpp en Apple Silicon

Ambos frameworks funcionan bien en Apple Silicon, pero con diferentes características de rendimiento:

Model: Llama 3.1 8B Instruct Q4_K_M
Hardware: MacBook Pro M3 Max, 64GB RAM

                    llama.cpp (Metal)    MLX
Prompt processing:  1,847 tok/s          2,103 tok/s
Token generation:   62.3 tok/s           58.7 tok/s
Time to first token: 145 ms              112 ms
Memory usage:       4.8 GB               5.1 GB

Model: Llama 3.1 70B Instruct Q4_K_M
Hardware: Mac Studio M2 Ultra, 192GB RAM

                    llama.cpp (Metal)    MLX
Prompt processing:  487 tok/s            612 tok/s
Token generation:   18.2 tok/s           16.8 tok/s
Time to first token: 890 ms              720 ms
Memory usage:       42 GB                44 GB

MLX tiende a ganar en velocidad de procesamiento de prompts y tiempo hasta el primer token gracias a sus kernels de cómputo Metal optimizados. llama.cpp a menudo aventaja ligeramente en generación sostenida de tokens. Las diferencias son lo suficientemente pequeñas como para que la elección deba estar motivada por el ajuste al ecosistema en vez del rendimiento bruto. Si estás construyendo una aplicación Python en macOS, MLX es la elección natural. Si necesitas soporte multiplataforma, llama.cpp es la apuesta más segura.

Ollama: El Docker de los LLMs

Ollama envuelve llama.cpp en una experiencia tipo Docker para gestión de modelos. Maneja la descarga, selección de cuantización, detección de GPU y servicio de API con configuración mínima.

# Install
curl -fsSL https://ollama.ai/install.sh | sh

# Pull and run a model
ollama pull llama3.1:8b
ollama run llama3.1:8b

# List available models
ollama list

# Model details
ollama show llama3.1:8b

# Run with specific parameters
ollama run llama3.1:8b --verbose

# Serve API (starts automatically on install)
# API available at http://localhost:11434

Modelfile: Configuración Personalizada del Modelo

# Modelfile for a custom assistant
FROM llama3.1:8b

PARAMETER temperature 0.7
PARAMETER num_ctx 8192
PARAMETER top_p 0.9
PARAMETER repeat_penalty 1.1

SYSTEM """You are a senior DevOps engineer. You provide concise,
accurate answers about infrastructure, containers, CI/CD pipelines,
and cloud architecture. Always include relevant commands and
configuration examples."""

TEMPLATE """{{ if .System }}<|start_header_id|>system<|end_header_id|>
{{ .System }}<|eot_id|>{{ end }}{{ if .Prompt }}<|start_header_id|>user<|end_header_id|>
{{ .Prompt }}<|eot_id|>{{ end }}<|start_header_id|>assistant<|end_header_id|>
{{ .Response }}<|eot_id|>"""
# Build and run custom model
ollama create devops-assistant -f Modelfile
ollama run devops-assistant

API de Ollama

import requests
import json

# Chat completion
response = requests.post(
    "http://localhost:11434/api/chat",
    json={
        "model": "llama3.1:8b",
        "messages": [
            {"role": "user", "content": "Explain Kubernetes pod scheduling"}
        ],
        "stream": False
    }
)
print(response.json()["message"]["content"])

# Streaming
response = requests.post(
    "http://localhost:11434/api/chat",
    json={
        "model": "llama3.1:8b",
        "messages": [{"role": "user", "content": "List Docker best practices"}],
        "stream": True
    },
    stream=True
)
for line in response.iter_lines():
    if line:
        data = json.loads(line)
        print(data["message"]["content"], end="", flush=True)

# Embeddings
response = requests.post(
    "http://localhost:11434/api/embed",
    json={
        "model": "llama3.1:8b",
        "input": "Kubernetes is a container orchestration platform"
    }
)
embedding = response.json()["embeddings"][0]

vLLM para Auto-Hospedaje en Producción

Cuando necesitas servicio de nivel producción con alto rendimiento, vLLM es la elección estándar. Su algoritmo PagedAttention gestiona la memoria GPU como un sistema de memoria virtual, mejorando dramáticamente el rendimiento para solicitudes concurrentes.

# Install
pip install vllm

# Start server
python -m vllm.entrypoints.openai.api_server \
  --model meta-llama/Meta-Llama-3.1-8B-Instruct \
  --port 8000 \
  --tensor-parallel-size 2 \
  --max-model-len 8192 \
  --gpu-memory-utilization 0.9 \
  --enable-prefix-caching \
  --max-num-batched-tokens 32768
# vLLM Python API for offline batch processing
from vllm import LLM, SamplingParams

llm = LLM(
    model="meta-llama/Meta-Llama-3.1-8B-Instruct",
    tensor_parallel_size=2,
    max_model_len=8192,
    gpu_memory_utilization=0.9
)

sampling_params = SamplingParams(
    temperature=0.7,
    max_tokens=512,
    top_p=0.9
)

# Batch process multiple prompts efficiently
prompts = [
    "Explain microservices architecture",
    "What is event-driven design?",
    "Describe the CAP theorem"
]

outputs = llm.generate(prompts, sampling_params)
for output in outputs:
    print(f"Prompt: {output.prompt[:50]}...")
    print(f"Output: {output.outputs[0].text[:100]}...")
    print()

El procesamiento por lotes continuo de vLLM significa que no espera a que un lote completo termine antes de iniciar nuevas solicitudes. A medida que las solicitudes individuales terminan, su memoria GPU se recicla inmediatamente para nuevas solicitudes, manteniendo una alta utilización bajo carga variable.

SGLang: Generación Estructurada y Decodificación Restringida

SGLang (Structured Generation Language) se especializa en salida restringida, asegurando que las respuestas del LLM se ajusten a formatos especificados. Esto es crítico para construir aplicaciones confiables que parsean la salida del LLM programáticamente.

# SGLang structured generation
import sglang as sgl

@sgl.function
def extract_entity(s, text):
    s += sgl.user(f"Extract entities from: {text}")
    s += sgl.assistant(
        sgl.gen("result", max_tokens=256,
                regex=r'\{"name": "[^"]+", "type": "(person|org|location)"\}')
    )

# JSON mode with schema enforcement
@sgl.function
def analyze_code(s, code):
    s += sgl.system("You are a code analyzer.")
    s += sgl.user(f"Analyze this code:\n```\n{code}\n```")
    s += sgl.assistant(
        sgl.gen("analysis",
                max_tokens=512,
                json_schema={
                    "type": "object",
                    "properties": {
                        "language": {"type": "string"},
                        "complexity": {"type": "string", "enum": ["low", "medium", "high"]},
                        "issues": {
                            "type": "array",
                            "items": {"type": "string"}
                        },
                        "suggestions": {
                            "type": "array",
                            "items": {"type": "string"}
                        }
                    },
                    "required": ["language", "complexity", "issues", "suggestions"]
                })
    )

El motor RadixAttention de SGLang cachea prefijos de prompt comunes entre solicitudes, reduciendo la computación redundante cuando muchas solicitudes comparten el mismo prompt de sistema o ejemplos few-shot.

Estrategias de Cuantización: GPTQ vs AWQ vs GGUF vs EXL2

Diferentes métodos de cuantización optimizan para diferente hardware y casos de uso:

Método Mejor Para GPU Requerida Calidad Velocidad
GGUF CPU + GPU híbrido Opcional Buena Moderada
GPTQ Solo GPU NVIDIA Buena Rápida
AWQ Solo GPU NVIDIA Mejor Rápida
EXL2 Solo GPU NVIDIA La mejor La más rápida
# GPTQ quantization (requires GPU)
pip install auto-gptq
python -c "
from auto_gptq import AutoGPTQForCausalLM
from transformers import AutoTokenizer

model_name = 'meta-llama/Meta-Llama-3.1-8B-Instruct'
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoGPTQForCausalLM.from_pretrained(model_name, device_map='auto')
model.quantize(tokenizer, quant_config={'bits': 4, 'group_size': 128})
model.save_quantized('./llama-3.1-8b-gptq-4bit')
"

# AWQ quantization
pip install autoawq
python -c "
from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer

model = AutoAWQForCausalLM.from_pretrained('meta-llama/Meta-Llama-3.1-8B-Instruct')
tokenizer = AutoTokenizer.from_pretrained('meta-llama/Meta-Llama-3.1-8B-Instruct')
model.quantize(tokenizer, quant_config={'zero_point': True, 'q_group_size': 128, 'w_bit': 4})
model.save_quantized('./llama-3.1-8b-awq-4bit')
"

Para la mayoría de usuarios, el árbol de decisiones es sencillo:

  • Apple Silicon: Usa MLX 4-bit o GGUF con llama.cpp Metal
  • GPU NVIDIA con suficiente VRAM: AWQ o EXL2 para mejor calidad y velocidad
  • GPU NVIDIA con VRAM limitada: GGUF con descarga parcial a GPU
  • Solo CPU: GGUF con cuantización Q4_K_M
  • Distribución multiplataforma: GGUF (funciona en todas partes)

Calculadora de Requisitos de Memoria

Una fórmula práctica para estimar los requisitos de memoria:

def estimate_memory_gb(params_billions, bits_per_weight, context_length=4096):
    """Estimate total memory needed for LLM inference."""
    # Model weights
    weight_memory = params_billions * bits_per_weight / 8  # GB

    # KV cache (approximate)
    # Assumes: 2 (K+V) * num_layers * hidden_dim * 2 bytes * context_length
    # Simplified: ~0.5 GB per billion params per 4096 context tokens
    kv_cache = params_billions * 0.5 * (context_length / 4096)

    # Overhead (activations, buffers)
    overhead = weight_memory * 0.1

    total = weight_memory + kv_cache + overhead
    return round(total, 1)

# Examples
models = [
    ("7B Q4_K_M", 7, 4.8),
    ("7B Q8_0", 7, 8.5),
    ("13B Q4_K_M", 13, 4.8),
    ("70B Q4_K_M", 70, 4.8),
    ("70B Q8_0", 70, 8.5),
]

for name, params, bits in models:
    mem = estimate_memory_gb(params, bits)
    print(f"{name:15s} -> {mem:6.1f} GB")

# Output:
# 7B Q4_K_M       ->    7.7 GB
# 7B Q8_0         ->   11.0 GB
# 13B Q4_K_M      ->   13.8 GB
# 70B Q4_K_M      ->   73.5 GB
# 70B Q8_0        ->  108.5 GB

Regla general: necesitas aproximadamente 1.1x el tamaño de los pesos del modelo para inferencia con una longitud de contexto moderada. Para contextos largos (32K+), añade margen significativo para la caché KV.

Construyendo Apps de IA Locales: RAG con Ollama y ChromaDB

Un ejemplo práctico de construcción de una aplicación RAG (Generación Aumentada por Recuperación) local:

import chromadb
import requests
import json
from pathlib import Path

# Initialize ChromaDB
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(
    name="documents",
    metadata={"hnsw:space": "cosine"}
)

def get_embedding(text):
    """Get embeddings from local Ollama."""
    response = requests.post(
        "http://localhost:11434/api/embed",
        json={"model": "llama3.1:8b", "input": text}
    )
    return response.json()["embeddings"][0]

def index_documents(docs_dir):
    """Index documents into ChromaDB."""
    for filepath in Path(docs_dir).glob("**/*.md"):
        content = filepath.read_text()
        # Chunk the document
        chunks = chunk_text(content, max_tokens=512, overlap=50)
        for i, chunk in enumerate(chunks):
            doc_id = f"{filepath.stem}_{i}"
            embedding = get_embedding(chunk)
            collection.add(
                documents=[chunk],
                embeddings=[embedding],
                ids=[doc_id],
                metadatas=[{"source": str(filepath)}]
            )
    print(f"Indexed {collection.count()} chunks")

def chunk_text(text, max_tokens=512, overlap=50):
    """Simple chunking by paragraphs with overlap."""
    paragraphs = text.split("\n\n")
    chunks = []
    current_chunk = []
    current_length = 0

    for para in paragraphs:
        para_length = len(para.split())
        if current_length + para_length > max_tokens and current_chunk:
            chunks.append("\n\n".join(current_chunk))
            # Keep last paragraph for overlap
            current_chunk = current_chunk[-1:] if overlap > 0 else []
            current_length = len(current_chunk[0].split()) if current_chunk else 0
        current_chunk.append(para)
        current_length += para_length

    if current_chunk:
        chunks.append("\n\n".join(current_chunk))
    return chunks

def query_rag(question):
    """Query the RAG system."""
    # Retrieve relevant chunks
    query_embedding = get_embedding(question)
    results = collection.query(
        query_embeddings=[query_embedding],
        n_results=5
    )

    # Build context
    context = "\n\n---\n\n".join(results["documents"][0])

    # Generate answer with local LLM
    response = requests.post(
        "http://localhost:11434/api/chat",
        json={
            "model": "llama3.1:8b",
            "messages": [
                {
                    "role": "system",
                    "content": (
                        "Answer the question using only the provided context. "
                        "If the context does not contain enough information, "
                        "say so. Cite your sources."
                    )
                },
                {
                    "role": "user",
                    "content": f"Context:\n{context}\n\nQuestion: {question}"
                }
            ],
            "stream": False
        }
    )
    return response.json()["message"]["content"]

# Usage
index_documents("./docs")
answer = query_rag("How do I configure Kubernetes pod autoscaling?")
print(answer)

Todo este pipeline se ejecuta localmente. Ningún dato sale de la máquina, no se necesitan claves API, y el sistema funciona sin conexión. Para un despliegue en producción, añadirías un frontend web, caché persistente y manejo de actualización de documentos, pero la arquitectura RAG principal permanece igual.

Ventajas de Privacidad y Cumplimiento

El despliegue local de LLM proporciona beneficios concretos de cumplimiento:

Soberanía de datos. Los datos nunca cruzan límites de red. Esto satisface los requisitos de residencia de datos (GDPR, CCPA, PIPEDA) sin acuerdos complejos de procesamiento de datos.

Simplicidad de auditoría. No hay proveedor externo que auditar. El modelo se ejecuta en tu hardware, procesa tus datos y produce salida que permanece en tus sistemas.

Sin riesgo de datos de entrenamiento. Los proveedores de API en la nube pueden usar tus datos para mejorar sus modelos (verifica los términos cuidadosamente). Los modelos locales tienen cero riesgo de que tus datos propietarios terminen en el conjunto de entrenamiento de alguien más.

Despliegue aislado. Para entornos clasificados o instalaciones de alta seguridad, los modelos locales pueden ejecutarse completamente desconectados de cualquier red.

# Air-gapped deployment workflow
# 1. On connected machine: download model
ollama pull llama3.1:8b
# Model stored in ~/.ollama/models/

# 2. Transfer to air-gapped machine (USB, approved media)
tar -czf ollama-models.tar.gz ~/.ollama/models/

# 3. On air-gapped machine: restore and run
tar -xzf ollama-models.tar.gz -C ~/
ollama serve &
ollama run llama3.1:8b

Cuándo Permanecer Local vs Cuándo Usar APIs en la Nube

La decisión no es binaria. La mayoría de organizaciones usarán un enfoque híbrido:

Permanecer local cuando:

  • Se procesan datos sensibles, regulados o propietarios
  • Aplicaciones interactivas sensibles a la latencia
  • Cargas de trabajo de alto volumen y predecibles (más barato a largo plazo)
  • Entornos sin conexión o aislados
  • Se necesita control total sobre el modelo y su comportamiento

Usar APIs en la nube cuando:

  • Se necesitan capacidades de modelo de frontera (clase GPT-4, clase Claude Opus)
  • Las cargas de trabajo son irregulares e impredecibles
  • Se carece del hardware para modelos grandes
  • El tiempo de despliegue importa más que el costo
  • Se necesitan capacidades más allá del texto (visión, audio, uso de herramientas) que los modelos locales no pueden igualar

El patrón híbrido: Usa modelos locales para procesamiento de datos, resumen, clasificación y generación de embeddings. Usa APIs en la nube para razonamiento complejo, tareas creativas y salidas donde la calidad es crítica. Esto captura la mayoría de los ahorros de costos y beneficios de privacidad mientras mantiene acceso a capacidades de frontera cuando importan.

El ecosistema de inferencia local ha alcanzado un nivel de madurez donde es una opción de producción genuina, no un compromiso. Las herramientas son estables, los modelos son capaces, y los requisitos de hardware están al alcance de cualquier equipo de desarrollo. Ya sea que empieces con Ollama por simplicidad, llama.cpp por control, o vLLM por escala, el camino de experimento a despliegue en producción está bien transitado y bien documentado.