Skip to content
← All work

RAG assistant for public tender documents on PostgreSQL + pgvector

Tenderlens

A retrieval-augmented assistant for tender documents: structure-aware chunking, hybrid search in one Postgres query, cited answers, incremental re-embedding and Row-Level Security, with a video walkthrough.

My role: Design and full-stack development: ingestion, retrieval, answer generation, security and UI

▶ Watch the walkthrough (5:23)
  • RAG
  • pgvector
  • FastAPI
  • Next.js
  • Hybrid search

Overview

Tenderlens is a working RAG pipeline built on real UK public tender notices to show each stage end to end: ingestion, chunking, storing vectors and metadata in Postgres, hybrid retrieval, grounded answers, and keeping the index current when documents change. No fine-tuning is used: facts come from the documents, so updating a document is enough. The walkthrough video shows the app and the code behind each step.

The challenge

Public tender packs are long, structured documents full of reference codes, deadlines and conditions. Bid teams need precise answers with the exact source, and they need the system to stay correct when a notice is amended. Generic chatbots split documents arbitrarily, miss exact codes that vector search alone cannot match, and answer confidently even when the information is not there.

Tenderlens was built to show a client, step by step, how a dependable RAG system handles this: from extraction and chunking through storage and hybrid retrieval to grounded answers and version updates, all on a standard PostgreSQL database.

How it works

  1. 1Upload PDF / DOCX
  2. 2Extract + OCR fallback
  3. 3Section-based chunks
  4. 4Embed → pgvector (one row)
  5. 5Hybrid search + RRF
  6. 6Cited answer

Architecture

Documents are uploaded through a Next.js interface and processed by an ARQ worker on Redis. PyMuPDF extracts text, with Tesseract OCR at 300 dpi for scanned pages in English and Italian. Headings are detected from relative font size and boldness to build a section tree, and the language of each document is detected so full-text search uses the right configuration. The chunker emits one chunk per section up to 800 tokens, merging very short sections and splitting long ones only at paragraph or sentence boundaries, and embeds each chunk together with its section path.

Every chunk is one row in PostgreSQL 16: text, tender, document type, date, version, organisation, the pgvector embedding and a tsvector, indexed with HNSW for cosine similarity, GIN for full text, trigram for reference codes and B-tree for filters. A single SQL query runs vector search, keyword search and an exact-code match, then fuses them with Reciprocal Rank Fusion (k = 60). Answers come from OpenAI, Claude or Ollama behind one provider interface, returned as JSON with citations and a confidence level; a validator rejects any citation that does not point to a retrieved passage.

Each chunk carries a content hash, so a new document version reuses embeddings for unchanged chunks and only re-embeds what changed, while old chunks are kept as history. Access keys map to organisations; every request sets a restricted Postgres role and organisation ID for the transaction so Row-Level Security hides other organisations' rows, and each organisation's model API keys are encrypted with pgcrypto. The API runs on Ubuntu on Oracle Cloud with Docker Compose, and the frontend on Vercel.

What I built

Ingestion pipeline

An ARQ background worker on Redis extracts text with PyMuPDF, falls back to Tesseract OCR for scanned pages (English and Italian), builds a section tree from font size and weight, and detects each document's language for full-text search.

Structure-based chunking

One chunk per section up to 800 tokens; short sections merge with siblings and long ones split only at paragraph or sentence boundaries. Each chunk keeps its section path, which is embedded with the text.

One Postgres table

Text, metadata, embedding and tsvector live in a single row with an HNSW cosine index, a GIN full-text index, a trigram index for reference codes and B-tree filter indexes.

Hybrid search in one query

Vector search, full-text search and an exact reference-code boost merged with Reciprocal Rank Fusion (k = 60), with all metadata filters inside the same SQL; a diagnostic page shows each score.

Grounded, cited answers

GPT, Claude or Ollama models answer only from retrieved passages as JSON with citations and a confidence level; every citation is checked against the passages, and the system says “not found” rather than guessing.

Versioning and tenant isolation

Content hashes mean only changed chunks are re-embedded on a new version; organisations are isolated by query filters and Postgres Row-Level Security, with API keys stored encrypted using pgcrypto.

Engineering decisions

  • Chunk by the document's own structure, so a retrieved passage is a whole clause or section.
  • Keep vectors, text and metadata in one Postgres table, so filters, full-text and vector search run in a single query.
  • Validate every citation and prefer “not found” over a confident guess.
  • Re-embed only changed chunks when a document is updated, keeping old versions as history.

Results

  • End-to-end RAG pipeline demonstrated on real UK public tender notices (see the video).
  • Exact reference codes found reliably alongside semantic matches through hybrid search.
  • Every answer cited and validated, with “not found” instead of guesses.
  • Document updates re-embed only changed chunks; organisations isolated with Row-Level Security.

Stack

Frontend
Next.js 14 (App Router), React 18, TypeScript, Tailwind CSS
Backend
Python, FastAPI, SQLAlchemy (async), Alembic, ARQ worker on Redis
Database & search
PostgreSQL 16, pgvector (HNSW), tsvector / GIN, pg_trgm, pgcrypto, Row-Level Security
Documents
PyMuPDF, Tesseract OCR (English + Italian), tiktoken, lingua language detection
AI
OpenAI (text-embedding-3-small, GPT-4o-mini), Claude, Ollama (bge-m3, gpt-oss, Llama 3.1, Qwen 2.5)
Infrastructure
Docker Compose, Ubuntu on Oracle Cloud (API), Vercel (frontend)

Related services

Need something like this?

I can build a version of this for your product, your data and your stack.

Start a project