legal-rag-router
Deterministic citation routing and epistemic abstention for legal RAG.
legal-rag-router sits in front of any vector search. It reads the legal citation in a user's
query, checks it against an index of the statute book, and returns exactly one decision: the
coordinate to retrieve from, a question for the user, or a refusal because the cited law does
not exist. It uses no model and no network, reads no disk per query, and gives the same answer
to the same input every time.
"What is the cap in section 124 of the Employment Rights Act 1996?"
→ ROUTE_BOUNDED uk/ukpga/1996/18/s124 retrieve only from here
"section 3 of the Digital Privacy Rights Act 2021"
→ EPISTEMIC_ABSTENTION_INSTRUMENT_NOT_FOUND refuse: no such Act
Built by Abdullah Memon at Memon Systems Ltd. Need it in your stack? See Consultancy.
Contents
- Why · What it covers · Install · Quick start
- Using it as a library: statuses · retrieval · discovery · follow-ups · scope · logging · errors
- Evidence · Documentation · Development · Roadmap · Consultancy · Citing · Licence
Why
A RAG system asked about "section 124 of the Employment Rights Act 1996" should retrieve from section 124, not from whatever text embeds closest to the question. Asked about an Act that does not exist, it should say so, not answer from the nearest real one. Vector search can do neither: it always returns something.
The router decides first, from the citation, and the rest of the pipeline obeys:
- A real citation binds to its coordinate, and retrieval is filtered to it.
- An ambiguous one ("the Companies Act") becomes a question, never a silent guess.
- Invented law is refused, with the index date it was checked against.
- A query that cites nothing is handed to discovery, which offers provisions for the user to confirm. Nothing reaches generation except through a bound coordinate.
What it covers
| Jurisdiction | United Kingdom. Spain (BOE) is next (roadmap) |
| Bound to provision level | UK Public General Acts (ukpga, including regnal Acts before 1963) and UK Statutory Instruments (uksi): 134,219 instruments |
| Recognised, reported out of coverage | Scottish, Welsh and Northern Ireland legislation, local Acts, Church Measures, older series (244,564 titles), and EU or retained EU law |
| Index snapshot | 28 September 2026. "Not found" always means "not in the statute book as of that date", and every refusal says so |
| Citation forms | Full and short titles, abbreviations (ERA 1996, PACE), chapter numbers (1996 c. 18), SI numbers (SI 2010/1904), pinpoints down to paragraph, lists, ranges, exclusions, typos. Every form is in docs/grammar.md |
The index holds coordinates, titles, numbers and lookup tables. It holds no provision text: your retrieval store keeps the text.
Install
pip install legal-rag-router # or: uv add legal-rag-router
Python 3.11 or later. The package has no runtime dependencies.
The wheel ships code only. The index (and, optionally, the concept index used for discovery) is
a separate download, attached to each GitHub release
with a SHA256SUMS file. Fetch, verify and unpack it with the GitHub CLI:
gh release download v0.1.0 -R azterizm/legal-rag-router -p 'index-*.tar.gz' -p 'SHA256SUMS' -D dist
(cd dist && sha256sum -c --ignore-missing SHA256SUMS) && mkdir -p data/index && tar -xzf dist/index-*.tar.gz -C data/index
On macOS use shasum -a 256 -c in place of sha256sum -c. For discovery, fetch
concepts-*.tar.gz the same way into data/concepts. Sizes unpacked: about 360 MB for the
index and 550 MB for the concept index.
The router checks every file against the SHA-256 hashes in the index manifest when it loads, so a damaged or altered index fails at load time, never at query time.
Quick start
from legal_rag_router import Router, RouteStatus, partition_filter
router = Router.from_path("data/index") # loads and verifies in well under a second
result = router.route("What is the cap in section 124 of the Employment Rights Act 1996?")
result.status # RouteStatus.BOUNDED
result.coordinates # (Coordinate('uk/ukpga/1996/18/s124'),)
partition_filter(result)
# 'instrument_id in ["uk_ukpga_1996_18"] and ((coordinate == "uk/ukpga/1996/18/s124"
# or coordinate like "uk/ukpga/1996/18/s124/%"))'
Load the router once and share it: it is immutable and thread-safe.
Using it as a library
The six statuses
Every call returns one RouteResult. Its status says what the router found, and its
next_action says what your pipeline must do next. The outputs below are real, from the
28 September 2026 index.
| Query | status |
next_action |
What you get |
|---|---|---|---|
s. 124 ERA 1996 |
ROUTE_BOUNDED |
RETRIEVE_BOUNDED |
coordinates: uk/ukpga/1996/18/s124 |
section 1 of the Companies Act |
ROUTE_AMBIGUOUS |
ASK_USER |
candidates (Companies Act 1976, 1985, 1989, 2006 …) and a ready-made clarification question |
section 3 of the Digital Privacy Rights Act 2021 |
EPISTEMIC_ABSTENTION_INSTRUMENT_NOT_FOUND |
REFUSE |
messages: "No instrument “Digital Privacy Rights Act 2021” is in the UK statute book as of the index snapshot 2026-09-28." |
section 999 of the Employment Rights Act 1996 |
EPISTEMIC_ABSTENTION_PROVISION_NOT_FOUND |
REFUSE |
messages: "Employment Rights Act 1996 has no s.999 (index snapshot 2026-09-28)." |
Article 6 of the GDPR |
ROUTE_OUT_OF_COVERAGE |
DECLARE_OUT_OF_COVERAGE |
messages: "“GDPR” is EU or retained EU law: outside the indexed corpus." |
unfair dismissal compensatory award statutory cap |
ROUTE_UNRESOLVED |
DISCOVER_THEN_BIND |
No citation found: see discovery |
A typical integration handles next_action, which is a closed set:
from legal_rag_router import NextAction, Router, partition_filter
router = Router.from_path("data/index", concepts="data/concepts")
def answer(query: str) -> str:
result = router.route(query)
match result.next_action:
case NextAction.RETRIEVE_BOUNDED:
chunks = vector_store.search(query, filter=partition_filter(result))
return generate(query, chunks) # your retrieval and LLM
case NextAction.ASK_USER:
return result.clarification # offer result.candidates
case NextAction.REFUSE | NextAction.DECLARE_OUT_OF_COVERAGE:
return " ".join(result.messages) # never retry with open retrieval
case NextAction.DISCOVER_THEN_BIND:
found = router.discover(query)
return offer_for_confirmation(found.candidates)
case NextAction.VERIFY_LIVE:
return ask_official_registry(result) # not returned in 0.1.0 (strict mode)
vector_store, generate, offer_for_confirmation and ask_official_registry are yours. The
rules each branch must keep ("never widen the filter", "never pick a candidate silently",
"never answer from other law") are in the downstream contract.
Other fields worth knowing (all on RouteResult):
| Field | Use |
|---|---|
corrections |
Typos the router fixed when it bound: section 12 of the Employmnet Rights Act 1996 binds s12 with (('employmnet', 'employment'),) |
suggestions |
Close real titles offered with a refusal |
excluded |
Provisions the query excludes: the Companies Act 2006 except section 172 binds the Act and excludes s172 |
citations |
Every citation found, with its character span and how it resolved |
repealed |
The bound instrument is repealed. It is still bound, never refused |
temporal_hint |
Text such as "as it stood in 2012", passed through for a point-in-time layer |
index_snapshot, latency_ns, reason |
The index date, the time taken, and a machine-readable detail for non-bound outcomes |
Retrieval: the partition filter
partition_filter(result) turns a bound result into a boolean filter over two fields every
stored chunk must carry: instrument_id (uk_ukpga_1996_18) and coordinate
(uk/ukpga/1996/18/s124/1). It includes every sub-provision of what was bound, removes anything
excluded, and raises FilterError for any status other than ROUTE_BOUNDED, so there is never
an unbounded filter to fall back to. Values are validated against the coordinate grammar before
they are interpolated, so nothing from the query can inject into the expression.
The expression is written in Milvus's boolean-expression syntax (in [...], ==, like,
and/or/not). For other stores, build the same filter from result.coordinates and
result.excluded.
Queries that cite nothing: discover, then bind
Most research questions cite no statute. They route ROUTE_UNRESOLVED, and Router.discover
offers candidate provisions from the concept index for the user to confirm. The confirmed
coordinate is then routed like any citation:
router = Router.from_path("data/index", concepts="data/concepts")
found = router.discover("unfair dismissal compensatory award statutory cap", limit=3)
[c.label for c in found.candidates]
# ['Employment Rights Act 1996, s. 124: Limit of compensatory award etc.',
# 'Enterprise and Regulatory Reform Act 2013, s. 15: Power by order to increase or decrease limit of compensatory award',
# 'Employment Rights Act 1996, s. 227: Maximum amount.']
router.route(str(found.candidates[0].coordinate)) # only after the user confirms it
Discovery never returns text and never binds. On the sealed UK test set it puts the right provision in the top 10 for 88 % of queries (report).
Follow-ups: context
Pass the previous turn's bound coordinates, and a bare provision binds against them:
router.route("what about section 125?", context=["uk/ukpga/1996/18/s124"])
# ROUTE_BOUNDED uk/ukpga/1996/18/s125, source="context"
Context coordinates are re-validated against the index, and an instrument named in the query always beats context.
Scope: jurisdictions
router.route(query, jurisdictions=["uk"]) limits routing to the listed jurisdictions. A scope
that excludes every indexed jurisdiction returns ROUTE_UNRESOLVED with
reason="no_jurisdiction_in_scope".
Logging and privacy
The router never logs query text by default. With Router(index, log_misses=True) (or
Router.from_path(path, log_misses=True)), a query that looks like a citation but routes
ROUTE_UNRESOLVED emits one event on the legal_rag_router.misses logger, carrying only a hash
and the length. Pass redact= a function to log a redacted form of the text instead. Nothing
is sent anywhere: the package makes no network calls.
Errors and failure behaviour
routeanddiscovernever raise for any input. Non-strings, queries over 4 KB and queries too complex to read safely returnROUTE_UNRESOLVEDwith areason.- An internal error is logged and fails safe to
ROUTE_UNRESOLVED. It never falls back to a guess. - Loading raises
IndexLoadError(orConceptIndexError) when a file doesn't match its hash or the format version is unknown.
The public API is everything in legal_rag_router.__all__; it is typed (py.typed) and
documented in docstrings.
Evidence
Every figure below comes from a sealed run: the test batteries were hashed and tagged before
the router was run on them once, and the results were hashed after. The seals are in
seals/.
Accuracy (2,112 labelled queries, 1,030 of them real citations taken from legislation and held out from development; report):
| Measure | Result |
|---|---|
| Bound to the wrong instrument | 0 / 1,897 real citations (v1, blind: 2 / 1,897) |
| Invented law bound | 0 / 68 (all 68 refused) |
| Same section number in different Acts, bound to the wrong Act | 0 / 40 |
| Real law refused | 82 / 1,897 (4.3 %) |
| Rows with the expected outcome | 1,916 / 2,112 (v1, blind: 1,903 / 2,112) |
Speed (306,336 calls per pass, 3 passes; measurements):
| Machine | p50 | p99 |
|---|---|---|
| Apple M4 | 0.14 ms | 2.10 ms |
| Intel i5-3570 (2012) | 0.50 ms | 9.21 ms |
The design target was a p99 under 2 ms. It is not met on either machine, and the report says why.
Against LLMs (report): on the same 2,112 queries, Gemini 3.8 Flash (high) routing on its own bound the wrong instrument for 102 / 1,897 real citations, changed its answer between repeats on 34 / 200 rows, and took 13.5 s at p50. Used as a citation parser in front of the router's index, it was the most accurate pipeline measured (1,972 / 2,112) with no wrong or invented bindings. The report also covers Jev and Laya, cost per query, and what leaves your network.
Documentation
| Document | What it covers |
|---|---|
| Downstream contract | What each status obliges the caller to do; the filter; discovery; context; failure behaviour |
| Grammar | The coordinate format and every citation form the router reads, with test IDs |
| Data sources | Where the index comes from, its licence, and what is known to be missing |
| All documentation | The full map: design, methodology, measurements and reports |
Development
git clone https://github.com/azterizm/legal-rag-router && cd legal-rag-router
uv sync
uv run ruff check && uv run ruff format --check && uv run mypy && uv run pytest
The tests need no index download: they run on a small real-data fixture index committed under
tests/fixtures/. Tests marked full_data run only where the full index is present. See CONTRIBUTING.
Roadmap
0.1.0 is the first release: the United Kingdom, routing and discovery. Planned:
- Spain (BOE):
es/boe/{year}/{number}/{provision…}, e.g.es/boe/1885/6627/art42/1/b. - More domains through grammar plugins that share the index, the abstention gate and the
typo tiers:
eu/{reg|dir|dec|judgment}/{year}/{number},us/usc/{title}/{section},us/cfr/{title}/{part}/{section},contract/edgar/{cik}/{accession}/{exhibit}. - Live checks against official registries (the
VERIFY_LIVEaction), for law newer than the index snapshot.
The build record, with every decision, is docs/ROADMAP.md.
Consultancy
legal-rag-router is built and maintained by Abdullah Memon at
Memon Systems Ltd, which designs and audits retrieval systems for
legal and other regulated domains. If you want help putting it into production, adding a
jurisdiction or document type, or testing whether your own legal RAG binds, refuses and isolates
as it should, see engagements or write to
abdullah@memonsystems.com.
Bug reports and questions about the library are welcome as GitHub issues. Security reports go by email: see SECURITY.md.
Citing
If you use the router or its benchmark in research, please cite it. GitHub's "Cite this
repository" button gives the reference from
CITATION.cff.
Licence and attribution
Code: AGPL-3.0-only, copyright © 2026 Memon Systems Ltd. If you run a modified version as a network service, the AGPL requires you to offer its source to that service's users.
Index data is derived from legislation.gov.uk and contains public sector information licensed
under the Open Government Licence v3.0.
Source: legislation.gov.uk, © Crown and database right. The full attribution is in
NOTICE, shipped with every index
release.
legal-rag-router is a retrieval component, not legal advice.
Metadata
Release files for legal-rag-router 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| legal_rag_router-0.1.0.tar.gz | 884.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| legal_rag_router-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 977.1 kB
Release files / legal_rag_router-0.1.0.tar.gz
| Download URL | legal_rag_router-0.1.0.tar.gz |
|---|---|
| Size | 884.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
21a388ef19a96e5a52d7fdc89832185f235ed15644a258ab2dc0df82dd339cb6
|
|
BLAKE2b-256 checksum How to use checksums |
0ad14540fb088da39e1c78a9ebb05e982eff06d0b8078cf1d2f8d6439ff8db3a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency logRelease files / legal_rag_router-0.1.0-py3-none-any.whl
| Download URL | legal_rag_router-0.1.0-py3-none-any.whl |
|---|---|
| Size | 92.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
616378556009ec4cf155b205d04a23c9417131850e0968dfb1e5e68546a0777d
|
|
BLAKE2b-256 checksum How to use checksums |
f8164f74a768d0acc586202a4287e36f8b738003685742992503069a46ff783c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency log