RAG over tabular-review documents via pgvector and OpenAI embeddings
nwhitehouse added end-to-end RAG on top of the tabular review: every uploaded document gets chunked and embedded via a new job type on the existing worker pool, chunks land in a `document_chunks` table with a pgvector HNSW index, and the TR chat retrieves top-K passages and injects them into the system prompt. Four commits over roughly an hour show what actually goes wrong when you run this on real PDFs.
The schema is in migration 007_document_chunks.sql: document_chunks(id, document_id, chunk_index, page_start, page_end, content, embedding vector(1536)) with a (document_id, chunk_index) unique constraint and an HNSW index using cosine ops. The kNN search is wrapped in a rag_search_chunks SQL function callable via supabase.rpc() because the JS client can't bind vector(1536) parameters inline. Migration 008_tabular_jobs_job_type.sql adds a job_type column to multiplex generate and embed jobs on the same worker pool - no new infrastructure, just a new dispatch case.
The chunker uses 800-token windows with 150-token overlap and tracks page boundaries from the ## Page N markers that the existing PDF extractor already emits. Embeddings use OpenAI text-embedding-3-small (1536 dims, ~$0.02 per 1M tokens). When OPENAI_API_KEY is unset, the upload path skips the embed job and TR chat falls back to cell-only context.
The follow-up commits document real production friction. U+0000 NUL bytes in pdfjs output caused Postgres to reject the entire document with "unsupported Unicode escape sequence" - the fix is a one-liner text.replace(/ /g, "") in the chunker, applied before both the chunker and page-marker scanner run. The backfill script (scripts/backfill-embeddings.ts) initially crashed on re-runs with unique-constraint violations from the (document_id, chunk_index) pair; the fix is wipe-then-insert, matching the worker's semantics, plus bumping the supabase-js row cap from the default 1000 to 100000. The fourth commit addresses two TR chat issues: cells completed in the gap between the last delta and the polling loop's terminal-status flip were silently dropped from the UI until manual refresh, and the system prompt's column-centric framing ("call read_table_cells before answering") caused the model to refuse questions whose answers were only in retrieved passages.
Spotted something wrong? Or know the PR text has fresher detail than the writeup above?