Pular para o conteúdo
iauaiiauai — portal de tecnologia, IA e Cloud
IAIntermediário

Banco vetorial na prática: pgvector e além

Implemente banco vetorial com pgvector no Postgres. Aprenda índices HNSW vs IVFFlat, busca híbrida em SQL e quando migrar para solução dedicada.

Por Equipe iauai · 13 de agosto de 2026 · 12 min de leitura

Nesta página

O problema real

Você leu que precisa de um "banco vetorial" e foi procurar qual escolher: Pinecone, Weaviate, Milvus, Qdrant. Assinou Pinecone (USD 0,04 por 100k embeddings armazenados/mês), criou uma infra nova, aumentou a complexidade da stack. Meses depois, descobre que o Postgres que já tem em produção resolveria 95% do seu caso com pgvector, economizando custo e devops. O 1% que não resolve é bem específico — escala massiva (>100M embeddings) ou filtros muito complexos.

A verdade: você não precisa de um banco dedicado até precisar. Começar com pgvector é pragmático, e migrar depois é trivial.

Por que pgvector cobre a maioria dos casos?

Um "banco vetorial" em sua essência é:

  1. Armazenar vetores (embeddings) de forma indexada
  2. Buscar rápido por similaridade (cosseno, euclidiana, ou inner product)
  3. Filtrar por metadados enquanto busca

Postgres + pgvector faz exatamente isso. Índices HNSW mantêm buscas em O(log n). Para 1 milhão de embeddings em sa-east-1, latência tipicamente entre 50-200ms. Custo: zero (você já paga Postgres). Migração futura: SQL direto em Python, sem aprender SDK novo.

Mão na massa

Vamos montar um retriever de RAG real usando pgvector com SQL.

Setup: instalar pgvector

# Em um Postgres existente
docker exec <seu_container_postgres> psql -U postgres

# Dentro do psql:
CREATE EXTENSION IF NOT EXISTS vector;

# Verifica instalação
SELECT * FROM pg_extension WHERE extname = 'vector';

Criar tabela com coluna vetorial

