Reproducible benchmark that compares retrieval strategies for LLM analysis of legal text.
It runs the same model prompt against multiple context-construction methods and measures:
- response quality versus a ground-truth pass
- latency
- token usage
- retrieval coverage and hallucination behavior
The benchmark target document is nda_section.md (NDA Section 8).
- Goal: compare retrieval strategies (flat vs semantic vs hierarchical) on the same legal extraction task.
- Entry point:
run.py. - Output:
results.json+ terminal summary table. - Local run:
BACKEND=ollama python run.py. - Longleaf run:
BACKEND=vllm_server python run.py(recommended — keeps model loaded between runs).
# Local
pip install requests transformers torch langchain-experimental langchain-huggingface sentence-transformers
BACKEND=ollama python run.py
# HPC — start model server in one terminal, run benchmark in another
# Terminal 1 (keep open):
vllm serve <model_path> --dtype float16 --port 8000
# Terminal 2:
BACKEND=vllm_server python run.py
# HPC (batch)
sbatch submit.slMost "RAG quality" discussions mix retrieval and generation errors together. This project separates them:
- Retrieval quality: did the retriever surface enough source text?
- Extraction quality: given that text, did the LLM answer correctly?
run.py evaluates both with field-level scoring against a ground-truth run.
run.py— benchmark driver, chunking/retrieval logic, model invocation, scoring, and summary outputnda_section.md— fixed legal source text under testprompt.md— fixed extraction prompt template ({context}placeholder)results.json— machine-readable benchmark outputsubmit.sl— Slurm job script for Longleaf GPU execution
The script currently executes:
-
GROUND TRUTH — Full Section 8- passes full source text into context
- acts as reference output for comparison
-
RUN 2 — BASELINE (flat chunking, BGE-M3 top 10)- fixed-size word chunks (approx 500 tokens/chunk)
- BGE-M3 cosine similarity retrieval, top 10
-
RUN 2.1 — BASELINE + CROSS-ENCODER (flat, BGE-M3→CE top 10)- same flat chunks as Run 2
- BGE-M3 retrieves top 20 candidates; cross-encoder (
ms-marco-MiniLM-L6-v2) reranks to top 10
-
RUN 3 — SEMANTIC CHUNKING (topic-shift, BGE-M3 top 10)- LangChain
SemanticChunkerwith MiniLM embeddings for splitting - BGE-M3 cosine similarity retrieval, top 10
- LangChain
-
RUN 3.1 — SEMANTIC + CROSS-ENCODER (topic-shift, BGE-M3→CE top 10)- same semantic chunks as Run 3
- BGE-M3 retrieves top 20 candidates; cross-encoder reranks to top 10
-
RUN 4 — HICHUNK + AUTO-MERGE (hierarchical, top 10)- structure-aware regex chunker identifies section/subsection/clause boundaries
- BGE-M3 retrieval over leaf nodes, auto-merge promotes siblings to parent context
- if full HiChunk repo is unavailable, falls back to the structure-aware chunker for local compatibility
-
RUN 4.1 — LLAMAINDEX (Structure-Aware HiChunk + AutoMerge, top 10)- same
structure_aware_chunk()splits as Run 4, built as LlamaIndexTextNodeobjects - explicit
PARENT/CHILDNodeRelationshiplinks; parent text = full leaf concatenation - Qdrant in-memory vector store, BGE-M3 embeddings,
AutoMergingRetriever
- same
-
RUN 4.2 — HICHUNK + CROSS-ENCODER + AUTO-MERGE (BGE-M3→CE top 10)- same HiChunk tree as Run 4
- BGE-M3 retrieves top 20 leaf candidates; cross-encoder reranks to top 10 before auto-merge
-
RUN 5 — LIGHTRAG (full document, hybrid)- full document inserted into LightRAG in a single call
- LightRAG extracts entities and relationships using the active LLM backend
- retrieval via hybrid graph traversal (local entity + global relationship)
- knowledge graph built in a temp dir and discarded after the run
All retrieval runs use the same QUERY (aligned with the prompt question) and BAAI/bge-m3 for embeddings. Only chunking and retrieval strategy vary — this isolates those two variables across runs.
| Run | Chunking | Retrieval | Expected behavior |
|---|---|---|---|
| Ground Truth | Full document | — | Reference output |
| Run 2 | Flat (500-token) | BGE-M3 top 10 | Flat boundaries cut across sections |
| Run 2.1 | Flat (500-token) | BGE-M3→cross-encoder top 10 | Tests whether reranking compensates for flat boundaries |
| Run 3 | Semantic (MiniLM) | BGE-M3 top 10 | Better split points, same retrieval |
| Run 3.1 | Semantic (MiniLM) | BGE-M3→cross-encoder top 10 | Tests whether reranking improves on semantic splits |
| Run 4 | Hierarchical (regex) | BGE-M3 + auto-merge top 10 | Structure-aware, sibling promotion |
| Run 4.1 | Hierarchical (regex, LlamaIndex) | BGE-M3 + auto-merge top 10 | Same chunking as Run 4, LlamaIndex/Qdrant stack |
| Run 4.2 | Hierarchical (regex) | BGE-M3→cross-encoder + auto-merge top 10 | Tests whether better leaf selection improves merged context |
| Run 5 | Full document | LightRAG hybrid graph traversal | Entity/relation-aware retrieval |
run.py supports four LLM backends, selected via the BACKEND environment variable:
BACKEND |
Where | Model | Notes |
|---|---|---|---|
ollama |
Local Mac | qwen2.5:7b |
Requires Ollama running locally |
vllm_server |
Longleaf GPU | Qwen/Qwen2.5-14B-Instruct |
Recommended on GPU — connects to a persistent vllm serve process; no reload between runs |
vllm |
Longleaf GPU | Qwen/Qwen2.5-14B-Instruct |
Loads model in-process; reloads on every run |
| unset (huggingface) | Longleaf GPU | Qwen/Qwen2.5-14B-Instruct |
Falls back to transformers.pipeline |
| Variable | Purpose |
|---|---|
HF_HOME |
Where HuggingFace looks for cached models. Set to work storage (/work/users/...) to avoid filling your home quota with the 29 GB model. |
HF_HUB_OFFLINE=1 |
Prevents huggingface_hub from making any network requests. Required on compute nodes, which have no internet access. |
TRANSFORMERS_OFFLINE=1 |
Same as above but for the transformers library. Both must be set since they are separate libraries. |
VLLM_SERVER_URL |
URL of the vLLM server (default: http://localhost:8000). Override if serving on a different port. |
- Install Python dependencies:
pip install requests transformers torch langchain-experimental langchain-huggingface sentence-transformers \
llama-index-core llama-index-vector-stores-qdrant llama-index-embeddings-huggingface qdrant-client lightrag-hku- Install and run Ollama:
ollama pull qwen2.5:7b- Run benchmark:
BACKEND=ollama python run.pyNotes:
- Runs 1–4 work in this mode.
- Run 4.1 requires
llama-index-*andqdrant-client— skipped gracefully if not installed. - Run 5 requires
lightrag-hku— skipped gracefully if not installed.
Compute nodes have no internet access. Download the model from the data transfer node before your first run:
ssh longleaf-xfer.its.unc.edu
module load anaconda
conda activate hichunk
export HF_HOME=/work/users/v/k/vkereka/hf_cache
huggingface-cli download Qwen/Qwen2.5-14B-InstructRun the download inside tmux — it pulls ~29 GB and takes 15–20 minutes. Do not run large downloads on the login node; it will be killed by the memory limit.
If any packages are missing from the hichunk env, install them on a compute node:
pip install langchain-experimental langchain-huggingface sentence-transformers \
llama-index-core llama-index-vector-stores-qdrant llama-index-embeddings-huggingface \
qdrant-client lightrag-hkuThis approach loads the model once and keeps it in GPU memory between runs.
Terminal 1 — start the model server (keep this open):
module load anaconda && module load cuda && conda activate hichunk
export HF_HOME=/work/users/v/k/vkereka/hf_cache
export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
vllm serve /work/users/v/k/vkereka/hf_cache/hub/models--Qwen--Qwen2.5-14B-Instruct/snapshots/<hash> --dtype float16 --port 8000Wait for Application startup complete.
Terminal 2 — run the benchmark (repeat as needed):
module load anaconda && conda activate hichunk
cd ~/Projects/vv
BACKEND=vllm_server python run.pyLoads the model fresh on every run — slower but simpler.
srun -n 1 --cpus-per-task=4 --mem=32g -t 1:00:00 -p l40-gpu --qos=gpu_access --gres=gpu:1 --pty bash
module purge && module load anaconda && module load cuda && conda activate hichunk
export HF_HOME=/work/users/v/k/vkereka/hf_cache
export TRANSFORMERS_OFFLINE=1
export HF_HUB_OFFLINE=1
cd ~/Projects/vv
BACKEND=vllm python run.pysubmit.sl is pre-configured for the l40-gpu partition with the correct HF_HOME:
sbatch submit.slResults are written to results.json with two top-level keys:
{
"summary": [ ... ],
"runs": [ ... ]
}summary is a compact table — one row per run with just the headline numbers. runs is the full detail.
After every run the script prints:
Run Latency Instruction Following Isolated Extraction Score Document Coverage Halluc
GROUND TRUTH 17.3s (baseline) — — —
RUN 2 — BASELINE 16.2s 85.5% 84.8% 95.5% 0
RUN 3 — SEMANTIC 98.7s 94.1% 83.8% 36.4% 0
RUN 4 — HICHUNK 15.4s 100.0% 100.0% 54.5% 0
| Column | What it means |
|---|---|
Latency |
Wall-clock time for the LLM call |
Instruction Following |
Average per-field score from the LLM judge across all fields. Fields where the retriever failed and the model correctly said [NOT FOUND] still score 1.0 — so this number can look high even when coverage is low. |
Isolated Extraction Score |
Same scoring, but fields where the retriever never surfaced the content are excluded entirely. This isolates extraction quality: given what the retriever found, how well did the model use it? This is the primary quality signal. |
Document Coverage |
Fraction of the document's fields the retriever actually surfaced. A run can score 100% Isolated Extraction but only 50% Document Coverage — meaning it was perfect on what it found, but missed half the document. |
Halluc |
Count of fields where the model asserted a specific fact not present in the retrieved context. |
How to read Instruction Following vs Isolated Extraction Score together: A high Instruction Following score with low Document Coverage means the model behaved correctly but the retriever failed it. A high Isolated Extraction Score with low Document Coverage means the retrieval strategy is precise but narrow — increase top_k to improve coverage.
Each field in details.judge_results has a category:
| Category | Meaning |
|---|---|
correct |
Answer matches ground truth in legal meaning |
correct_gap |
Context didn't contain the answer; model correctly said [NOT FOUND] |
over_extracted |
Correct answer but includes extra text beyond what was asked |
under_extracted |
Correct but missing key detail or conditions |
partial |
Got some of the answer but missed parts (e.g. 1 of 6 survival sections) |
missing |
Context had the answer but model returned something wrong |
hallucinated |
Model stated a specific fact not present in the retrieved context |
parse_error |
LLM judge returned malformed JSON; score defaults to 0.0 |
Each entry in runs includes:
run— labellatency_secondstokens_prompt/tokens_responsecontext_used— exact text sent to the model for that runoutput— raw model responseauto_merge_fired— (Runs 4, 4.1, 4.2) whether auto-merge promoted any retrieved chunk to its parent sectioncomparison— scoring results vs ground truth, including per-fieldjudge_results
Start with summary. This is the same as the terminal table — one row per run. Look at retrieval_coverage first. If coverage is low, the retriever is the problem regardless of what the other scores say. Then look at retrieval_adjusted_score to see how well the model used what it did retrieve.
To investigate a specific run, find it in runs by matching the run label.
Check context_used to see exactly what text the model had access to. If a field scored [NOT FOUND] and you think the answer should have been there, look here first — the answer may simply not have been retrieved.
Check output to see the raw model response before any parsing. If scores look wrong, check whether the model followed the output format correctly.
To find where a run went wrong, go to comparison.details.judge_results. Each entry is one field with its score and a one-line reason. Look for category: "partial", "missing", or "hallucinated" — those are the failures. correct_gap means the retriever missed it, not the model.
retrieval_gaps vs missing_in_run: both count fields that got [NOT FOUND], but retrieval_gaps is the subset where the context genuinely didn't contain the answer. If missing_in_run is high but retrieval_gaps is low, the model had the answer available and still said [NOT FOUND] — that's an extraction failure, not a retrieval failure.
parse_error entries scored 0.0 but may not actually be wrong — the judge produced invalid JSON for those fields. Check the reasoning string; if it starts with Failed to parse judge response:, treat that field's score as unreliable.
compare_to_ground_truth() parses structured field outputs and compares each run to run 1.
The judge prompt in run.py enforces a two-step evaluation:
- Determine whether the needed information existed in the provided context.
- Score extraction quality only after checking retrieval sufficiency.
This avoids penalizing a model for missing data it never received, and flags hallucinations when it invents unsupported facts.
prompt.mdandnda_section.mdare benchmark fixtures; keep them stable when comparing retrieval strategies.- Summary trends are more informative than one-off run variance.
- For fair comparisons, keep backend/model, prompt, and source document constant.
- RUN 4 should generally improve coverage vs RUN 3 on deeply nested clauses.
retrieval_coverageshould increase when hierarchical retrieval is working.retrieval_adjusted_scorehelps separate extraction mistakes from retrieval misses.- Hallucinations should remain low even as context breadth increases.
Safe modifications for experiments:
- retrieval parameters (
top_k, chunk settings) - retrieval algorithms/chunkers
- backend model choice
Avoid modifying while benchmarking strategy quality:
run()interface- scoring logic in
compare_to_ground_truth() - fixture semantics in
prompt.mdandnda_section.md
-
Skipping RUN 4 — sentence-transformers not installed- run
pip install langchain-experimental langchain-huggingface sentence-transformers
- run
-
Cannot find the requested files in the disk cache- model hasn't been downloaded yet, or
HF_HOMEis pointing to the wrong path - verify with
ls /work/users/v/k/vkereka/hf_cache/hub/
- model hasn't been downloaded yet, or
-
Killedduring model download- login nodes have memory limits — use
longleaf-xfer.its.unc.edufor large downloads
- login nodes have memory limits — use
-
Could not find nvcc/ FlashInfer build error- CUDA toolkit is not loaded — run
module load cudabefore starting vLLM
- CUDA toolkit is not loaded — run
-
Connection refusedwithBACKEND=vllm_server- the vLLM server in Terminal 1 is not running or hasn't finished starting up yet
-
slow or failed model load
- use
BACKEND=ollamalocally, orBACKEND=vllm_serveron GPU
- use
-
unexpected retrieval quality regressions
- inspect
context_usedinresults.json - verify
top_k, merge behavior, and chunk boundaries
- inspect
-
no summary differences across runs
- ensure chunking/retrieval settings actually differ per run