title: Lexora
emoji: π
colorFrom: indigo
colorTo: gray
sdk: docker
app_port: 7860
pinned: false
short_description: 'UAE labour & Dubai tenancy law: cited answers, or refusal'
Lexora
Ask a document a question. Get the exact clause β or an honest "not in here."
βΈ Live demo β uselexora.vercel.app
Bring UAE labour and Dubai tenancy law β indexed, pinned by SHA-256 β or bring a contract, a lease, a scanned page or a link of your own. Every claim carries the clause it came from, every citation opens the real text, and when the source does not cover the question Lexora says so and shows the passages it rejected with the scores that rejected them.
The colour is the score. Cyan is a verified citation, violet is evidence, amber is a near miss, rose is a citation that did not resolve. The glowing line across the hero is the refusal threshold: passages above it can be answered from, passages below it cannot. Ask "What is the capital gains tax rate in Singapore?" to watch nothing cross it.
![]() Bring your own document β PDF, Word, a photo of a page, or a link. Held in memory for the session, never written to disk. |
![]() Light and dark are both designed, not inverted β the spectrum darkens so every hue still passes contrast on paper. |
The API is a separate deployment on Hugging Face Spaces β
/api/health Β·
/api/laws β because the UI is static and the
API needs a container with 1 GB and two ONNX sessions. The image honours $PORT, so the
the image is not tied to this host.
The API sleeps after 48 hours idle and takes ~30 s to wake; the first question of the day is slow and the rest are not.
Retrieval quality is measured against a 61-question hand-labelled set, not asserted.
hit-rate@5 0.913 β 0.935 reranking on/off, same index, same questions
hit-rate@1 0.630 β 0.739
MRR 0.740 β 0.811
refusal 0.333 β 0.800 with zero false refusals on 46 answerable questions
latency p50 618 ms Β· p95 765 ms end to end, CPU only, 30 queries warm
The full method, every threshold, and the things that did not work are in AUDIT.md.
Bring your own document
Drop a contract, a policy, a lease, a photo of a page, or paste a link. The same retrieval, reranking, refusal gate and citation verification run over it β only extraction and storage differ.
| Reads | PDF (text layer and scanned), DOCX, images, HTML, plain text, or a URL |
| Scanned pages | OCR β Claude vision when a key is present, Tesseract otherwise |
| Coverage | 100% of every word reaches the index, asserted per document and in CI |
| Storage | In memory for the session. Never written to disk, dropped when the tab closes |
| Accuracy | Measured on one document β see below. Still not calibrated |
Measured over the Dubai tenancy law uploaded through that path, against 12 hand-labelled
questions (make eval-workspace): hit-rate@5 1.000, hit-rate@1 0.750, MRR 0.875,
refusal accuracy 0.750, zero false refusals. Retrieval scores higher than the corpus
does, which is a smaller haystack rather than an achievement β 13 chunks against 181. The
number worth reading is refusal accuracy: 0.75 against the corpus's 0.80, because the
workspace runs a deliberately permissive floor. One question in four that the document
cannot answer gets answered anyway, bought in exchange for never wrongly refusing one it
can. AUDIT.md Β§6.7 argues why that trade is right here and wrong as a permanent setting.
Uploaded documents do not inherit the corpus's numbers, and the API says so. Every
workspace response carries calibrated: false. The refusal floor was fitted against 61
labelled questions about this corpus; a document uploaded a minute ago has no labelled
set and no fitted threshold, so the workspace runs a deliberately permissive floor and the
interface states that the accuracy figures were measured against the law library rather
than against your file. Borrowing the corpus's credibility for an un-evaluated document
would be exactly the overclaim this project exists to avoid.
curl -X POST https://Abdr007-lexora.hf.space/api/workspace/upload -F file=@contract.pdf
# β 201 with a session id on X-Lexora-Session, then ask with {"scope": "workspace"}
The two pipelines
flowchart TB
subgraph A["Pipeline A β indexing (offline, make index)"]
direction TB
PDF["4 official government PDFs<br/>SHA-256 pinned"]
--> PARSE["parse.py<br/>column-aware Β· ligature repair<br/>contents cross-check"]
--> CHUNK["chunk.py<br/>article-aware Β· 600 tok / 80 overlap<br/>capped at the 512-token encoder window"]
--> EMB["FastEmbed bge-small-en-v1.5<br/>local ONNX Β· 384-dim"]
CHUNK --> BM["rank_bm25<br/>sparse index, unstemmed"]
EMB --> QD[("Qdrant<br/>embedded or Cloud")]
BM --> DISK[("bm25.json<br/>chunks.jsonl<br/>vectors.json")]
end
subgraph B["Pipeline B β query (online, per question)"]
direction TB
Q(["question"]) --> GATE["guard/gate.py<br/>Haiku 4.5 rewrite + injection screen"]
GATE -->|blocked| BLK["blocked"]
GATE --> RET["retrieve.py<br/>dense top-20 β₯ BM25 top-20<br/>RRF k=60 β top-20"]
RET --> RR["rerank.py<br/>cross-encoder β top-5"]
RR --> G{"refusal gate<br/>scope Β· relevance floor"}
G -->|not covered| REF["refusal<br/>+ near misses + scores"]
G -->|covered| GEN["generate.py<br/>Sonnet 4.6 Β· temp 0 Β· context-only<br/>SSE token stream"]
GEN --> VER["verify.py<br/>every cited article must be<br/>in the retrieved set"]
VER --> OUT(["answer + verified citations"])
end
DISK -.-> RET
QD -.-> RET
The phrase to remember: hybrid retrieval because legal queries mix exact terms ("Article 30", "gratuity") that vectors miss with paraphrases ("end-of-service money") that keywords miss; RRF fuses both, a local cross-encoder reranks, and generation is grounded with verified citations.
Corpus
Four official English-language publications, fetched by corpus/download.py and pinned
by SHA-256. Nothing is generated, paraphrased or synthesised.
| Instrument | Publisher | Articles |
|---|---|---|
| Federal Decree-Law 33/2021 (Employment Relationships), as amended | MOHRE | 74 |
| Cabinet Resolution 1/2022 (implementing regulation) | MOHRE | 39 |
| Law 26/2007 β Landlords and Tenants, Dubai | Government of Dubai | 37 |
| Law 33/2008 β amending Law 26/2007 | Government of Dubai | 13 |
| Decree 43/2013 β Rent Increase, Dubai | Government of Dubai | 4 |
167 articles β 181 chunks. The PDFs are gitignored; only the manifest of URLs and digests is committed, so the repo neither redistributes the documents nor trusts a silently-changed one.
Fallback note.
www.mohre.gov.aeis unreachable from some networks anduaelegislation.gov.aesits behind a bot challenge.sources.jsontherefore carries a Wayback Machinemirror_urlfor the affected file β a snapshot of that exact government PDF. The mirror is only tried after the official URL fails, the digest is enforced either way, andmanifest.jsonrecords which URL actually served the bytes.
Running it
Requires Python 3.12 (via uv), Node 20+, and about 1 GB of disk for the ONNX models.
make setup # both toolchains
make corpus # download + verify the official PDFs
make index # parse β chunk β embed β Qdrant + BM25
make dev # API on :7862, web on :3020
Open http://127.0.0.1:3020. Ports are deliberately not 8000/3000 β those are usually already taken, and quietly binding a neighbouring project's port is a confusing failure.
It works with no API key
With no ANTHROPIC_API_KEY, Lexora runs in offline-extractive mode: retrieval,
fusion, reranking, the refusal gate and citation verification all run and are fully
measurable; answers are quoted verbatim from the corpus instead of being written. Every
response is labelled offline-extractive through the API and in the UI, so a number
measured in that mode can never be mistaken for a Claude number.
Adding the key upgrades two stages β the query gate and generation. It does not unlock any.
| Command | What it does |
|---|---|
make check |
every quality gate, exactly as CI runs them |
make eval |
retrieval metrics, with and without reranking |
make eval-judge |
adds Claude-judged faithfulness and answer relevance |
make calibrate |
recompute the refusal floor from the labelled set |
make chunking |
re-index at 300/600/1000 tokens and compare |
make stop |
stop both services (leaves other projects alone) |
How it is actually deployed
Not hypothetically. These are the two hosts serving the links at the top of this file.
| Host | Why | |
|---|---|---|
| API | Hugging Face Docker Space | Needs a container with 1 GB and two ONNX sessions. Requires a PRO subscription ($9/mo), which covers every Docker Space on the account |
| Web | Vercel | Static Next.js export; free tier, and the build is where the CSP is fixed |
# API β pushes this repo to the Space, which builds the Dockerfile
python scripts/deploy_space.py --space lexora --public
# Web β set the variable BEFORE the first build, see below
cd apps/web
printf 'https://YOUR_USER-lexora.hf.space' | vercel env add NEXT_PUBLIC_API_URL production
vercel --prod --yes
Then allow the UI's origin on the API β Space β Settings β Variables β
LEXORA_CORS_ALLOW_ORIGINS=https://uselexora.vercel.app. Full walkthrough, including
what each host does and does not enforce, in
docs/DEPLOY.md.
NEXT_PUBLIC_API_URL must exist before the first build. next.config.ts derives the
CSP's connect-src from it at build time. Deploy without it and the shipped policy pins
connect-src to localhost, so the browser blocks every call β which looks exactly like an
API outage and leaves nothing in the API logs, because no request ever leaves the page.
Verify the whole stack, and warm it, in one command:
make verify-hosted # 8 checks: UI, CSP, health, CORS, both refusals, citations, upload
Total cost: $9/month for the Space, $0 for Vercel. Embeddings and reranking run locally on CPU; Claude is the only metered API, at roughly $0.002β0.01 per query.
The host had to have 1 GB, and that decided it
Measured, not estimated. In a container the service peaks at 524 MB β onnxruntime's allocators and the two model sessions do not share the host's page cache, so host RSS (155 MB) badly understates it. At a 512 MB cap it is OOM-killed, and thread-count, arena and malloc-trim tuning do not close the gap (AUDIT.md Β§6.4).
That ruled out every 512 MB free tier, Render's included, and is why this runs on a paid Space rather than a free host.
Qdrant and Langfuse
Both optional. Unset, Qdrant runs embedded on disk with no account and no credentials.
Set LEXORA_QDRANT_URL + LEXORA_QDRANT_API_KEY to use the Cloud free tier (1 GB β this
corpus needs a few megabytes). Langfuse traces every model call when its keys are set,
and is a no-op when they are not; a tracing failure can never fail a request.
Layout
lexora/
apps/api/ FastAPI service (Python 3.12)
app/rag/ parse Β· chunk Β· index Β· retrieve Β· rerank Β· generate Β· verify
app/guard/ query rewrite + prompt-injection screening
app/core/ settings Β· models Β· claude Β· embedding Β· vectorstore Β· observability
app/workspace/ extract Β· chunk Β· store Β· retrieve, for uploaded documents
tests/ 227 tests, run against the real corpus and index
apps/web/ Next.js 15 Β· Tailwind 4 Β· Framer Motion
corpus/ download.py + provenance manifest (PDFs gitignored)
eval/ questions.jsonl Β· ragas_run.py Β· workspace_run.py Β· chunking
scripts/ deploy Β· bake Β· hosted verification Β· demo pre-flight
docs/screens/ README screenshots (excluded from the image)
Dockerfile API image β honours $PORT, so it is not host-specific
docs/DEPLOY.md how the Space and Vercel are actually deployed
AUDIT.md every check, every measurement, every accepted residual
What is worth reading in the code
app/rag/parse.pyβ the corpus fights back. A two-column layout, a font whoseThligature maps to a bareT, a definitions table that inverts on sub-pixel coordinates, and one article whose heading the government simply forgot to print. Each defect was found by measurement and is fixed with evidence, not a guess.app/rag/rerank.pyβ bi-encoder versus cross-encoder, and the refusal gate that rides on the cross-encoder's scores.app/rag/scope.pyβ the finding that topical relevance is not legal applicability, and why no similarity score can close that gap.app/rag/verify.pyβ why a fabricated citation is flagged rather than deleted.AUDIT.mdβ including the two approaches that were measured and rejected.


