n4nAI

Building a case law research assistant with LlamaIndex

Step-by-step tutorial to build a case law research assistant with LlamaIndex: load opinions, index with metadata, query citations, and deploy a research agent.

n4n Team3 min read633 words

Audio narration

Coming soon — every post will get a voice note here.

Legal research eats time because relevant precedent hides in long opinions. A case law research assistant llamaindex implementation lets you index thousands of court documents and ask natural-language questions with cited sources. This tutorial builds one from scratch using LlamaIndex and a local corpus of public-domain cases.

Prerequisites

  • Python 3.10 or newer
  • pip install llama-index python-dotenv
  • An OpenAI API key (or any OpenAI-compatible endpoint)
  • A directory ./cases containing at least two plain-text case files (.txt). For testing, grab Marbury v. Madison and Brown v. Board of Education from CourtListener or Project Gutenberg and save them as marbury.txt and brown.txt.

Set your key in a .env file:

echo "OPENAI_API_KEY=sk-..." > .env

Loading and parsing case text

LlamaIndex’s SimpleDirectoryReader handles directory traversal and file extraction. For .txt it returns one Document per file with file_path in metadata.

import os
from dotenv import load_dotenv
from llama_index.core import SimpleDirectoryReader

load_dotenv()

documents = SimpleDirectoryReader(
    input_dir="cases",
    required_exts=[".txt"],
    recursive=False,
).load_data()

print(f"Loaded {len(documents)} documents")
for d in documents[:1]:
    print(d.metadata, len(d.text))

Expected output:

Loaded 2 documents
{'file_path': 'cases/marbury.txt'} 45231

If you see zero documents, check the extension filter and that the path is relative to your run directory.

Chunking and LLM configuration

Case opinions are dense. Naive fixed-size splitting breaks sentences mid-holding. Use SentenceSplitter with a 1024-token window and 64-token overlap. Lock temperature to zero for deterministic legal summarization.

from llama_index.core import Settings
from llama_index.core.node_parser import SentenceSplitter
from llama_index.llms.openai import OpenAI

Settings.llm = OpenAI(model="gpt-4o-mini", temperature=0)
Settings.node_parser = SentenceSplitter(chunk_size=1024, chunk_overlap=64)

The default embedding model is OpenAI’s text-embedding-3-small. That is fine for a prototype; swap to a local BGE model later if egress cost becomes an issue.

Building the base vector index

VectorStoreIndex.from_documents parses the docs through Settings.node_parser, embeds each node, and builds an in-memory FAISS-style store.

from llama_index.core import VectorStoreIndex

index = VectorStoreIndex.from_documents(documents)
query_engine = index.as_query_engine(
    response_mode="compact",
    similarity_top_k=4,
)

response_mode="compact" concatenates the top nodes into one prompt, which preserves cross-reference context better than tree_summarize for short queries.

Querying with citations

A research tool is useless without traceability. The query response exposes source_nodes with scores and metadata.

response = query_engine.query(
    "What did the Court hold about judicial review in Marbury v. Madison?"
)
print(response.response)
print("--- Sources ---")
for node in response.source_nodes:
    print(node.metadata.get("file_path"), round(node.score, 3))

Expected output shape:

The Court held that the Supreme Court has the power to review acts of Congress and declare them void if they conflict with the Constitution.
--- Sources ---
cases/marbury.txt 0.841
cases/marbury.txt 0.792
cases/brown.txt 0.411

The score spread tells you the retrieval is discriminating. If brown.txt scores above 0.7 on a Marbury-specific question, tighten chunk size or add metadata filters.

Enriching with metadata

Plain file paths are weak signals. Attach case name, year, and jurisdiction before indexing so you can filter at query time.

for d in documents:
    fname = d.metadata.get("file_path", "").lower()
    if "marbury" in fname:
        d.metadata.update({"case": "Marbury v. Madison", "year": 1803, "jurisdiction": "US-SCOTUS"})
    elif "brown" in fname:
        d.metadata.update({"case": "Brown v. Board", "year": 1954, "jurisdiction": "US-SCOTUS"})

index = VectorStoreIndex.from_documents(documents)

Now constrain a query to a single jurisdiction:

from llama_index.core.vector_stores import MetadataFilters, FilterCondition

filters = MetadataFilters.from_dicts(
    [{"key": "jurisdiction", "value": "US-SCOTUS"}],
    condition=FilterCondition.AND,
)
filtered_engine = index.as_query_engine(
    filters=filters, similarity_top_k=4
)

This pushes the predicate to the vector store. With an in-memory store it post-filters; with pgvector or Chroma it can pre-filter for speed.

Turning it into a research agent

A case law research assistant llamaindex deployment becomes more useful when the model can decide to search, then synthesize. Wrap the query engine in a tool and give it to an OpenAI agent.

from llama_index.agent.openai import OpenAIAgent
from llama_index.core.tools import QueryEngineTool, ToolMetadata

tool = QueryEngineTool(
    query_engine=filtered_engine,
    metadata=ToolMetadata(
        name="case_law_search",
        description="Search indexed U.S. Supreme Court opinions for holdings and precedent",
    ),
)

agent = OpenAIAgent.from_tools([tool], llm=Settings.llm, verbose=True)
agent.chat("Find a case that limits congressional power under the commerce clause")

Verbose mode prints the ReAct loop. You should see a Action: case_law_search step followed by an Observation containing retrieved text, then a final answer. The agent will not hallucinate cases outside the index because every claim must ground in tool output.

Model portability and fallback

The OpenAI class in LlamaIndex is just an OpenAI-compatible client. Point api_base at any compliant gateway to change providers without touching index code. For example, an OpenAI-compatible gateway such as n4n.ai exposes one endpoint for 240+ models and auto-falls back when a provider is degraded, so the LlamaIndex code above stays unchanged aside from the base URL and model string.

from llama_index.llms.openai import OpenAI

Settings.llm = OpenAI(
    model="anthropic/claude-3-haiku",
    api_base="https://api.n4n.ai/v1",
    api_key=os.environ["N4N_API_KEY"],
    temperature=0,
)

This is the cleanest way to experiment with model routing while keeping the case law research assistant llamaindex stack stable.

Persistence and production notes

The in-memory index rebuilds on every restart. Persist it with a storage context:

from llama_index.core.storage.docstore import SimpleDocumentStore
from llama_index.core.storage.index_store import SimpleIndexStore
from llama_index.core.vector_stores import SimpleVectorStore
from llama_index.core import StorageContext

storage_context = StorageContext.from_defaults(
    docstore=SimpleDocumentStore.from_persist_dir("storage"),
    index_store=SimpleIndexStore.from_persist_dir("storage"),
    vector_store=SimpleVectorStore.from_persist_dir("storage"),
)
index = VectorStoreIndex.from_documents(documents, storage_context=storage_context)
storage_context.persist("storage")

For multi-user legal SaaS, move the vector store to pgvector and the docstore to Redis. Add row-level metadata (client_id) and enforce it in MetadataFilters per request—never trust the client to filter server-side alone.

Embedding cost scales with corpus size, not query volume. A 10k-opinion corpus at 1k tokens each is ~10M tokens; at $0.02/M that is $0.20 per full re-embed. Use incremental indexing via index.insert(document) to avoid re-embedding the whole corpus when a new ruling drops.

Finally, the case law research assistant llamaindex pattern here is the same one you would use for healthcare policy PDFs or regulatory filings—only the metadata schema changes. Build the filter layer early; retrofitting jurisdiction and date fields after a million nodes are embedded is painful.

Tagsllamaindexlegal-techresearch-agent

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All framework tutorials: legal & healthcare document q&a posts →