BidTender — multi-tenant SaaS
Citable RAG on documents that break pipelines
A Moroccan public tender file arrives as ten to thirty documents — PDF, Word, Excel, AutoCAD drawings — in French and Arabic, often scanned, up to 3,592 pages for a single contract. I built the system that reads them, with every extracted value tied back to the passage that produced it.
- Role
- Co-founder · AI and full-stack engineer
- Period
- Since September 2025
Stack
The problem
Before bidding, a company has to know whether it qualifies, which certification is required, what bid bond to post, how it will be scored, and which clauses will prove expensive. That reading takes hours, it is done by the most expensive people in the building, and most files end up discarded anyway.
Pointing an LLM at it is the obvious move. It is also where things break: a tender file is not a document, it is a contractual instrument. An approximate answer is not a slightly worse answer — it is a bid rejected for non-compliance, or a commitment made by mistake.
“I think the bid bond is 50,000 dirhams” is unusable. The same figure with the exact sentence from the specification that states it is usable, because a human verifies it in three seconds. The constraint is not answer quality. It is traceability.
Constraints
Two technical founders: anything one person cannot maintain does not get built.
Multi-tenant from day one, with competing companies on the same database. A cross-tenant leak is not a bug, it is the end of the product.
No control over input format: native PDFs, scans, Word documents, spreadsheets, AutoCAD drawings, and two languages — French and Arabic, including scanned Arabic script.
A wrong answer costs more than no answer. “That information is not in the file” had to be a first-class result.
What I built
Provenance carried end to end. Citations are not lost at generation, they are lost at chunking. Every extracted value keeps the verbatim passage that produced it: 274,376 citations across 58 distinct fields, 99.95 % of them with the source text. A field that cannot be sourced is flagged as uncertain rather than returned with confidence.
Clause-based chunking, not token windows. Splitting a contractual specification every 800 tokens produces chunks that start mid-article. On legal text that is destructive: the unit of meaning is the numbered article, and an obligation cut in half becomes a false obligation. Chunking follows the document hierarchy; the token window is only a safety net.
Hybrid pgvector + BM25 retrieval — out of necessity, not elegance. Procurement language is formulaic: two passages saying opposite things have near-identical embeddings. Conversely, what users actually search for — an article number, a lot reference, a date, an amount — are rare tokens, which is BM25’s territory. Neither approach alone reached a usable level. 151,661 chunks, 113,244 vectors, HNSW index.
Page-level OCR routing, bilingual. Pages with no usable text layer and pages in Arabic script go to a dedicated OCR engine; the rest do not, because blanket OCR is expensive and degrades a native PDF. On the documents that need it, every word keeps its bounding box and confidence score — 1.1 million words located on their page.
Tenant isolation inside retrieval, not after it. The naive implementation searches the whole index then filters by tenant: secure, and broken. If the global top results belong to other organisations, the user gets zero results on documents they own. Recall collapses as the corpus grows, and the bug never shows up in a single-customer demo.
Structured extraction, tables included. 58 fields per file, but also 132,492 documentary requirements, 29,096 lots, 12,521 award criteria, and 228,771 price-schedule rows — the hardest part, because in a pricing grid the table structure *is* the information.
Asynchronous processing with resume. The real engineering is not the queue: it is that a document failing at page 180 restarts there instead of paying for 180 pages of OCR again.
Results
- 45,000 files processed in production, 40,800 distinct documents, 56 GB — PDF, Word, Excel, AutoCAD drawings
- Over 250,000 pages counted · median file 58 pages · largest encountered 3,592 pages across 32 documents
- 274,376 provenance citations across 58 fields, 99.95 % carrying the verbatim excerpt
- 14 % of indexed documents required OCR — 1.1 million words kept with position and confidence score
- 228,771 price-schedule rows extracted across 2,950 files
- Vector search in 1 ms server-side over 113,000 vectors, HNSW index
- A complete tender file analysed end to end in a median 2 min 11 s, for 0.22 USD
- 4 active client organisations, 3 of them on a paid plan
- Triaging a tender file: from 4 h down to 40 min — and the number of files actually reviewed went from 10 to 30 a month at constant headcount (two client interviews)
- Extraction accuracy measured against a hand-annotated reference set — 30 stratified tender files × 8 critical fields, 240 cells: 90.4% overall, 86.2% on scanned files, and 75% on the subset where the source document could be re-read, which is where every error sits. Deterministic sample, reproducible protocol and scoring code.
What I’d do differently
Build the evaluation set before the pipeline. For months, “is this better?” was judged by eye on a handful of familiar queries. The reference set exists today — 30 files stratified across native, scanned and hundred-page-plus, drawn deterministically so anyone can replay exactly the same sample. Two days of work that would have made every decision measurable instead of arguable. I’d do it first.
I underestimated tables. Flowing text settles quickly; price schedules, where the grid structure is the information, needed an entirely separate processing chain. On an unfamiliar corpus my first question is now: how much of your information lives in tables? The answer doubles or halves the estimate.
Silence should have been a product goal from the start. A system that always answers is a system that sometimes invents — the only truly serious failure mode on a contractual document. Making “not in this file” a correct, deliberate answer is as much interface work as model work, and I got to it too late.