2. RAG com pgvector
Um modelo de linguagem não sabe nada sobre o catálogo do BoxOffice desta semana, e quando não sabe, inventa com confiança. A geração aumentada por recuperação (RAG) resolve o primeiro problema e reduz o segundo: antes que o modelo responda, o serviço recupera os documentos mais relacionados à pergunta e os coloca no prompt, com instruções para responder só a partir deles e citá-los. A teoria está na aula de RAG e memória.
flowchart LR
subgraph ingest [ingestão: uma vez, e quando o catálogo muda]
cat[API do catalog] --> txt[evento -> texto]
txt --> emb1[modelo de embeddings]
emb1 --> pg[("assistant.documents (vector 768)")]
end
subgraph query [toda pergunta]
q[pergunta] --> emb2[modelo de embeddings]
emb2 -->|"ORDER BY embedding <=> q LIMIT k"| pg
pg --> ctx[top-k documentos]
ctx --> llm[LLM: resposta + citações]
end Embeddings em um parágrafo
Um modelo de embeddings mapeia um texto para um vetor (aqui, 768 números) de modo que textos com significado parecido recebam vetores apontando em direções parecidas. "Tem algum festival em Recife?" e "Festival #74 na Arena Recife…" acabam próximos, mesmo com poucas palavras em comum. A distância do cosseno (<=> no pgvector) mede o ângulo entre dois vetores: 0 para a mesma direção, até 2 para direções opostas; 1 - distância é a similaridade que a busca retorna.
A tabela e o índice
"""PostgreSQL + pgvector: the vector store of the assistant (schema "assistant")."""
import psycopg
from pgvector.psycopg import register_vector_async
from psycopg_pool import AsyncConnectionPool
from .config import settings
SCHEMA = f"""
CREATE EXTENSION IF NOT EXISTS vector;
CREATE SCHEMA IF NOT EXISTS assistant;
CREATE TABLE IF NOT EXISTS assistant.documents (
id TEXT PRIMARY KEY, -- e.g. "event:123"
kind TEXT NOT NULL, -- "event" (add "faq", "policy" ... later)
title TEXT NOT NULL,
content TEXT NOT NULL, -- the text that was embedded
embedding vector({settings.embed_dim}) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- approximate nearest neighbours (HNSW) on cosine distance, the metric of the embedding model
CREATE INDEX IF NOT EXISTS documents_embedding_hnsw
ON assistant.documents USING hnsw (embedding vector_cosine_ops);
"""
async def migrate() -> None:
"""Create the extension and the table before the pool registers the vector type."""
async with await psycopg.AsyncConnection.connect(settings.db_dsn, autocommit=True) as conn:
await conn.execute(SCHEMA)
pool = AsyncConnectionPool(settings.db_dsn, configure=register_vector_async, min_size=1, max_size=10, open=False)
vector(768)é o tipo de coluna do pgvector; a dimensão precisa bater com a do modelo de embeddings.- HNSW (Hierarchical Navigable Small World) é um índice de vizinhos mais próximos aproximado: um grafo em camadas que encontra os vetores mais próximos em tempo logarítmico, em vez de comparar a consulta com cada linha. Ele troca um pouco de recall por muita velocidade;
vector_cosine_opso constrói para a distância do cosseno que as consultas usam. - O schema é criado pelo serviço na inicialização (
CREATE … IF NOT EXISTS), não pelo Flyway: o assistente é o único serviço Python e mantém a sua única tabela simples. Cada serviço continua dono do seu próprio schema, como no lab 0.
Ingestão e busca
"""Retrieval-augmented generation: catalog events -> text -> embeddings -> pgvector -> top-k."""
import asyncio
from dataclasses import dataclass
import httpx
import numpy as np
from .config import settings
from .db import pool
from .llm import embed
# nomic-embed-text was trained with task prefixes: documents and queries are embedded differently
DOC_PREFIX, QUERY_PREFIX = "search_document: ", "search_query: "
@dataclass
class Hit:
id: str
title: str
content: str
score: float # cosine similarity, 1 = same direction
def event_text(e: dict) -> str:
"""The text a fan would search for: what, where, when, how much."""
v = e["venue"]
return (f"{e['title']} ({e['category'].lower()}) at {v['name']}, {v['city']} "
f"(capacity {v['capacity']}), on {e['startsAt']}. Tickets from R$ {e['priceFrom']}.")
async def fetch_events(limit: int) -> list[dict]:
"""Events 1..limit from the catalog API (8 requests in parallel)."""
sem = asyncio.Semaphore(8)
async with httpx.AsyncClient(base_url=settings.catalog_url, timeout=10) as http:
async def one(event_id: int) -> dict | None:
async with sem:
r = await http.get(f"/catalog/events/{event_id}")
return r.json() if r.status_code == 200 else None
found = await asyncio.gather(*(one(i) for i in range(1, limit + 1)))
return [e for e in found if e]
async def ingest(limit: int, batch: int = 32) -> int:
events = await fetch_events(limit)
for start in range(0, len(events), batch):
chunk = events[start:start + batch]
texts = [event_text(e) for e in chunk]
vectors = await embed([DOC_PREFIX + t for t in texts])
async with pool.connection() as conn:
async with conn.cursor() as cur:
await cur.executemany(
"""
INSERT INTO assistant.documents (id, kind, title, content, embedding)
VALUES (%s, 'event', %s, %s, %s)
ON CONFLICT (id) DO UPDATE
SET title = EXCLUDED.title, content = EXCLUDED.content,
embedding = EXCLUDED.embedding, updated_at = now()
""",
[(f"event:{e['id']}", e["title"], t, np.array(v, dtype=np.float32))
for e, t, v in zip(chunk, texts, vectors)],
)
return len(events)
async def search(query: str, k: int) -> list[Hit]:
[vector] = await embed([QUERY_PREFIX + query])
async with pool.connection() as conn:
rows = await (await conn.execute(
"""
SELECT id, title, content, 1 - (embedding <=> %s) AS score
FROM assistant.documents
ORDER BY embedding <=> %s
LIMIT %s
""",
(np.array(vector, dtype=np.float32), np.array(vector, dtype=np.float32), k),
)).fetchall()
return [Hit(id=r[0], title=r[1], content=r[2], score=float(r[3])) for r in rows]
| Decisão | Por quê |
|---|---|
| um documento por evento, escrito como uma frase | o modelo de embeddings foi treinado com frases, não com JSON; "o quê, onde, quando, quanto" é o que os fãs perguntam |
prefixos search_document: / search_query: | o nomic-embed-text foi treinado com eles; sem eles, a qualidade da recuperação cai |
| lotes de 32 textos por chamada de embedding | menos idas e voltas; o modelo processa um lote mais rápido que 32 chamadas avulsas |
ON CONFLICT … DO UPDATE | rodar a ingestão de novo atualiza os eventos alterados em vez de duplicá-los |
ORDER BY embedding <=> %s LIMIT k | exatamente a forma que o índice HNSW acelera (um ORDER BY pela distância, com um LIMIT) |
O seed do catálogo do lab 1 tem 200.000 eventos; gerar o embedding de todos numa CPU de notebook levaria horas. O lab ingere os primeiros limit eventos (300 aqui); sistemas em produção geram embeddings de forma incremental, conforme os eventos são criados ou alterados (um evento de outbox do lab 7 é o gatilho natural).
Ingira e busque
event:150 0.8472402642106125
event:250 0.8454701076817256
A ingestão levou 3 minutos e meio numa execução de teste num notebook ocupado, com o Ollama na CPU (≈ 1,4 evento/s); com o Ollama numa GPU, leva segundos. Você deve ver {"ingested":300} e três ids event:N com similaridades em torno de 0,8. Olhe as linhas no PostgreSQL:
docker compose exec db psql -U boxoffice -d boxoffice \
-c "SELECT id, left(content, 70) FROM assistant.documents ORDER BY id LIMIT 3;"
Avalie a recuperação
"Retornou algo plausível" não é uma medição. Como o seed do catálogo é gerado por uma fórmula, as respostas certas para um conjunto de perguntas são conhecidas exatamente, e a recuperação pode ser pontuada:
# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx>=0.28"]
# ///
"""Retrieval evaluation: does the vector search find the right events? (hit@k and MRR)
The catalog seed of lab 1 is generated by a formula, so the right answers are known exactly:
event g is title ["Concert", "Match", "Festival", "Play"][g % 4] + " #g", at venue 1 + (g * 7) % 50.
uv run scripts/eval_retrieval.py --k 5 --limit 300
"""
import argparse
import httpx
CITIES = ["São Paulo", "Rio de Janeiro", "Belo Horizonte", "Porto Alegre", "Curitiba",
"Salvador", "Recife", "Fortaleza", "Brasília", "Manaus"]
KINDS = ["Stadium", "Arena", "Hall", "Theater", "Club"]
TITLES = ["Concert", "Match", "Festival", "Play"]
WORDS = {"Concert": "concert", "Match": "match", "Festival": "festival", "Play": "play"}
def event(g: int) -> dict:
if g == 1:
return {"id": 1, "kind": "Festival", "venue_kind": "Stadium", "city": "São Paulo"}
v = 1 + (g * 7) % 50
return {"id": g, "kind": TITLES[g % 4], "venue_kind": KINDS[(v - 1) % 5], "city": CITIES[(v - 1) // 5]}
def labeled(limit: int) -> list[tuple[str, set[str]]]:
events = [event(g) for g in range(1, limit + 1)]
cases = []
for g in [7, 42, 118, 199, 256]: # exact lookups: one right answer
if g <= limit:
e = event(g)
cases.append((f"When is {e['kind']} #{g}?", {f"event:{g}"}))
for kind, city in [("Festival", "Recife"), ("Play", "Curitiba"), ("Match", "Salvador"),
("Concert", "Manaus"), ("Festival", "São Paulo")]: # open questions: many right answers
right = {f"event:{e['id']}" for e in events if e["kind"] == kind and e["city"] == city}
if right:
cases.append((f"Is there any {WORDS[kind]} in {city}?", right))
cases.append(("the big festival night at the stadium in São Paulo", {"event:1"}))
return cases
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--url", default="http://localhost:8088")
ap.add_argument("--k", type=int, default=5)
ap.add_argument("--limit", type=int, default=300, help="how many events were ingested")
args = ap.parse_args()
hits, rr = 0, 0.0
print("| question | relevant | first relevant rank |\n|---|---|---|")
for question, right in labeled(args.limit):
found = httpx.post(f"{args.url}/assistant/search", json={"query": question, "k": args.k}, timeout=60).json()
ranks = [i + 1 for i, h in enumerate(found) if h["id"] in right]
rank = ranks[0] if ranks else None
hits += rank is not None
rr += 1 / rank if rank else 0
print(f"| {question} | {len(right)} | {rank or '-'} |")
n = len(labeled(args.limit))
print(f"\nhit@{args.k} = {hits / n:.2f} MRR = {rr / n:.2f} ({n} questions)")
if __name__ == "__main__":
main()
O cabeçalho # /// script são metadados de script inline: o uv run os lê e roda o script num ambiente descartável com httpx, sem pip install e sem ambiente virtual para gerenciar.
Uma execução de teste com nomic-embed-text:
| question | relevant | first relevant rank |
|---|---|---|
| When is Play #7? | 1 | 1 |
| When is Festival #42? | 1 | 1 |
| When is Festival #118? | 1 | 1 |
| When is Play #199? | 1 | 1 |
| When is Concert #256? | 1 | 1 |
| Is there any festival in Recife? | 9 | 1 |
| Is there any play in Curitiba? | 6 | 1 |
| Is there any match in Salvador? | 9 | 1 |
| Is there any concert in Manaus? | 6 | 1 |
| Is there any festival in São Paulo? | 10 | 1 |
| the big festival night at the stadium in São Paulo | 1 | 3 |
hit@5 = 1.00 MRR = 0.94 (11 questions)
hit@k é a fração de perguntas com pelo menos um documento certo entre os k primeiros; o MRR (mean reciprocal rank) também recompensa colocá-lo em primeiro (posição 1 → 1, posição 3 → ⅓). A última pergunta é a difícil: "the big festival night" é o evento 1, BoxOffice Fest — Stadium Night, e os festivais genéricos no mesmo estádio ficam acima dele. Datas e nomes exatos são onde a busca puramente vetorial é mais fraca; sistemas reais a combinam com busca por palavras-chave (busca híbrida) e um reranker.
Por que avaliar a recuperação separadamente das respostas?
Se o documento certo não está no contexto, nenhum modelo consegue responder corretamente — e a falha parece um "modelo ruim". Medir só a recuperação diz qual metade do pipeline consertar, e é barato: nenhuma chamada ao LLM, totalmente determinístico.