RAG em Parquet: Como Construir Recuperação Sobre Seu Data Lake Sem Embeddings
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 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 com esta abordagem:
- Custo: Gerar embeddings de 50M de linhas custa centenas de dólares e leva horas.
- Desatualização: Cada atualização de dados requer novo embedding.
- Imprecisão: Recuperação retorna linhas similares, não respostas agregadas.
- Sem SQL: Não consegue fazer GROUP BY, SUM, JOIN, window functions.
- 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:
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,000Só 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 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."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 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 Perguntas no Mesmo Dataset
Executamos ambas as abordagens em um dataset de vendas de 10M de linhas em Parquet:
| Pergunta | Resposta RAG | Resposta SQL/Lens | Correto? |
|---|---|---|---|
| Vendas totais último trimestre | ”Aproximadamente $4,2M baseado na amostra" | "$4.230.512,00” | RAG: parcial, Lens: exato |
| Top 5 produtos por receita | Listou 5 produtos das linhas recuperadas | Top 5 exato com valores | RAG: 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 > $10K | Recuperou algumas linhas de alto valor | Lista completa de 847 clientes | RAG: incompleto, Lens: completo |
| Valor médio do pedido por região | Estimou a partir de 20 linhas | AVG exato de todas as 10M linhas | RAG: 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 Custo | RAG | SQL/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 similaridade | Consulta DuckDB |
| Total mensal (100 consultas/dia) | ~$1.690 | ~$30 |
Comparação de Latência
| Operação | RAG | SQL/Lens |
|---|---|---|
| Primeira consulta (frio) | 500ms (busca vetorial + LLM) | 2-5s (scan Parquet + LLM) |
| Consultas subsequentes | 500ms | 200ms (em cache) |
| Consulta de agregação | 500ms (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 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}}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 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"], }, }, },]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.