ragparquetduckdbai-agentsdata-lake

RAG sobre Parquet: Como construir recuperacion sobre tu Data Lake sin embeddings

Michael San Martim · 2026-04-17

Tienes 50 millones de filas de datos estructurados en archivos Parquet. Tu CEO pregunta: “Podemos dejar que la gente haga preguntas sobre nuestros datos en espanol simple?” La respuesta por defecto en 2026 es RAG: convertir todo en embeddings en una base de datos vectorial, recuperar fragmentos relevantes y alimentarlos a un LLM. Para datos estructurados, esta es la respuesta incorrecta.

El problema con RAG sobre datos estructurados

RAG fue disenado para documentos: PDFs, wikis, bases de conocimiento. Funciona encontrando fragmentos de texto que son semanticamente similares a una pregunta. Esto se rompe para datos estructurados:

  • “Cuales fueron las ventas totales del ultimo trimestre?” requiere una agregacion SUM. Ningun fragmento contiene la respuesta precalculada.
  • “Que clientes se dieron de baja en marzo?” requiere un filtro en una columna de fecha. La busqueda por similitud devuelve registros de clientes aleatorios.
  • “Compara ingresos por region ano contra ano.” requiere un GROUP BY con una window function. La recuperacion basada en embeddings no puede hacer matematicas.

El problema fundamental: la similitud vectorial encuentra texto relacionado, no respuestas correctas a preguntas analiticas.

El enfoque tradicional de RAG

Asi se ve el pipeline tipico de RAG para datos Parquet:

# Step 1: Read Parquet files
import pandas as pd
import pyarrow.parquet as pq
df = pq.read_table("s3://my-bucket/curated/sales/").to_pandas()
# Step 2: Convert rows to text chunks
chunks = []
for _, row in df.iterrows():
chunk = f"Sale: {row['product']} in {row['region']} for ${row['amount']} on {row['date']}"
chunks.append(chunk)
# Step 3: Embed chunks (this is expensive)
from openai import OpenAI
client = OpenAI()
embeddings = []
# 50M rows × $0.0001/1K tokens = ~$500+ just for embeddings
for batch in batched(chunks, 100):
resp = client.embeddings.create(model="text-embedding-3-small", input=batch)
embeddings.extend([e.embedding for e in resp.data])
# Step 4: Store in vector database
import chromadb
chroma = chromadb.Client()
collection = chroma.create_collection("sales")
collection.add(documents=chunks, embeddings=embeddings, ids=[str(i) for i in range(len(chunks))])
# Step 5: Query
results = collection.query(query_texts=["total sales last quarter"], n_results=10)
# Returns 10 individual sale records, not a SUM

Problemas con este enfoque:

  1. Costo: Convertir 50M filas en embeddings cuesta cientos de dolares y toma horas.
  2. Desactualizacion: Cada actualizacion de datos requiere re-crear embeddings.
  3. Imprecision: La recuperacion devuelve filas similares, no respuestas agregadas.
  4. Sin SQL: No puede hacer GROUP BY, SUM, JOIN, window functions.
  5. No auditable: No puedes inspeccionar que datos vio realmente el LLM.

El enfoque SQL con DataSpoc Lens

En lugar de crear embeddings de tus datos, montalos cómo tablas SQL y deja que el LLM escriba consultas:

Terminal window
pip install dataspoc-lens
from dataspoc_lens import LensClient
lens = LensClient()
# No embedding. No vector store. Just SQL over your existing Parquet files.
answer = lens.ask("What were total sales last quarter?")
print(answer)
# → Executed: SELECT SUM(amount) FROM curated_sales WHERE sale_date >= '2026-01-01'
# → Result: $4,230,000

Eso es todo. Los archivos Parquet se quedan donde estan en tu bucket de nube. Lens usa DuckDB para leerlos directamente. El LLM genera SQL en lugar de buscar texto similar.

Construyendo ambos: Una comparación justa

Construyamos el mismo sistema de preguntas y respuestas de ambas formas y comparemos.

Version RAG (completa)

"""RAG-based data Q&A over Parquet files."""
import pandas as pd
import chromadb
from openai import OpenAI
client = OpenAI()
chroma_client = chromadb.PersistentClient(path="./chroma_db")
def build_rag_index(parquet_path: str, collection_name: str):
"""Embed Parquet data into ChromaDB. Run once (or on every update)."""
df = pd.read_parquet(parquet_path)
collection = chroma_client.get_or_create_collection(collection_name)
# Convert each row to a text document
documents = []
for _, row in df.iterrows():
doc = " | ".join(f"{col}: {val}" for col, val in row.items())
documents.append(doc)
# Batch embed and store
batch_size = 100
for i in range(0, len(documents), batch_size):
batch = documents[i : i + batch_size]
ids = [str(j) for j in range(i, i + len(batch))]
collection.add(documents=batch, ids=ids)
return collection
def rag_query(question: str, collection_name: str) -> str:
"""Retrieve relevant rows and ask LLM to answer."""
collection = chroma_client.get_collection(collection_name)
results = collection.query(query_texts=[question], n_results=20)
context = "\n".join(results["documents"][0])
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "Answer based on the data provided. If you cannot determine the exact answer from the context, say so."},
{"role": "user", "content": f"Context:\n{context}\n\nQuestion: {question}"},
],
)
return response.choices[0].message.content
# Usage
collection = build_rag_index("s3://bucket/curated/sales/data.parquet", "sales")
answer = rag_query("What were total sales last quarter?", "sales")
print(answer)
# → "Based on the retrieved records, I can see several sales entries...
# I cannot provide an exact total as I only have a sample of 20 records."

Version SQL con Lens (completa)

"""SQL-based data Q&A over Parquet files with DataSpoc Lens."""
from dataspoc_lens import LensClient
lens = LensClient()
def sql_query(question: str) -> str:
"""Ask a question, get a precise answer via SQL."""
return lens.ask(question)
# Direct natural language
answer = sql_query("What were total sales last quarter?")
print(answer)
# → "$4,230,512.00 — Query: SELECT SUM(amount) FROM curated_sales
# WHERE sale_date >= '2026-01-01' AND sale_date < '2026-04-01'"
# Or explicit SQL for full control
df = lens.query("""
SELECT
region,
DATE_TRUNC('month', sale_date) AS month,
SUM(amount) AS total_sales,
COUNT(*) AS num_orders,
AVG(amount) AS avg_order_value
FROM curated_sales
WHERE sale_date >= '2025-01-01'
GROUP BY region, month
ORDER BY region, month
""")
print(df)

Benchmark: 5 preguntas sobre el mismo dataset

Ejecutamos ambos enfoques en un dataset de ventas de 10M filas en Parquet:

PreguntaRespuesta RAGRespuesta SQL/LensCorrecto?
Ventas totales ultimo trimestre”Apróximadamente $4.2M basado en muestra""$4,230,512.00”RAG: parcial, Lens: exacto
Top 5 productos por ingresosListo 5 productos de filas recuperadasTop 5 exacto con montosRAG: orden incorrecto, Lens: correcto
Tasa de crecimiento MoM”No puedo calcular desde el contexto""+12.3% Marzo vs Febrero”RAG: fallo, Lens: correcto
Clientes con > $10K de gastoRecupero algunas filas de alto valorLista completa de 847 clientesRAG: incompleto, Lens: completo
Valor promedio de orden por regionEstimo de 20 filasAVG exacto de las 10M filasRAG: impreciso, Lens: exacto

Puntuacion: RAG 0/5, SQL 5/5 en preguntas analiticas.

Comparación de costos

Para un dataset de 10M filas, actualizado diariamente:

Factor de costoRAGSQL/Lens
Embedding inicial~$50 (10M filas)$0
Re-embedding diario~$50/dia (refresh completo)$0
Hosting de vector DB~$100/mes (gestionado)$0
Por consulta (LLM)~$0.03 (contexto + respuesta)~$0.01 (solo generacion SQL)
Por consulta (computo)Busqueda de similitudConsulta DuckDB
Total mensual (100 consultas/dia)~$1,690~$30

Comparación de latencia

OperacionRAGSQL/Lens
Primera consulta (fria)500ms (busqueda vectorial + LLM)2-5s (escaneo Parquet + LLM)
Consultas subsiguientes500ms200ms (cacheado)
Consulta de agregacion500ms (pero respuesta incorrecta)1-3s (escaneo completo de tabla)

Lens es mas lento en la primera consulta porque lee archivos Parquet del almacenamiento en la nube. Despues de la primera consulta, DuckDB cachea los datos y las consultas subsiguientes son mas rapidas. También puedes cachear proactivamente:

# Pre-warm the cache for tables you know will be queried
lens.cache_refresh("curated_sales")
lens.cache_refresh("curated_customers")
# Check cache status
status = lens.cache_status()
print(status)
# {'curated_sales': {'cached': True, 'rows': 10000000, 'size_mb': 450}}

Cuando usar cada enfoque

Usa RAG cuando:

  • Tus datos son no estructurados (documentos, emails, tickets de soporte)
  • Necesitas busqueda semantica (“encuentra registros similares a este”)
  • La respuesta es un pasaje especifico en un documento
  • Tus datos son pequenos (< 100K documentos)

Usa SQL/Lens cuando:

  • Tus datos son estructurados (tablas, columnas, filas)
  • Necesitas agregaciones (SUM, AVG, COUNT, GROUP BY)
  • La respuesta requiere números exactos
  • Tus datos estan en archivos Parquet en un bucket de nube
  • Necesitas auditabilidad (cada respuesta se traza a una consulta SQL)

Usa ambos cuando:

  • Tienes datos mixtos: tablas estructuradas + documentos no estructurados
  • Construye dos herramientas y deja que el LLM decida cual usar por pregunta
# Hybrid: Let the agent choose between RAG and SQL
from dataspoc_lens import LensClient
lens = LensClient()
TOOLS = [
{
"type": "function",
"function": {
"name": "query_structured_data",
"description": "Query structured data (sales, orders, metrics) using SQL.",
"parameters": {
"type": "object",
"properties": {"sql": {"type": "string"}},
"required": ["sql"],
},
},
},
{
"type": "function",
"function": {
"name": "search_documents",
"description": "Search unstructured documents (policies, manuals, tickets).",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
},
]

La conclusion: para datos estructurados en un lake, SQL supera a la recuperacion basada en embeddings en cada dimension que importa — precision, costo, latencia y auditabilidad. Guarda RAG para lo que fue disenado: texto no estructurado.

Recomendados