-- Cria tabela para armazenar chunks + embeddings
CREATE TABLE document_chunks (
    id BIGSERIAL PRIMARY KEY,
    document_id INT NOT NULL,
    chunk_index INT NOT NULL,
    content TEXT NOT NULL,
    embedding vector(1536),  -- 1536 dimensões é padrão OpenAI
    metadata JSONB,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Índice para buscas rápidas (HNSW é mais rápido que IVFFlat para <5M vetores)
CREATE INDEX ON document_chunks 
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

-- Índice para filtros de metadados
CREATE INDEX ON document_chunks USING GIN (metadata);

Inserir embeddings

import psycopg2
from openai import OpenAI
from typing import List

def insert_chunks_with_embeddings(
    db_host: str,
    db_name: str,
    document_id: int,
    chunks: List[str],
    embedding_model: str = "text-embedding-3-small"
):
    """Insere chunks com embeddings no pgvector."""
    client = OpenAI()
    
    # Gera embeddings em batch (até 2.048 por request)
    response = client.embeddings.create(
        model=embedding_model,
        input=chunks
    )
    embeddings = [item.embedding for item in response.data]
    
    # Conecta ao Postgres
    conn = psycopg2.connect(
        host=db_host,
        database=db_name,
        user="postgres",
        password="sua_senha"
    )
    cursor = conn.cursor()
    
    # Insere chunks + embeddings
    for idx, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
        cursor.execute(
            """
            INSERT INTO document_chunks 
            (document_id, chunk_index, content, embedding, metadata)
            VALUES (%s, %s, %s, %s, %s)
            """,
            (
                document_id,
                idx,
                chunk,
                embedding,  # pgvector aceita list direto
                {"source": "documento_principal"}
            )
        )
    
    conn.commit()
    cursor.close()
    conn.close()
    
    print(f"Inseridos {len(chunks)} chunks com embeddings")

# Uso
chunks = [
    "A Constituição foi promulgada em 1988",
    "O Presidente é eleito por voto direto",
    "O Congresso Nacional composto de Câmara e Senado"
]

insert_chunks_with_embeddings(
    db_host="localhost",
    db_name="sua_db",
    document_id=1,
    chunks=chunks
)

Custo real: Para 10 mil chunks de 512 tokens: 5M tokens × USD 0,02/1M = USD 0,10. Uma vez. (Depois é grátis — apenas Postgres.)

Busca por similaridade (cosseno)

def retrieve_by_similarity(
    db_host: str,
    db_name: str,
    query: str,
    top_k: int = 3,
    embedding_model: str = "text-embedding-3-small"
) -> List[dict]:
    """Busca chunks similares à query usando cosseno."""
    
    client = OpenAI()
    
    # Embedding da query
    response = client.embeddings.create(
        model=embedding_model,
        input=[query]
    )
    query_embedding = response.data[0].embedding
    
    # Busca no Postgres
    conn = psycopg2.connect(
        host=db_host,
        database=db_name,
        user="postgres",
        password="sua_senha"
    )
    cursor = conn.cursor()
    
    # HNSW index (mais rápido com <5M vetores)
    cursor.execute(
        """
        SELECT 
            id,
            content,
            metadata,
            1 - (embedding <=> %s) as similarity_score
        FROM document_chunks
        ORDER BY embedding <=> %s
        LIMIT %s
        """,
        (query_embedding, query_embedding, top_k)
    )
    
    results = cursor.fetchall()
    cursor.close()
    conn.close()
    
    return [
        {
            "id": r[0],
            "content": r[1],
            "metadata": r[2],
            "score": r[3]
        }
        for r in results
    ]

# Uso
query = "Qual é o sistema de governo?"
results = retrieve_by_similarity(
    db_host="localhost",
    db_name="sua_db",
    query=query,
    top_k=3
)

for r in results:
    print(f"Score: {r['score']:.3f} | {r['content'][:80]}...")

Latência típica: 50-150ms para 1M documentos em sa-east-1.

Busca híbrida (vetorial + full-text)

Combine busca semântica (embedding) com busca literal (full-text). Melhor de dois mundos.

def retrieve_hybrid(
    db_host: str,
    db_name: str,
    query: str,
    top_k: int = 3
) -> List[dict]:
    """Busca híbrida: 70% semântica + 30% full-text."""
    
    client = OpenAI()
    
    # Embedding da query
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=[query]
    )
    query_embedding = response.data[0].embedding
    
    conn = psycopg2.connect(
        host=db_host,
        database=db_name,
        user="postgres",
        password="sua_senha"
    )
    cursor = conn.cursor()
    
    # CTE com ranking: 70% similaridade vetorial + 30% full-text
    cursor.execute(
        """
        WITH vector_results AS (
            SELECT 
                id,
                content,
                metadata,
                1 - (embedding <=> %s) as vector_score,
                ROW_NUMBER() OVER (ORDER BY embedding <=> %s) as vector_rank
            FROM document_chunks
            LIMIT 100  -- busca os top-100 primeiro
        ),
        text_results AS (
            SELECT 
                id,
                content,
                metadata,
                ts_rank(to_tsvector('portuguese', content), 
                        plainto_tsquery('portuguese', %s)) as text_score,
                ROW_NUMBER() OVER (ORDER BY ts_rank DESC) as text_rank
            FROM document_chunks
            WHERE to_tsvector('portuguese', content) @@ 
                  plainto_tsquery('portuguese', %s)
        ),
        combined AS (
            SELECT 
                COALESCE(v.id, t.id) as id,
                COALESCE(v.content, t.content) as content,
                COALESCE(v.metadata, t.metadata) as metadata,
                0.7 * COALESCE(v.vector_score, 0) + 
                0.3 * COALESCE(t.text_score / 32.0, 0) as combined_score
            FROM vector_results v
            FULL OUTER JOIN text_results t ON v.id = t.id
        )
        SELECT id, content, metadata, combined_score
        FROM combined
        ORDER BY combined_score DESC
        LIMIT %s
        """,
        (query_embedding, query_embedding, query, query, top_k)
    )
    
    results = cursor.fetchall()
    cursor.close()
    conn.close()
    
    return [
        {
            "id": r[0],
            "content": r[1],
            "metadata": r[2],
            "score": r[3]
        }
        for r in results
    ]

