parsr.

Integration · pgvector (Postgres)

Add semantic search to your existing Postgres in 50 lines — parsr chunks + pgvector + HNSW.

pgvector is the simplest path to RAG: a Postgres extension that adds a `vector` column type and HNSW / IVFFlat indices. ~50K GitHub stars, ships with every Postgres on Neon / Supabase / Crunchy Bridge / RDS Aurora out of the box. The wedge for parsr customers: if your application already runs on Postgres (most fintechs and AI bookkeeping agents do), pgvector means RAG without operating a second datastore. parsr's chunks ingest into a single `chunks` table; metadata filtering is just SQL `WHERE` clauses you already know how to write. Joins between parsr-extracted entities (transactions, invoices) and chunk vectors become trivial — semantic search composed with relational filters in one query.

Install

One command

pip install parsr-sdk psycopg[binary] pgvector openai

Code

Working sample

pgvector (Postgres) integrationcode
from parsr_sdk import AsyncParsr
import psycopg, pgvector.psycopg

parsr = AsyncParsr(api_key="sk_eu_live_...")
conn = psycopg.connect("postgresql://...")
pgvector.psycopg.register_vector(conn)

What you get

Highlights

  • No second datastore — RAG inside the Postgres you already operate
  • SQL metadata filters compose with vector search in one query
  • Joins between parsr-extracted entities and chunk vectors are native
  • HNSW indices give sub-10ms p95 at million-vector scale
  • Neon EU + parsr EU = end-to-end EU residency, no extra vendor

Architecture

How the pieces fit

Single Postgres database with two tables: a relational `documents` table (parsr structured extraction landed here) and a `chunks` table with `(id, doc_id, text, embedding vector(1536), metadata jsonb)`. HNSW index on the embedding column for sub-10ms semantic retrieval. parsr.parse(*, include_chunks=true) produces both the structured payload (insert into `documents`) and the chunks (insert into `chunks` after embedding). Joins between the two are native SQL — semantic retrieval composed with relational filters in a single query.

Quickstart

End-to-end example

Parse a document with `include_chunks=true`, embed each chunk, upsert into pgvector (Postgres), query.

parsr → embed → pgvector (Postgres) → querypython
import os
import asyncio
from parsr_sdk import AsyncParsr
import psycopg
from pgvector.psycopg import register_vector
from openai import AsyncOpenAI

parsr = AsyncParsr(api_key=os.environ["PARSR_API_KEY"])
openai = AsyncOpenAI()

conn = psycopg.connect(os.environ["DATABASE_URL"], autocommit=True)
register_vector(conn)

# 1. Schema (run once).
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
conn.execute("""
    CREATE TABLE IF NOT EXISTS chunks (
      id           text PRIMARY KEY,
      org_id       text NOT NULL,
      doc_type     text NOT NULL,
      text         text NOT NULL,
      page_numbers int[] NOT NULL,
      metadata     jsonb NOT NULL,
      embedding    vector(1536) NOT NULL
    );
""")
conn.execute("""
    CREATE INDEX IF NOT EXISTS chunks_embedding_idx
    ON chunks USING hnsw (embedding vector_cosine_ops)
""")
conn.execute("CREATE INDEX IF NOT EXISTS chunks_org_idx ON chunks(org_id)")

# 2. Parse + chunks.
result = await parsr.parse_invoice(
    document_url="https://files.example.com/invoice.pdf",
    include_chunks=True,
    chunking={"strategy": "block"},
)

# 3. Embed and insert.
texts = [c.text for c in result.chunks]
embeds = await openai.embeddings.create(model="text-embedding-3-small", input=texts)

with conn.cursor() as cur:
    for c, e in zip(result.chunks, embeds.data):
        cur.execute(
            """
            INSERT INTO chunks (id, org_id, doc_type, text, page_numbers, metadata, embedding)
            VALUES (%s, %s, %s, %s, %s, %s, %s)
            ON CONFLICT (id) DO UPDATE
              SET text = EXCLUDED.text,
                  embedding = EXCLUDED.embedding,
                  metadata = EXCLUDED.metadata
            """,
            (
                c.id, "org_acme", "invoice", c.text,
                c.page_numbers, c.metadata, e.embedding,
            ),
        )

# 4. Semantic query — composed with SQL metadata filter.
question = "What was the largest line item?"
qe = await openai.embeddings.create(
    model="text-embedding-3-small", input=[question]
)
rows = conn.execute(
    """
    SELECT text, page_numbers, 1 - (embedding <=> %s) AS similarity
    FROM chunks
    WHERE org_id = %s AND doc_type = 'invoice'
    ORDER BY embedding <=> %s
    LIMIT 3
    """,
    (qe.data[0].embedding, "org_acme", qe.data[0].embedding),
).fetchall()
for r in rows:
    print(r["similarity"], r["text"][:80], r["page_numbers"])

Cost

What you'll actually pay

pgvector itself is free — you pay only for the underlying Postgres. On Neon EU at 5GB / 1M vectors the cost is ~€20/mo. On a self-hosted CAX22 with persistent storage it's ~€8/mo. Add OpenAI embedding cost (~€0.02 per 1K chunks at text-embedding-3-small) and parsr's per-page rate. The total RAG-on-Postgres pipeline at 10K invoices/mo is typically under €60 — cheaper than every dedicated vector DB short of running them yourself.

Performance

Tuning tips

  • HNSW (hnsw vector_cosine_ops) over IVFFlat at >1M rows — recall is materially better at the same query cost
  • Add a partial index on (org_id) WHERE org_id IS NOT NULL for multi-tenant filtering
  • Use `EXPLAIN ANALYZE` to confirm the planner uses your HNSW index — common pitfall is forgetting to set ef_search higher than the LIMIT
  • Don't store embeddings on the same row as large text content — keep `chunks` narrow + an `chunk_text` table if your chunks are >2KB; tighter row size means more vectors fit per page

Three lines and you're calling parsr from pgvector (Postgres).

Start building