ragparquetduckdbai-agentsdata-lake

RAG em Parquet: Como Construir Recuperação Sobre Seu Data Lake Sem Embeddings

Michael San Martim · 2026-04-17

Você tem 50 milhões de linhas de dados estruturados em arquivos Parquet. Seu CEO pergunta: “Podemos deixar as pessoas fazerem perguntas sobre nossos dados em português?” A resposta padrão em 2026 é RAG: gerar embeddings de tudo em um banco vetorial, recuperar chunks relevantes e alimentar um LLM. Para dados estruturados, essa é a resposta errada.

O Problema com RAG em Dados Estruturados

RAG foi projetado para documentos: PDFs, wikis, bases de conhecimento. Funciona encontrando chunks de texto semanticamente similares a uma pergunta. Isso quebra para dados estruturados:

  • “Quais foram as vendas totais no último trimestre?” requer uma agregação SUM. Nenhum chunk contém a resposta pré-calculada.
  • “Quais clientes deram churn em março?” requer um filtro em uma coluna de data. Busca por similaridade retorna registros aleatórios de clientes.
  • “Compare receita por região ano a ano.” requer um GROUP BY com window function. Recuperação baseada em embedding não consegue fazer matemática.

O problema fundamental: similaridade vetorial encontra texto relacionado, não respostas corretas para perguntas analíticas.

A Abordagem Tradicional de RAG

Veja como o pipeline RAG típico se parece para dados 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 com esta abordagem:

  1. Custo: Gerar embeddings de 50M de linhas custa centenas de dólares e leva horas.
  2. Desatualização: Cada atualização de dados requer novo embedding.
  3. Imprecisão: Recuperação retorna linhas similares, não respostas agregadas.
  4. Sem SQL: Não consegue fazer GROUP BY, SUM, JOIN, window functions.
  5. Não auditável: Você não pode inspecionar quais dados o LLM realmente viu.

A Abordagem SQL com DataSpoc Lens

Em vez de gerar embeddings dos seus dados, monte-os como tabelas SQL e deixe o LLM escrever 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

Só isso. Os arquivos Parquet ficam onde estão no seu bucket na nuvem. O Lens usa DuckDB para lê-los diretamente. O LLM gera SQL em vez de buscar texto similar.

Construindo Ambos: Uma Comparação Justa

Vamos construir o mesmo sistema de perguntas e respostas de ambas as formas e comparar.

Versão 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."

Versão SQL com 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 Perguntas no Mesmo Dataset

Executamos ambas as abordagens em um dataset de vendas de 10M de linhas em Parquet:

PerguntaResposta RAGResposta SQL/LensCorreto?
Vendas totais último trimestre”Aproximadamente $4,2M baseado na amostra""$4.230.512,00”RAG: parcial, Lens: exato
Top 5 produtos por receitaListou 5 produtos das linhas recuperadasTop 5 exato com valoresRAG: ordem errada, Lens: correto
Taxa de crescimento MoM”Não consigo calcular pelo contexto""+12,3% Março vs Fevereiro”RAG: falhou, Lens: correto
Clientes com gasto > $10KRecuperou algumas linhas de alto valorLista completa de 847 clientesRAG: incompleto, Lens: completo
Valor médio do pedido por regiãoEstimou a partir de 20 linhasAVG exato de todas as 10M linhasRAG: impreciso, Lens: exato

Placar: RAG 0/5, SQL 5/5 em perguntas analíticas.

Comparação de Custos

Para um dataset de 10M de linhas, atualizado diariamente:

Fator de CustoRAGSQL/Lens
Embedding inicial~$50 (10M linhas)$0
Re-embedding diário~$50/dia (refresh completo)$0
Hospedagem vector DB~$100/mês (gerenciado)$0
Por consulta (LLM)~$0,03 (contexto + resposta)~$0,01 (apenas geração SQL)
Por consulta (compute)Busca de similaridadeConsulta DuckDB
Total mensal (100 consultas/dia)~$1.690~$30

Comparação de Latência

OperaçãoRAGSQL/Lens
Primeira consulta (frio)500ms (busca vetorial + LLM)2-5s (scan Parquet + LLM)
Consultas subsequentes500ms200ms (em cache)
Consulta de agregação500ms (mas resposta errada)1-3s (full table scan)

O Lens é mais lento na primeira consulta porque lê arquivos Parquet do armazenamento na nuvem. Após a primeira consulta, o DuckDB faz cache dos dados e consultas subsequentes são mais rápidas. Você também pode fazer cache proativo:

# 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}}

Quando Usar Cada Abordagem

Use RAG Quando:

  • Seus dados são não estruturados (documentos, emails, tickets de suporte)
  • Você precisa de busca semântica (“encontre registros similares a este”)
  • A resposta é uma passagem específica em um documento
  • Seus dados são pequenos (< 100K documentos)

Use SQL/Lens Quando:

  • Seus dados são estruturados (tabelas, colunas, linhas)
  • Você precisa de agregações (SUM, AVG, COUNT, GROUP BY)
  • A resposta requer números exatos
  • Seus dados estão em arquivos Parquet em um bucket na nuvem
  • Você precisa de auditabilidade (cada resposta rastreia uma consulta SQL)

Use Ambos Quando:

  • Você tem dados mistos: tabelas estruturadas + documentos não estruturados
  • Construa duas ferramentas e deixe o LLM decidir qual usar por pergunta
# 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"],
},
},
},
]

A conclusão: para dados estruturados em um lake, SQL supera recuperação baseada em embeddings em todas as dimensões que importam — precisão, custo, latência e auditabilidade. Guarde RAG para o que foi projetado: texto não estruturado.

Recomendados