Themis β Search-Relevance Handoff
Owner handoff for whoever drives Themis search quality next. Covers: what the product is, exactly how search works today (fast + deep), step by step, the known weaknesses, and β most importantly β the autoresearch methodology, the eval rig, the compute setup, and the experiment discipline so you can run the proposeβmeasureβcommit loop yourself from day one. Read Β§4βΒ§7 before touching any code.
1. What Themis is & the goal
Grounded AI legal research over 37,898 reportable Indian Supreme Court judgments (the open AWS registry
indian-supreme-court-judgments, ap-south-1). It retrieves on-point cases for a lawyer's query and produces a
grounded summary (every asserted point backed by a verbatim quote from a retrieved case).
North star right now: make search quality good enough for a live pilot with practising lawyers (via Hitin, the lawyer co-founder). A blind benchmark vs CaseMine put us at nDCG@5 0.77 vs 0.64 β but it was a 3β3 split and partly self-graded (see Β§4), and the real, repeatedly-confirmed gap is foundational-authority recall on doctrinal queries (landmark cases like Khushal Rao, Indra Sawhney rank too low or don't surface).
2. How search works TODAY β step by step
2.0 Serving stack
phase1/scripts/serve.py (FastAPI) + frontend.html (vanilla JS). The corpus is chunked: 1,299,748
passages. Each chunk has a BGE-small-en-v1.5 embedding (384-d, float32 matrix /.../.envM = escr_vectors.npy) and
sits in a BM25 index. The serving LLM is DeepSeek (deepseek-chat) via ` DEEPSEEK_API_KEY. Artifacts: escr_chunks.jsonl(chunk text),escr_vectors.npy, escr_meta.jsonl(per-judgment metadata incl.issue/heldheadnotes),edges.jsonl(citation graph, 86,702 edges),good_law.jsonl(citator),escr_pdfmap.jsonl`.
Shared retrieval primitives
dense(q)β embed"Represent this sentence for searching relevant passages: " + qwith BGE-small, cosineM @ qv, take top CAND=40 chunks.bm25_top(q)β BM25 over tokenized chunks, top 40. βrank_bm25.get_scoresscans 1.3M postings in pure Python = ~68 s/query β the reason the eval can't use the full serve path (see Β§6).candidates(q)β RRF-fuse dense+BM25 (k=60), top ~48 chunk candidates.rerank(q,cand,k)β cross-encoder ms-marco-MiniLM-L-6-v2 scores(q, chunk), dedups to best chunk per judgment, returns top-k docs (rr= cross-encoder score).verify(q,results)β a DeepSeek "paralegal" labels each resultrelevant/partial/not, seeing only a ~280-charpassage_snippet(NOT the holding). On JSON parse failure β everything defaults topartial.- Grounding gate (
grounded_answer/verify_claims) β DeepSeek emits{claim, n, quote}triples; the gate drops any claim whosequoteisn't a β₯4-word verbatim substring of cited casen's chunk; renders only survivors. (Checks provenance, not entailment β a real quote can be misread; that's a known residual.) - Good-law mask is "dark": only confirmed-overruled is flagged; unknown shows nothing.
FAST mode β GET /api/search_stream (SSE, stepwise)
- F0 ROUTER
identity_hits(q)β if q is a bare citation (regex) or "X v Y" name (fuzzyname_search, difflib +cite_indegsalience tiebreak), it's a lookup β metadataid_card, bypass the pipeline. - F1 RETRIEVE
candidates(q)β ~48 hybrid chunks. - F2 RERANK cross-encoder β top 12 judgments.
- F3 REVIEW
verifyβ keep relevant+partial, dropnot. - F4 THIN-GUARD if kept<4 β one DeepSeek query rewrite β re-retrieve/rerank/verify, add new.
- F5 ORDER sort relevant-first, then by
rr; top 8. - F6 GOOD-LAW dark mask. β In fast mode this is currently a no-op β the SSE label says "Checked which results are still good law" but NO fast-path code filters/demotes overruled cases (deep mode does). Fix pending.
- F7 ANSWER grounded summary (claims + verbatim-quote gate).
DEEP mode β GET /api/deep_search_stream
- D0 ROUTER same identity bypass.
- D1 PLAN DeepSeek decomposes the issue into 1β3 sub-issues + names β€5 leading authorities a lawyer expects
(names only; each grounded via
name_searchβ a hallucinated name simply fails to resolve). - D2 SEED
retrieve(q,12)thenverifyβ relevant/partial seed set. - D3 EXPAND (the only agentic step, one batch): (a) ground each named authority via
name_search; (b) citation neighbours β from top-6 seed docs, pull what THEY cite (out_edges), take 6 most-common; (c)card_for_docper added doc (reranks only its first 6 chunks βcis[:6], a real bug: a landmark's holding is often deeper β it gets mis-scored and dropped) β verify β keep relevant/partial AND not-overruled. - D4 AUTHORITY-CHECK report which named authorities landed (conflates "found but off-point" vs "not in corpus").
- D5 RANK a reviewer-confirmed PLAN authority that's
relevantgets a top slot; then relevant byrr; then partials; top 10. β authority bonus is binary + AND-gated on the snippet-derivedrelevantlabel. - D6 GOOD-LAW + ANSWER same as fast.
Other endpoints
/api/judgment?id= (full judgment view β metadata, issue/held headnote, citator, dark good-law, cleaned text, +
/api/pdf?id= which pulls the official SCR PDF from the open registry and caches β€20 locally, embedded inline),
/api/search (non-stream fallback). The "ask this judgment" feature was removed.
3. Known weaknesses (panel-reviewed, grounded in serve.py) β prioritized
The foundational-recall gap is created in F1 (shallow 48-chunk pool), never recovered because ranking ignores
authority (F5/D5 use raw rr; cite_indeg/bench/date sit on every card unused), and actively worsened by
F3 deleting weakly-phrased landmarks on a 280-char snippet and D3's cis[:6] bug. Plus the F6 fast-mode
good-law no-op (a trust defect: an overruled case can rank #1 under a false "checked" label; only ~40 of 43,175
docs are ever flaggable, partly_overruled in the D3 filter is dead code, doubted is wrongly omitted). Both
models (BGE-small embedder, ms-marco reranker) are general web English, not legal/India-tuned.
Full per-step verdicts + the prioritized sequence are in the memory file index and the panel transcripts (Β§9).
4. The AUTORESEARCH approach (the methodology β read this)
Modeled on Karpathy's AutoResearch (Mar 2026): an agent runs experiments in a loop β read code β propose ONE
change β run a short job β measure ONE mechanical metric β git commit if it improved / git revert if not β
repeat. Three pillars: (1) a hard constraint, (2) one mechanical metric, (3) autonomous propose/score/commit.
Plus his older "Recipe" discipline: become one with the data first, dumb baseline, change ONE thing at a time,
never add unverified complexity.
How we adapt it (the rules β do not break these):
- The metric is a FROZEN qrels file scored by pure arithmetic β NO LLM at score time. This makes it millisecond-cheap to recompute and non-self-gradeable by construction.
- The judge must NEVER be the serving model family. Our live
verifygate is DeepSeek, so DeepSeek is banned from labeling the eval (that's why the old 0.77 number is inadmissible β20_score_benchmark.pygraded DeepSeek-with-DeepSeek). Labels came from Claude (a different family); Codex is the cross-family second judge; the citation graph + known-item are model-free anchors. - Commit a change only if its metric delta clears the paired-bootstrap 95% CI AND no guardrail regresses.
- Cheap vs expensive experiments: anything that only re-ranks (pool depth, authority Ξ±, reranker swap, the ordering rule) is a ~65 s offline re-score β run hundreds. Anything that re-embeds the 1.3M corpus (swapping the embedder) is expensive and gated behind a proven "landmarks enter the pool but rank low" signal.
- Precision-first: a wrong overruled-case shown to a lawyer is the catastrophic error β the bad-law@10 guardrail and known-item success@1 can never regress for a commit to count.
5. The EVAL RIG (the measurement substrate) β phase1/eval/
Frozen set: 800 queries (queries.tsv + qrels.tsv + bad_law_docids.txt):
- 500 SILVER doctrinal / fact-pattern / vague β generated by Claude subagents (a Workflow, 25 agents) that
read a judgment's
held/issueheadnote and wrote 2 natural, anti-leak queries it answers (NEVER naming the case/citation); gold = that source case (grade 3).gen_sample.json= the 250 sampled judgments. This is silver β to be audited by Hitin (double-label a stratified ~200 sample, compute Cohen's ΞΊ, promote to gold). The Claude+Codex dual-judge for graded multi-relevant labels (pooling top-k from fast/deep/BM25) is the next eval upgrade. - 300 known-item (150 neutral-citation + 150 case-name) β gold = own doc; the success@1 control.
- 104 bad-law deny-list (overruled/doubted/per_incuriam doc_ids) β the model-free precision guardrail.
β Critical caveat: the silver gold = a random source case, not a landmark β this set measures GENERAL retrieval, NOT foundational-authority recall. The authority prior (PageRank/
cite_indeg) must be tested on a landmark set (extendeval/gold_foundational.json), where gold IS a landmark β on the silver set it catastrophically hurts (see Β§7). Building a bigger landmark/foundational gold set is a top eval to-do.
Metric suite (score_qrels.py, pure numpy, frozen, no network):
- PRIMARY: nDCG@10 (graded, 2^gβ1 gain) + nDCG@5.
- GUARDRAILS: known-item success@1 (must stay ~1 once the router is in the harness) and bad-law@10 (lower is better; a commit that raises it is auto-rejected).
- DIAGNOSTICS: recall@10, MAP@20, MRR, per-intent breakdown.
- All with bootstrap-over-queries 95% CIs.
Harnesses:
lean_run.pyβ serial, dense+CE, drops BM25 (the 68s killer), env knobs. ~0.74 s/q.batched_run.pyβ GPU-batched (encode all queries β one dense matmul β ONE batched cross-encoder pass) β full 800-query run in ~65 s (~9Γ the serial). This is the loop harness.sweep.pyβ loads the corpus once and scores many (reranker Γ CAND Γ ALPHA) configs with paired bootstrap vs baseline. The autonomous experiment driver.embed_chunks.pyβ re-embed the corpus on GPU (exists but too slow ~80 min β transfer original vectors instead).
6. COMPUTE β where & how to run
The Windows GPU box (the experiment machine)
ssh admin@100.81.98.43 (tailnet). RTX 5060 Ti 16 GB (Blackwell sm_120), 62 GB RAM, Python 3.11, default shell
PowerShell. App dir C:\Users\admin\themis. Setup gotchas you WILL hit:
- My key is in
C:\ProgramData\ssh\administrators_authorized_keyswith anicaclsperms lock β required or sshd silently ignores it (admin accounts). - torch must be
+cu128for Blackwell. The venv's old pip backtracks to the CPU wheel βpip install -U pipfirst, thenpip install torch --index-url https://download.pytorch.org/whl/cu128(NOT--extra-index-url, which re-picks the CPU build). Current:torch 2.11.0+cu128, CUDA True. PYTHONUTF8=1is MANDATORY β Windowsopen()defaults to cp1252 βUnicodeDecodeErroron the legal text.- Box is set to never-sleep +
tailscale up --unattendedso it stays reachable logged-out.
Run an experiment (the loop in practice):
cd $env:USERPROFILE\themis
$env:PYTHONUTF8="1"; $env:THEMIS_DATA="."; $env:THEMIS_EVAL="."; $env:THEMIS_DEVICE="cuda"
# one config:
$env:CAND="40"; $env:ALPHA="0"; $env:THEMIS_RERANKER="cross-encoder/ms-marco-MiniLM-L-6-v2"
.\venv\Scripts\python.exe batched_run.py # -> run.tsv (~65s)
.\venv\Scripts\python.exe score_qrels.py run.tsv
# or sweep many at once:
.\venv\Scripts\python.exe sweep.py
Getting artifacts onto the box (it was painful β documented so you don't repeat it)
Macβbox tailnet is DERP-relayed (0.8 MB/s, useless). Mac can't reach Thor from its network. Thor (the old
Jetson, now /backup100.99.130.27 tailnet, flaky) shares a LAN with the box β the box pulls Thor:` over LAN (192.168.1.76, ~3β5 MB/s). We added the box's own SSH key to Thor (from the Mac, retrying through Thor's flakiness). **Re-embedding on the 5060 Ti is too slow (~80 min, CPU-tokenization-bound) β transferring the original escr_vectors.npy` (~10 min) beats it and is exact.**
Other places it runs
- Local serve (the live app, for demoing/QA): on the Mac,
.venv(py3.12) + torch CPU, launched from the artifacts dir:THEMIS_LOG_DIR=β¦ .venv/bin/uvicorn --app-dir phase1/scripts serve:app --host 127.0.0.1 --port 8000(artifacts opened CWD-relative;scripts/.envholds the DeepSeek key; no passcode on localhost). - Pilot deploy:
phase1/deploy/has a ready Caddy + systemd + runbook for a Hetzner CPX41 (Caddy auto-TLS β uvicorn + a passcode gate,themis.apexflo.ai). Serving is CPU-only β no GPU needed to host. Not yet provisioned.
7. Results so far (the experiment log β keep appending to this)
| Config | nDCG@10 | recall@10 | succ@1 (known) | bad-law@10 | Ξ vs baseline (paired bootstrap) | Verdict |
|---|---|---|---|---|---|---|
| ms-marco, CAND=40, Ξ±=0 (baseline) | 0.517 | 0.595 | 0.31 | 0.037 | β | baseline |
| ms-marco, CAND=100, Ξ±=0 | 0.532 | 0.611 | 0.34 | 0.036 | +0.015 [+0.005, +0.026] βsig | COMMIT (deeper pool helps) |
| ms-marco, CAND=40, Ξ±=0.5 | 0.231 | 0.495 | 0.02 | 0.102 | β0.286 [β0.309, β0.262] | REJECT (authority prior wrong on silver) |
| ms-marco, CAND=100, Ξ±=0.5 | 0.131 | 0.299 | 0.00 | 0.179 | β0.386 | REJECT |
7b. The AUTHORITY slice β authority_{queries,qrels,badlaw} (150 landmark doctrinal queries)
Built to make the rig see the foundational-authority gap the silver set hides (silver gold is the random
source-case, not the landmark; so it actively penalizes authority). Gold = the doctrine's landmark (grade 3) +
strong-citation progeny (grade 2). Run with THEMIS_QFILE=authority_queries.tsv THEMIS_QRELS=authority_qrels.tsv THEMIS_QUERIES=authority_queries.tsv THEMIS_BADLAW=authority_badlaw.txt.
| Config | nDCG@10 | nDCG@5 | MRR | recall@10 | bad-law@10 | Verdict |
|---|---|---|---|---|---|---|
| ms-marco, Ξ±=0 (baseline) | 0.282 [.253,.312] | β | 0.551 | 0.204 | 0.113 | baseline β the gap, quantified (vs 0.517 silver) |
| bge-reranker-base, Ξ±=0 | 0.283 [.251,.313] | 0.315 | 0.553 | 0.195 | 0.127 | REJECT β Ξ+0.001, a better topical reranker does NOT find foundational law |
| ms-marco, Ξ±=0.3 | 0.354 [.324,.385] | β | 0.692 | 0.195 | 0.173 | win on nDCG but bad-law regresses (boosts overruled ex-landmarks) |
| ms-marco, Ξ±=0.6 | 0.353 | β | 0.686 | 0.194 | 0.180 | Ξ± plateaus past 0.3 |
| ms-marco, Ξ±=0.3 + good-law filter | 0.351 [.320,.384] | 0.416 | 0.693 | 0.193 | 0.000 | COMMIT β full nDCG gain held, bad-law β 0 |
Reading β three decisive results:
- Reranker swap is dead. bge β ms-marco (+0.001) on the exact slice it was meant to fix. Topical rerankers under-score old-language landmarks regardless of model. Don't ship the 1.1GB model / 10Γ slower CPU pass.
- The authority prior is the lever: +0.072 nDCG@10 (+24% rel), MRR 0.551β0.692; recall@10 flat β it's a pure ranking fix (landmarks were always in-pool, just low) β exactly the diagnosis the pool-probe gave.
- It ships gated by good-law: authority alone lifts bad-law (0.113β0.173); the denylist filter holds the gain (0.351) and drops bad-law to 0.000. Authority prior + good-law filter are one unit, never shipped apart.
The architecture consequence: the prior is β0.29 on general silver, +0.072 on doctrinal β it must be
query-routed (ON for doctrinal/principle-seeking, OFF for known-item/fact lookup). That swing is the empirical
mandate for a cheap LLM query-classifier at the front. Caveats: silver labels (Hitin audit pending); mild
circularity (slice gold & prior both key off cite_indeg β magnitude may inflate, direction is sound); bad-lawβ0
is only as real as the denylist, so it depends on F6 good-law being real in production.
7c. STAGE 0 of the agentic build β routed authority prior + good-law spine (committed 2026-06-25)
First stage of AGENTIC_SEARCH_SPEC.md. Folds the Β§7b lever into one query-routed spine and measures it
end-to-end. Round-0 intent classifier (classify_intent.py, DeepSeek: AUTHORITY vs SPECIFIC) gates the prior;
good-law filter drops the 43 confirmed-bad docs (goodlaw_badlaw.txt, the real product signal β not the ad-hoc
authority_badlaw.txt). Harness: spine_run.py (loads corpus once, CE once/slice, derives baseline + spine).
Routing fires on 69% of authority queries, 6% of silver β aggressive-OFF on general, as designed.
| Slice | paired ΞnDCG@10 vs baseline | Ξsucc@1 | bad-law@10 |
|---|---|---|---|
| AUTHORITY (150) | +0.048 [+0.028, +0.070] SIG | +0.12 SIG | 0.033 β 0.000 |
| SILVER all-800 | β0.013 [β0.021, β0.006] SIG | β0.020 | 0.013 β 0.000 |
The silver regression is confined to silver_doctrinal (β0.036 SIG); factpattern, vague, known-item are all
flat/ns. It is an eval artifact: silver_doctrinal gold is the arbitrary source case, not the doctrine's
landmark, so it penalizes us for correctly surfacing leading authority β the same query type scores +0.048 on
the authority slice where the gold is the landmark. The real (correctly-specified) guardrails β known-item
success@1, factpattern, vague, bad-law β all hold. Verdict: PASS; proceed to Stage 1.
Open caveats logged for Hitin's audit: (a) classifier precision β it over-fires AUTHORITY on a few narrow doctrinal queries ("frustration in a statutory tenancy") where a specific case may beat the landmark; correct gold is needed to tune this. (b) good-law coverage β only 43 docs flagged bad in 38k; the filter is perfect within what's labeled but labeling is thin (the F6 dependency). (c) silver_doctrinal needs landmark gold to be a valid instrument for authority features.
Files: classify_intent.py, spine_run.py, goodlaw_badlaw.txt, intent_{authority,silver}.json,
{authority,silver}_{baseline,spine}.tsv.
7d. STAGE 1 β the agentic controller (two-turn parallel) β PASS (2026-06-26)
Tool-rich ReAct agent (founder-chosen over the binary router), shaped by the panel as two-turn parallel,
not serial N-step ReAct: 1 plan LLM call (intent + expected authorities + statute refs + HyDE) β ALL retrieval
tools fan out at once (vector + authority + name_lookup(plan authorities) + statute/cases_on_section
hyde+ citation-graph) β merge β one batched CE rerank β authority-prior rank β good-law flag-don't-drop -for-authority. Files:scripts/tools.py(14-tool registry),scripts/agent.py(controller),eval/agentic_run.py.
| Authority slice (150) | nDCG@10 | MRR | succ@1 | recall@10 | bad-law@10 |
|---|---|---|---|---|---|
| baseline | 0.282 | 0.551 | 0.380 | 0.204 | 0.033 |
| spine (Stage 0) | 0.330 | 0.638 | 0.500 | 0.194 | 0.000 |
| agent | 0.389 | 0.788 | 0.687 | 0.181 | 0.160 |
- agent vs spine paired ΞnDCG@10 = +0.0585 [+0.028,+0.089] SIG; Ξrecall@10 ns (no real loss). The controlling authority is #1 in 69% of queries (succ@1 0.500β0.687).
- Recall-recovery slice (8 landmarks OUTSIDE the dense pool): recall@10 = 0.75 vs spine β 0 β the agent (name_lookup of LLM-named authorities + graph + hyde) solves what one-shot retrieval structurally cannot.
- bad-law@10 0.160 is 88% artifact: 23/26 hits are high-authority FALSE-POSITIVE landmarks (Maneka-type) that flag-don't-drop keeps WITH A WARNING; only 3 are genuine dead law (β spine's leak). The headline rise is the good-law data's mislabels, not a precision regression. β Hitin's good-law audit is now the #1 data item.
Latency: the Mac eval is ~11s/query (CPU, multiple sequential CE passes) β production (GPU box, async parallel
tools) hits the panel's 15s budget. name_lookup indexed (token postings) so it's no longer O(corpus).
NEXT: product path (2nd LLM turn = judge + streamed grounded answer + SSE steps), wire as serve.py adaptive
endpoint (keep fast/deep as fallback), per-tool ablation (cut tools that don't earn latency), good-law audit.
8. The stepwise plan (roadmap β panel-prioritized)
- Step 0 β eval rig β (this doc / Β§5).
- Step 1 β cheap honesty/correctness (ship together, low risk): make F6 good-law real in fast mode (demote/
tag the ~40 confirmed-overruled), fix the D3 good-law token set (drop
partly_overruled, adddoubted), log theverifyparse-failure no-op, sigmoid-normalizerr, share ONE F0/D0 router fn, emit D4 got/miss to the usage log. - Step 2 β pool depth (CANDβ + diversity) β first signal positive (+0.015).
- Step 3 β ranking core (now data-backed, Β§7b): ship the authority prior
sigmoid(rr)+Ξ±Β·log1p(cite_indeg)at Ξ±=0.3, query-routed (ON for doctrinal/principle queries only β it's β0.29 on general, +0.072 on doctrinal) and gated by the good-law filter (the two are one unit). Needs (a) a cheap LLM query-classifier at the front, (b) F6 good-law real. headnote-fedverify(down-weight not drop high-authority), fixcard_for_doc cis[:6]. NOTE: the bge-reranker swap is REJECTED (Β§7b) β do not pursue; the reranker is not the lever. - Step 4 β router guards (question-word before bypass; bare-name lookup) + A/B-and-maybe-cut the F4 rewrite.
- Step 5 β answer: deep synthesis over top-8 + force-include confirmed authorities + a Qwen/cross-family entailment gate after the verbatim gate.
- Stage 2 (post-pilot, measured): swap to a legal/Indic embedder (needs a re-embed of the corpus β
expensive; note the bge-reranker is already ruled out by Β§7b, so this is an embedder bet, not a reranker one); the citation-resolution rebuild (it's a MISSING-KEYS problem β SCC resolves at 0% β not
name-matching; tiered exact-lookup spine + trust firewall); the **statute layer (BNSβIPC / BNSSβCrPC / BSAβEvidence)
- unified intentβplanβtools** agentic search (collapse fast/deep into one adaptive-effort pipeline); PageRank / Personalized PageRank for authority (gated on clean Tier-1/2 edges).
9. Rigor principles (the culture to keep)
- Verify against the real code/data β the panels repeatedly caught wrong assumptions (e.g., "registry has SCC cites" was false; "re-scrape the registry" buys 0 keys; the "76 overruled" figure was wrong β it's 40). Read the file before you claim.
- Never self-grade. Judge family β serving family. Anchor in model-free truth (citation graph, known-item) where possible; reserve LLMs for the gaps and audit them.
- One change at a time β paired-bootstrap CI β commit or revert. No unverified complexity.
- Precision-first guardrails are non-negotiable (bad-law@10, known-item succ@1).
- Reproducibility/backups: artifacts live on the Mac (
phase1/data/thor_artifacts/, byte-verified), Thor~/backup, and the GPU box; code on GitHub. The eval set + qrels are frozen and versioned.
10. Pointers
- Code:
phase1/scripts/serve.py,frontend.html; eval:phase1/eval/{build_qrels,score_qrels,lean_run,batched_run,sweep,embed_chunks}.py+queries.tsv/qrels.tsv/bad_law_docids.txt; deploy:phase1/deploy/. - Design records (in the user's auto-memory): themis-eval-rig, themis-citation-resolution, themis-agentic-search-design, themis-benchmark-v1, thor-gpu-embedding (the box saga + gotchas).
- The panel reviews (per-step search critique, eval-metric design, citation-resolution) ran as multi-agent Workflows; their full transcripts are in the session's
subagents/workflows/dirs.
First thing to do when you pick this up: ssh the GPU box, run sweep.py, read the bge-reranker-base row, and
either commit it (if it clears the CI and guardrails hold) or move to Step 1. The loop is live β turn the crank.