SBERT Legal Paragraph Retrieval (Indian Court Judgments)
This is a sentence-transformers bi-encoder finetuned from sentence-transformers/all-MiniLM-L6-v2 for legal paragraph retrieval: given a lawyer's natural-language query, retrieve the most relevant paragraph from a corpus of Indian court judgments. It maps sentences and paragraphs into a 384-dimensional vector space where cosine similarity reflects legal relevance.
Training data pairs each piece of legal content with two parallel representations — the original verbatim judgment text and an LLM-generated case-summary sentence — so the model is trained to retrieve either representation with comparable confidence for the same query, rather than learning a shortcut tied to one source's writing style.
A companion variant trained with chunk-and-pool encoding (for documents whose
individual paragraphs exceed 256 tokens) is available at
sbert-legal-v1-chunkpoolmean.
Model Details
- Model type: Sentence Transformer (bi-encoder)
- Base model: sentence-transformers/all-MiniLM-L6-v2
- Maximum sequence length: 256 tokens (longer paragraphs are truncated — see "Splitting a judgment into paragraphs" below for why this is rarely an issue in practice)
- Output dimensionality: 384
- Similarity function: Cosine similarity
- Language: English
- Domain: Indian court judgments
- License: Apache 2.0
Full Model Architecture
SentenceTransformer(
(0): Transformer({'transformer_task': 'feature-extraction', 'architecture': 'BertModel'})
(1): Pooling({'embedding_dimension': 384, 'pooling_mode': 'mean', 'include_prompt': True})
(2): Normalize({})
)
Usage
Install the library first:
pip install -U sentence-transformers pdfplumber
If you're in Google Colab and hit an error like
AttributeError: 'SentenceTransformer' object has no attribute 'encode_query': that method needs a very recentsentence-transformersversion, and Colab often keeps the old version loaded in memory even afterpip install -Uuntil you restart the runtime (Runtime → Restart session). The example below avoids the issue entirely by using the standard.encode()method, which works on every version.
If loading raises
TypeError: Pooling.__init__() missing 1 required positional argument: 'embedding_dimension': this model's saved config uses an older key name for the pooling module than the very newestsentence-transformersreleases expect, so the one-lineSentenceTransformer("vishnuprabha72/sbert-legal-v1-legacy")loader can fail to auto-infer that argument depending on which library version you have installed. Load it by explicitly constructing the modules instead — this always works regardless of version, and is what both examples below do.
Quick start (sanity check)
Confirms the model loads and encodes correctly before you build a full retrieval pipeline:
from sentence_transformers import SentenceTransformer, models
# Load explicitly via modules rather than the one-line loader — this sidesteps
# the Pooling config mismatch some sentence-transformers versions hit when
# auto-inferring embedding_dimension from an older saved config.
word_embedding_model = models.Transformer(
model_name_or_path="vishnuprabha72/sbert-legal-v1-legacy"
)
embedding_dimension = word_embedding_model.get_word_embedding_dimension()
pooling_model = models.Pooling(embedding_dimension, pooling_mode="mean")
model = SentenceTransformer(modules=[word_embedding_model, pooling_model])
sentences = [
"What are the essential conditions for granting a temporary injunction?",
"The cardinal principles for grant of temporary injunction require a strong "
"prima facie case, that non-interference would cause irreparable injury with "
"no other remedy available, and that the balance of convenience favours the "
"party seeking relief.",
"The plaintiff filed suits for declaration and specific performance against "
"the defendant companies regarding a total area of 19,685 square metres of "
"land, contending a concluded contract existed after negotiations for a "
"total sum running into several crores of rupees.",
"The appeals are allowed and the orders of injunction granted by the trial "
"court and affirmed by the High Court are set aside.",
]
embeddings = model.encode(sentences)
similarities = model.similarity(embeddings, embeddings)
print(similarities.shape)
# [4, 4]
The query (sentence 1) and the injunction-principles paragraph (sentence 2) should score noticeably higher against each other than either scores against the unrelated background-facts or disposition sentences — that's the retrieval behavior the rest of this section builds on.
Full example: retrieving a paragraph from a real judgment
This example uses excerpts from Ambalal Sarabhai Enterprise Ltd. v. K.S. Infraspace LLP (Supreme Court of India, Civil Appeal No. 9346/2019) — a specific-performance and injunction dispute — as a stand-in for "a judgment you've split into paragraphs."
from sentence_transformers import SentenceTransformer, util, models
# Load explicitly via modules rather than the one-line loader — this sidesteps
# the Pooling config mismatch some sentence-transformers versions hit when
# auto-inferring embedding_dimension from an older saved config.
word_embedding_model = models.Transformer(
model_name_or_path="vishnuprabha72/sbert-legal-v1-legacy"
)
pooling_model = models.Pooling(
word_embedding_model.get_word_embedding_dimension(),
pooling_mode="mean",
)
model = SentenceTransformer(modules=[word_embedding_model, pooling_model])
# Paragraphs from one judgment (already split — see next section for how to
# do this automatically from a PDF).
paragraphs = [
"The plaintiff filed suits for declaration and specific performance "
"against the defendant companies regarding land measuring 19,685 square "
"metres, contending that a concluded contract existed after negotiations "
"for a total sum running into several crores of rupees.",
"The Principal Civil Judge held that the terms and conditions for sale "
"stood finalised by e-mail, that a token amount had already been paid, "
"and restrained the defendants from executing any further documents or "
"creating third-party rights in the suit lands.",
"Section 36 of the Specific Relief Act, 1963 provides for grant of "
"preventive relief, and Section 37 provides that temporary injunction in "
"a suit shall be regulated by the Code of Civil Procedure. The grant of "
"relief in a suit for specific performance is itself a discretionary "
"remedy.",
"The cardinal principles for grant of temporary injunction were "
"considered in Dalpat Kumar v. Prahlad Singh: the court must be "
"satisfied there is a prima facie case, that non-interference would "
"result in irreparable injury with no other remedy available, and that "
"the balance of convenience favours granting the injunction.",
"The plaintiff was aware from the very inception that the defendant was "
"negotiating for sale of the lands simultaneously with two others, and "
"was further aware that the deal with it had fallen through before the "
"registered agreement for sale was executed with defendant no. 2.",
"The suit was filed seven months after the plaintiff acknowledged "
"receiving a refund of its advance payment; in commercial dealings "
"involving high-stake matters, such delay is a vital consideration "
"against the grant of equitable relief.",
"The appeals are allowed and the orders of injunction are set aside; "
"nothing in this order shall be construed as an expression of opinion on "
"the merits of the suit, which will be decided on its own merits at "
"trial.",
]
query = "What are the essential conditions for granting a temporary injunction?"
query_embedding = model.encode(query, convert_to_tensor=True)
paragraph_embeddings = model.encode(paragraphs, convert_to_tensor=True)
scores = util.cos_sim(query_embedding, paragraph_embeddings)[0]
ranked = scores.argsort(descending=True)
for rank, idx in enumerate(ranked, start=1):
print(f"[{rank}] score={scores[idx]:.4f}\n{paragraphs[idx]}\n")
This should rank the Dalpat Kumar / Section 36–37 passages at the top — those are the paragraphs that actually answer "what are the conditions for granting a temporary injunction," as opposed to the case's background facts or procedural history.
Splitting a judgment into paragraphs (from a PDF)
For a real workflow, you'll extract text from a judgment PDF and split it into paragraphs before encoding. Indian judgments are almost always numbered ("1. ...", "2. ...", etc.), so a simple regex on that pattern is a reasonable default:
import re
import pdfplumber
from sentence_transformers import SentenceTransformer, util, models
def extract_paragraphs(pdf_path, min_words=15):
"""Extract numbered paragraphs from a judgment PDF."""
with pdfplumber.open(pdf_path) as pdf:
full_text = "\n".join(page.extract_text() or "" for page in pdf.pages)
# Split right before a numbered paragraph marker at the start of a line,
# e.g. "\n15. The cardinal principles..."
raw_chunks = re.split(r"\n(?=\d{1,3}\.\s+[A-Z])", full_text)
paragraphs = []
for chunk in raw_chunks:
chunk = re.sub(r"\s+", " ", chunk).strip()
if len(chunk.split()) >= min_words:
paragraphs.append(chunk)
return paragraphs
# See the loading note above — constructed explicitly to avoid the Pooling
# config mismatch.
word_embedding_model = models.Transformer(
model_name_or_path="vishnuprabha72/sbert-legal-v1-legacy"
)
pooling_model = models.Pooling(
word_embedding_model.get_word_embedding_dimension(),
pooling_mode="mean",
)
model = SentenceTransformer(modules=[word_embedding_model, pooling_model])
paragraphs = extract_paragraphs("your_judgment.pdf")
print(f"Extracted {len(paragraphs)} paragraphs")
query = "your search query here"
query_embedding = model.encode(query, convert_to_tensor=True)
paragraph_embeddings = model.encode(paragraphs, convert_to_tensor=True)
scores = util.cos_sim(query_embedding, paragraph_embeddings)[0]
top_k = scores.topk(min(5, len(paragraphs)))
for score, idx in zip(top_k.values, top_k.indices):
print(f"score={score:.4f}\n{paragraphs[idx]}\n")
This regex-based splitter is intentionally simple. Real judgments contain
numbers inside citations (e.g. "33 Cal. 789") and quoted passages from other
judgments, which can occasionally get mis-split as paragraph boundaries —
always spot-check the extracted list on a document before trusting it fully,
especially for footnote-heavy judgments. If most of your paragraphs are
short (a few sentences), 256 tokens is comfortably enough and truncation
rarely matters; if you're regularly working with very long paragraphs (dense
LAW/REASON sections, 200+ words), use the
chunk-pool variant
instead, which was trained specifically to avoid truncating those.
Evaluation
Evaluated with InformationRetrievalEvaluator
on the held-out validation split.
| Metric | Value |
|---|---|
| cosine_accuracy@1 | 0.5051 |
| cosine_accuracy@3 | 0.7408 |
| cosine_accuracy@5 | 0.8375 |
| cosine_accuracy@10 | 0.925 |
| cosine_precision@1 | 0.5051 |
| cosine_precision@3 | 0.3231 |
| cosine_precision@5 | 0.2406 |
| cosine_precision@10 | 0.1484 |
| cosine_recall@1 | 0.2525 |
| cosine_recall@3 | 0.4836 |
| cosine_recall@5 | 0.5998 |
| cosine_recall@10 | 0.7393 |
| cosine_ndcg@10 | 0.5889 |
| cosine_mrr@10 | 0.6441 |
| cosine_map@100 | 0.4949 |
Training Details
Training Dataset
- Size: 90,142 training pairs (
sentence_0= query,sentence_1= positive paragraph/summary) - Source: 693 raw Indian court judgments, rhetorical-role-tagged
(
FACT,ISSUE,ARG_P,ARG_R,REASON,HOLDING,ORDER,LAW) - Dual-positive design: for every aligned
(query, content)pair, two training rows are emitted from the same alignment — one against the original judgment paragraph, one against an LLM-generated summary sentence covering the same content. This is what teaches the model cross-source semantic equivalence rather than a stylistic shortcut. - Queries: three styles per aligned pair — short bare-keyword queries,
natural one-line questions, and detailed multi-clause questions —
approximating how a lawyer might actually search, generated via
meta-llama-3-3-70b-instruct(Meta Llama 3.3) on IBM watsonx.ai. - Statistics (first 100 samples): query length averages 22.3 tokens
(7–70 range); paragraph length averages 126.9 tokens (7–256 range, capped
by
max_seq_length).
Loss Function
MultipleNegativesRankingLoss
— for each (query, positive) pair in a batch, every other positive in the
batch serves as an in-batch negative; the loss encourages the correct
pairing to score highest among all in-batch alternatives.
{
"scale": 20.0,
"similarity_fct": "cos_sim"
}
Training Hyperparameters
| Parameter | Value |
|---|---|
| Epochs | 4 |
| Learning rate | 5e-05 |
| LR scheduler | linear |
| Batch size (train/eval) | 32 |
| Optimizer | adamw_torch_fused |
| Max grad norm | 1.0 |
| Seed | 42 |
Training Logs
| Epoch | Step | Training Loss | val NDCG@10 |
|---|---|---|---|
| 0.18 | 500 | 0.6311 | – |
| 0.36 | 1000 | 0.4600 | – |
| 0.71 | 2000 | 0.3694 | – |
| 1.0 | 2817 | – | 0.5814 |
| 1.42 | 4000 | 0.2425 | – |
| 2.0 | 5634 | – | 0.5886 |
| 2.66 | 7500 | 0.1785 | – |
| 3.0 | 8451 | – | 0.5887 |
| 3.55 | 10000 | 0.1482 | – |
| 4.0 | 11268 | – | 0.5889 |
Validation performance plateaus after epoch 1 (0.5814 → 0.5889 over the remaining three epochs) while training loss continues to fall — the saved checkpoint is the epoch-4 model, which is also the best-on-validation model for this run.
Training time: 1.0 hours.
Framework Versions
- Python 3.12.13 · Sentence Transformers 5.6.0 · Transformers 5.13.1 · PyTorch 2.11.0+cu128 · Accelerate 1.14.0 · Datasets 4.0.0 · Tokenizers 0.22.2
Known Limitations
- 256-token truncation: any single paragraph or query longer than 256
tokens is silently truncated. This is rarely an issue for individual
judgment paragraphs (median well under the limit) but can matter for very
dense
LAW/REASONsections — use the chunk-pool variant for those. - Domain-specific: trained and evaluated only on Indian court judgments; not validated on other jurisdictions, statutes, contracts, or general-domain text.
- English only.
- Source bias: the model retrieves LLM-generated summary text somewhat more easily than original judgment text (style-parity mean gap around −0.07 in full-scale evaluation); this gap narrows with more training data but individual query/paragraph pairs can still differ substantially.
- Paragraph splitting is not solved by this model. How you split a judgment into candidate paragraphs (numbering-based regex, page layout, rhetorical-role tagging) directly affects retrieval quality — this model only ranks whatever candidate paragraphs you give it.
- Single training run: hyperparameters and results reflect one seed, not an average over multiple runs.
Intended Use
Retrieval of relevant paragraphs from a corpus of Indian court judgments given a natural-language legal query — e.g. as the first-stage retriever in a legal research or case-law search tool. Not intended for legal advice generation, outcome prediction, or use as a standalone decision-making system.
Citation
This model
@misc{sbert-legal-v1-legacy,
title = {SBERT Legal Paragraph Retrieval for Indian Court Judgments},
author = {Vishnuprabha V},
year = {2026},
note = {Fine-tuned from sentence-transformers/all-MiniLM-L6-v2}
}
Sentence Transformers
@inproceedings{reimers-2019-sentence-bert,
title = "Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks",
author = "Reimers, Nils and Gurevych, Iryna",
booktitle = "Proceedings of the 2019 Conference on Empirical Methods in Natural Language Processing",
month = "11",
year = "2019",
publisher = "Association for Computational Linguistics",
url = "https://arxiv.org/abs/1908.10084",
}
MultipleNegativesRankingLoss
@misc{oord2019representationlearningcontrastivepredictive,
title={Representation Learning with Contrastive Predictive Coding},
author={Aaron van den Oord and Yazhe Li and Oriol Vinyals},
year={2019},
eprint={1807.03748},
archivePrefix={arXiv},
primaryClass={cs.LG},
url={https://arxiv.org/abs/1807.03748},
}
Acknowledgements
Built on sentence-transformers/all-MiniLM-L6-v2
(Apache 2.0). Built with Llama — training queries were generated with
Meta's Llama-3.3-70B-Instruct via IBM watsonx.ai.