Skip to content

Build a domain RAG (legal, medical)

CiteNexus is built for domains where a wrong answer is worse than no answer — legal, medical, finance, compliance. The recipe is the same across them: cite or abstain, and prove it on a golden set that rewards abstention.

  1. Curate authoritative sources. Ingest the documents you’d actually cite — a statute, a clinical guideline, a policy PDF — not the open web. Idempotent ingest (bulk) means you can re-run as the corpus grows.

    for path in guideline_pdfs:
    rag.ingest(str(path), document_id=path.stem)
  2. Keep strict mode. It’s the default, and it’s the guarantee — the answer is generated only from retrieved passages and every claim is verified against its citation (what that check actually proves). For a regulated surface, do not drop to normal/exploratory.

    response = rag.ask("What is the maximum pediatric dose of drug X?") # strict
    if response.evidence.decision.value == "answered":
    src = response.sources[0]
    print(response.answer, "", src.document, "p.", src.page) # cite the exact passage
    else:
    print("abstained:", response.answer) # no dosage invented
  3. Inject your models. Any OpenAI-compatible embedding + LLM (+ reranker, + vision for figures). Keep it local (Ollama) for data-residency, or use a hosted endpoint — see custom endpoints & auth.

  4. Prove it with a golden set — and assert the refusals separately. evaluate() scores the answerable rows: put the text you expect quoted in the expected column and read groundedness_rate, citation_rate and expected_support_rate.

    question,expected
    "What notice must a CA landlord give a >1yr month-to-month tenant?","60"

    Must-refuse rows do not belong in that CSV. A blank expected is scored as supported only when the row was answered, so a correct refusal lowers expected_support_rate rather than raising it. Assert abstention directly instead:

    from citenexus.answer.result import Decision
    must_refuse = ["What is the maximum security deposit under this statute?"]
    for q in must_refuse:
    assert rag.ask(q).evidence.decision is Decision.refused, q

The order those guards run in matters for a regulated surface — grounding first, then the authority floor, then the per-claim gate, and no stage can promote what an earlier one rejected. How it works lays the pipeline out stage by stage.

The law worked example is exactly this recipe on California landlord–tenant law, run live against Jina + Gemini: 100% groundedness and citation, zero fabricated answers across an 11-question golden set — versus the 17–33% hallucination rate reported for generic legal RAG (Stanford RegLab, 2024, cited as context). It also shows the honest failure mode to watch for.

That is a single live run against a generator that is not reproducible at temperature 0 — an identical re-run moved the rate metrics by a question. The safety metrics (100% groundedness, 100% citation, 0 out-of-jurisdiction citations) held across both. Treat the rates as illustration and the safety numbers as the result.

The same shape applies: ingest the guideline / formulary / label, keep strict mode so a dose or contraindication is only ever quoted from the source (never synthesized), cite the exact page, and keep a separate list of “not in the guideline” questions asserted against Decision.refused — abstention has to be measured outside evaluate(). In any supported script, answers come back in the clinician’s language while the citation stays verbatim in its own language, so an English answer can carry a non-English quote — see languages.