RAG sobre Parquet: Como construir recuperacion sobre tu Data Lake sin embeddings
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 filesimport pandas as pdimport pyarrow.parquet as pq
df = pq.read_table("s3://my-bucket/curated/sales/").to_pandas()
# Step 2: Convert rows to text chunkschunks = []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 OpenAIclient = OpenAI()
embeddings = []# 50M rows × $0.0001/1K tokens = ~$500+ just for embeddingsfor 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 databaseimport chromadbchroma = chromadb.Client()collection = chroma.create_collection("sales")collection.add(documents=chunks, embeddings=embeddings, ids=[str(i) for i in range(len(chunks))])
# Step 5: Queryresults = collection.query(query_texts=["total sales last quarter"], n_results=10)# Returns 10 individual sale records, not a SUMProblemas con este enfoque:
- Costo: Convertir 50M filas en embeddings cuesta cientos de dolares y toma horas.
- Desactualizacion: Cada actualizacion de datos requiere re-crear embeddings.
- Imprecision: La recuperacion devuelve filas similares, no respuestas agregadas.
- Sin SQL: No puede hacer GROUP BY, SUM, JOIN, window functions.
- 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:
pip install dataspoc-lensfrom 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,000Eso 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 pdimport chromadbfrom 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
# Usagecollection = 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 languageanswer = 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 controldf = 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:
| Pregunta | Respuesta RAG | Respuesta SQL/Lens | Correcto? |
|---|---|---|---|
| Ventas totales ultimo trimestre | ”Apróximadamente $4.2M basado en muestra" | "$4,230,512.00” | RAG: parcial, Lens: exacto |
| Top 5 productos por ingresos | Listo 5 productos de filas recuperadas | Top 5 exacto con montos | RAG: 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 gasto | Recupero algunas filas de alto valor | Lista completa de 847 clientes | RAG: incompleto, Lens: completo |
| Valor promedio de orden por region | Estimo de 20 filas | AVG exacto de las 10M filas | RAG: 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 costo | RAG | SQL/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 similitud | Consulta DuckDB |
| Total mensual (100 consultas/dia) | ~$1,690 | ~$30 |
Comparación de latencia
| Operacion | RAG | SQL/Lens |
|---|---|---|
| Primera consulta (fria) | 500ms (busqueda vectorial + LLM) | 2-5s (escaneo Parquet + LLM) |
| Consultas subsiguientes | 500ms | 200ms (cacheado) |
| Consulta de agregacion | 500ms (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 queriedlens.cache_refresh("curated_sales")lens.cache_refresh("curated_customers")
# Check cache statusstatus = 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 SQLfrom 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.