Home / Articles / Practical notes: Beyond Basic RAG: Building a Production-Style Legal Research

This article is published in English.

Practical notes: Beyond Basic RAG: Building a Production-Style Legal Research

Operable walkthrough of Practical notes: Beyond Basic RAG: Building a Production-Style Legal Research: contracts, checks, and drop-in code slots for teams shipping this pattern.

3979 words

This walkthrough rebuilds the path from raw materials to a working system for: Beyond Basic RAG: Building a Production-Style Legal Research Assistant (Part-I). The focus is operable steps, explicit checks, and code that you can drop into a repo without guessing intent. For the Overview stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish.

1. Ingestion: turning a legal PDF into structured content

When working through the 1 Ingestion turning a stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Prefer small, testable units over sprawling scripts. When a step fails, the failure should point at a single responsibility rather than a tangled pipeline. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.

!mineru -p c.pdf -o output --dump-content-list -b pipeline -l en

2.Chunking

When working through the 2 Chunking stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.

Structured document
        ↓
Heading-aware parent chunks
        ↓
Semantic child chunks

Why the two-level strategy is useful

When working through the Why the two-level strategy stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface. When working through the Why the two-level strategy stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish.

Query
  ↓
Retrieve focused child
  ↓
Read parent_id
  ↓
Return complete parent context

Creating heading-aware parent chunks

The Creating heading-aware parent chunks stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Prefer small, testable units over sprawling scripts. When a step fails, the failure should point at a single responsibility rather than a tangled pipeline. Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move.

data/processed/cr.md
data/extracted/auto/blockpage.json
code/chunking_parent.py

Step 1: Read both extraction artifacts

The Step 1 Read both stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion. Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move.

md_text = MARKDOWN_PATH.read_text(encoding="utf-8")
with BLOCKPAGE_PATH.open("r", encoding="utf-8") as file:
    blockpage = json.load(file)

Step 2: Divide the Markdown into blocks

The Step 2 Divide the stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move. The Step 2 Divide the stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish.

for raw_block in md_text.split("\n\n"):
heading
paragraph
list
table
superscript
blank
HEADING_RE = re.compile(r"^(#{1,6})\s+")

Step 3: Track the active heading

For the Step 3 Track the stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Prefer small, testable units over sprawling scripts. When a step fails, the failure should point at a single responsibility rather than a tangled pipeline. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap.

def find_heading(heading_text: str):
    nonlocal search_pos
    for index in range(search_pos, len(blockpage)):
        block = blockpage[index]        if (
            block.get("type") == "text"
            and block.get("text") == heading_text
            and "text_level" in block
        ):
            search_pos = index + 1
            return block

Step 4: Collect related content

For the Step 4 Collect related stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap.

Heading
  ├── Paragraph
  ├── Clause
  ├── List
  └── Table
        ↓
     Parent chunk

Step 5: Assign stable metadata

For the Step 5 Assign stable stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap. For the Step 5 Assign stable stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish.

{
    "parent_id": f"parent_{len(chunks):04d}",
    "doc_id": DOC_ID,
    "heading_path": heading_path.copy(),
    "text": "\n\n".join(stack),
}
{
  "parent_id": "parent_0164",
  "doc_id": "constitution_of_india",
  "heading_path": ["PART XII"],
  "text": "# PART XII\n\nFINANCE, PROPERTY, CONTRACTS AND SUITS..."
}
PART XII — Finance
Article 264 — Interpretation
Article 265 — Taxes not to be imposed without authority of law
Article 266 — Consolidated Funds and public accounts
Article 267 — Contingency Fund

Creating semantic child chunks

When working through the Creating semantic child chunks stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Prefer small, testable units over sprawling scripts. When a step fails, the failure should point at a single responsibility rather than a tangled pipeline. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.

code/semantic-children.ipynb

Step 1: Parse the parent into structural blocks

When working through the Step 1 Parse the stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.

table_pattern = re.compile(
    r"(<table[\s\S]*?</table>)",
    re.IGNORECASE
)

Step 2: Attach headings to their content

When working through the Step 2 Attach headings stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface. When working through the Step 2 Attach headings stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish.

if pending_heading:
    block.text = pending_heading + "\n\n" + block.text
    pending_heading = None

Step 3: Compare neighbouring paragraphs

The Step 3 Compare neighbouring stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Prefer small, testable units over sprawling scripts. When a step fails, the failure should point at a single responsibility rather than a tangled pipeline. Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move.

embedder = SentenceTransformer(
    "BAAI/bge-base-en-v1.5"
)
similarity = cosine_similarity(
    emb1,
    emb2
)[0][0]
Similarity threshold: 0.40

Step 4: Apply size and structure constraints

The Step 4 Apply size stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion. Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move.

Preferred minimum size: 800 characters
Maximum combined size: 1,600 characters
Tiny-block threshold: 150 characters
if (
    current_type == BlockType.TABLE
    and block.type != BlockType.TABLE
) or (
    block.type == BlockType.TABLE
    and current_type != BlockType.TABLE
):
    is_under_min = False
Document structure
        +
Semantic similarity
        +
Minimum and maximum sizes

Step 5: Preserve the parent relationship

The Step 5 Preserve the stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move. The Step 5 Preserve the stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish.