Vantagem: Captura tanto semântica ("qual sistema de governo") quanto literal ("constituição", "congresso"). Melhora recall.

Filtros de metadados durante busca

def retrieve_with_filters(
    db_host: str,
    db_name: str,
    query: str,
    filters: dict,
    top_k: int = 3
) -> List[dict]:
    """Busca vetorial + filtros de metadados (ex: apenas documentos após 2020)."""
    
    client = OpenAI()
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=[query]
    )
    query_embedding = response.data[0].embedding
    
    conn = psycopg2.connect(
        host=db_host,
        database=db_name,
        user="postgres",
        password="sua_senha"
    )
    cursor = conn.cursor()
    
    # Constrói WHERE clause dinamicamente
    where_clauses = ["1=1"]
    params = [query_embedding, query_embedding]
    
    for key, value in filters.items():
        where_clauses.append(f"metadata->'{key}' = %s")
        params.append(value)
    
    where_sql = " AND ".join(where_clauses)
    
    cursor.execute(
        f"""
        SELECT 
            id,
            content,
            metadata,
            1 - (embedding <=> %s) as similarity_score
        FROM document_chunks
        WHERE {where_sql}
        ORDER BY embedding <=> %s
        LIMIT %s
        """,
        params + [top_k]
    )
    
    results = cursor.fetchall()
    cursor.close()
    conn.close()
    
    return results

# Uso: buscar só documentos da legislação pós-2020
results = retrieve_with_filters(
    db_host="localhost",
    db_name="sua_db",
    query="governo",
    filters={"ano": "2020"},
    top_k=5
)

Armadilhas comuns

Armadilha 1: Índice HNSW vs IVFFlat

  • HNSW: Rápido, recall alto (~99%), mas construção lenta (5-10 min para 1M). Use para < 5M vetores.
  • IVFFlat: Construção rápida, mas recall ~95% e requer re-tuning. Use para > 5M vetores.

Para começar, use HNSW. Se latência ficar insuportável, mude.

Armadilha 2: Não normalizar embeddings

Se misturar modelos de embedding (OpenAI com Hugging Face), distâncias ficam inconsistentes. Use sempre o mesmo modelo.

Armadilha 3: Forget to vacuum

Postgres não deleta automaticamente. Se deletar muitos chunks:

VACUUM ANALYZE document_chunks;

Quando não usar pgvector

  • Escala > 100M embeddings: Postgres fica lento. Use Pinecone (USD 40/mês) ou Weaviate.
  • Filtros muito complexos: Você quer buscar "documentos de 2023 para usuários premium em sa-east-1 com score > 0.8". Weaviate é melhor.
  • Multi-tenancy com isolamento rígido: Postgres consegue, mas é complexo. Pinecone/Weaviate isolam melhor.
  • Zero latência é crítico: Caching em Redis com pgvector bate tudo, mas adiciona complexidade.

Próximos passos

  1. Combine pgvector com RAG na prática — substitua Chroma por pgvector.
  2. Leia FinOps para IA para calcular real vs. Pinecone em escala.
  3. Se evoluir para multi-tenant, migre com observabilidade de LLM em place.

Resumo prático

Aspecto pgvector Pinecone Weaviate
Setup 5 min 2 min 30 min
Custo (1M docs) Postgres USD 40/mês USD 50/mês+
Latência (sa-east-1) 50-150ms 10-50ms 50-200ms
Escala máxima 100M (degradado) 500M (caro)
Filtros complexos ⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐

Recomendação: Comece com pgvector. Se escala > 10M ou latência < 10ms virar crítico, migre. SQL em Postgres é transferível — seus evals vão continuar funcionando.

Continue lendo