{
    "parent_id": parent["parent_id"],
    "child_id": f"{parent['parent_id']}_child_{index}",
    "doc_id": parent.get("doc_id", ""),
    "heading_path": parent.get("heading_path", []),
    "text": chunk["text"],
    "type": chunk.get("type", "mixed"),
    "embedding": encode_text(chunk["text"])
}
parent_0164
│
├── parent_0164_child_1
│   Article 264 and introductory Finance context
│
├── parent_0164_child_2
│   Articles 265 and 266 concerning taxation and public funds
│
└── parent_0164_child_3
    Article 267 concerning the Contingency Fund
{
  "parent_id": "parent_0164"
}
{
  "child_id": "parent_0164_child_2",
  "parent_id": "parent_0164",
  "doc_id": "constitution_of_india",
  "heading_path": ["PART XII"],
  "type": "list",
  "text": "265. Taxes not to be imposed save by authority of law...",
  "embedding": [0.012, -0.034, 0.021]
}

3. Hybrid retrieval: from query to parent context

For the 3 Hybrid retrieval from stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Prefer small, testable units over sprawling scripts. When a step fails, the failure should point at a single responsibility rather than a tangled pipeline. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap.

Why one retrieval method was not enough

For the Why one retrieval method stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap.

Sparse retrieval with BM25

For the Sparse retrieval with BM25 stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap. For the Sparse retrieval with BM25 stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish.

Dense retrieval with BGE and FAISS

When working through the Dense retrieval with BGE stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Prefer small, testable units over sprawling scripts. When a step fails, the failure should point at a single responsibility rather than a tangled pipeline. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.

embedding_text = heading_path + child_text

Choosing the embedding model through evaluation

When working through the Choosing the embedding model stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion. Cache stable system instructions and tool schemas. Re-sending identical preamble is a common source of burn.

query_text = (
    "Represent this sentence for searching relevant passages: "
    + user_query
)

Building the FAISS index

When working through the Building the FAISS index stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface. When working through the Building the FAISS index stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish.

index = faiss.IndexHNSWFlat(
    embedding_dimension,
    32,
    faiss.METRIC_INNER_PRODUCT,
)
index.hnsw.efConstruction = 200
index.hnsw.efSearch = 64
index.add(embeddings)

Combining both rankings

The Combining both rankings stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Prefer small, testable units over sprawling scripts. When a step fails, the failure should point at a single responsibility rather than a tangled pipeline. Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move.

RRF score = Σ 1 / (k + rank + 1)
sparse_results = bm25_search(query)
dense_results = dense_search(query)
fused_candidates = reciprocal_rank_fusion(
    sparse_results,
    dense_results,
)

Reranking with a cross-encoder

The Reranking with a cross-encoder stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion. Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move.

pairs = [
    (query, child_text),
    ...
]
scores = reranker.predict(pairs)
candidates = fused_candidates[:30]
reranked_children = cross_encoder.rank(query, candidates)
selected_children = [
    child
    for child in reranked_children[:20]
    if child.score >= 0.30
]

Expanding selected children into parent context

The Expanding selected children into stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move. The Expanding selected children into stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish.

{
  "child_id": "parent_0082_child_1",
  "parent_id": "parent_0082",
  "text": "23. Prohibition of traffic in human beings and forced labour..."
}
parent_id = selected_child["parent_id"]
parent = parent_lookup[parent_id]
Selected child 1 ──┐
Selected child 2 ──┼── parent_0082
Selected child 3 ──┘

4. Query expansion and multi-hop questions

For the 4 Query expansion and stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Prefer small, testable units over sprawling scripts. When a step fails, the failure should point at a single responsibility rather than a tangled pipeline. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap.

What the Kaggle evaluation revealed

For the What the Kaggle evaluation stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap.

Expanding a query without changing its intent

For the Expanding a query without stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Cite the passages that actually grounded the answer. Without citations, operators cannot tell hallucination from an indexing gap. For the Expanding a query without stage, define the inputs, the owner of the step, and the exit criteria before changing code. Operators should be able to re-run the step from a known checkpoint without guessing hidden state. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish.

Query expansion is not the same as subquery generation

When working through the Query expansion is not stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Prefer small, testable units over sprawling scripts. When a step fails, the failure should point at a single responsibility rather than a tangled pipeline. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.

The resulting query strategy

When working through the The resulting query strategy stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Treat this stage as a contract between inputs and validated outputs. Name the artifacts, define success checks, and refuse silent partial completion. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface.

Closing thoughts

When working through the Closing thoughts stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Record timings and token or query cost next to functional results. Cost visibility early prevents surprise bills when the path moves from demo to shared environments. Measure recall on a fixed question set before tuning prompts. Prompt churn rarely fixes a weak retrieval surface. When working through the Closing thoughts stage, write down the contract first: required inputs, success signal, and what happens on partial failure. That checklist keeps later code changes honest. Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish.

Operational checklist

The Operational checklist stage works best when treated as a measurable surface. Capture one golden transcript, one failure case, and the rollback note before expanding scope.

Keep configuration outside application code. Environment files, secret stores, and feature flags belong in one place operators can audit without reading the whole graph.

Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move.

Add a smoke test that exercises the critical path in CI with fixtures, not live paid APIs, whenever budgets allow.

Document the happy path and the recovery path together. Retries, human gates, and dead-letter handling are part of the product, not later polish.

Separate chunking policy from retrieval policy. Changing one should not force a rewrite of the other when quality metrics move.

Before promoting the stack, freeze versions, capture a golden transcript for the critical path, and confirm rollback steps. Shared environments need rate limits, tenancy checks, and a clear owner for secret rotation. Prefer boring reliability over clever one-off demos.

Batch note for 8a4b50ff4af2: keep provider keys out of the repo, set a per-session token ceiling, and store transcripts next to the eval fixtures so later model swaps stay comparable.