n4nAI

Legal document search with LangChain and hybrid retrieval

Build a production-ready legal document search system using LangChain with hybrid retrieval combining dense embeddings and BM25 sparse vectors.

n4n Team2 min read515 words

Audio narration

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

Legal document search demands precision that pure semantic search often misses. Legal language relies on specific terminology, citations, and exact phrasing where keyword overlap matters as much as conceptual similarity. This tutorial builds a hybrid retrieval system with LangChain that combines dense embeddings for semantic understanding with BM25 for exact term matching — the combination that actually works for contracts, case law, and regulatory filings.

Prerequisites

You need Python 3.10+ and an OpenAI API key (or compatible endpoint). Install the dependencies:

pip install langchain langchain-openai langchain-community \
    faiss-cpu rank-bm25 pypdf python-docx tiktoken

If you’re running against a gateway like n4n.ai, set OPENAI_BASE_URL to your endpoint and OPENAI_API_KEY to your gateway key. The code below works unchanged.

Project structure

legal-search/
├── data/                    # PDF/DOCX/TXT files go here
├── ingest.py                # One-time indexing script
├── search.py                # Query interface
├── hybrid_retriever.py      # Core hybrid retrieval logic
└── config.py                # Tunable parameters

Configuration first

Keep tunable parameters in one place so you can experiment without hunting through code.

# config.py
from dataclasses import dataclass

@dataclass
class Config:
    # Chunking
    chunk_size: int = 1000
    chunk_overlap: int = 150
    
    # Embeddings
    embedding_model: str = "text-embedding-3-small"
    
    # Retrieval
    dense_k: int = 20          # Candidates from vector store
    sparse_k: int = 20         # Candidates from BM25
    final_k: int = 5           # Results after fusion
    
    # Fusion weights (must sum to 1.0)
    dense_weight: float = 0.6
    sparse_weight: float = 0.4
    
    # Reranking (optional, requires cohere or cross-encoder)
    use_reranker: bool = False
    reranker_model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"

CONFIG = Config()

Hybrid retriever implementation

The core insight: dense vectors capture “this clause resembles that clause” while BM25 captures “this clause contains the exact phrase ‘force majeure’.” You need both.

# hybrid_retriever.py
from typing import List, Tuple
from langchain_core.documents import Document
from langchain_core.vectorstores import VectorStore
from langchain_community.retrievers import BM25Retriever
from rank_bm25 import BM25Okapi
import numpy as np

class HybridRetriever:
    """
    Combines dense vector search with BM25 sparse retrieval using
    reciprocal rank fusion (RRF). RRF is parameter-light and works
    well without training data.
    """
    
    def __init__(
        self,
        vectorstore: VectorStore,
        documents: List[Document],
        dense_k: int = 20,
        sparse_k: int = 20,
        final_k: int = 5,
        dense_weight: float = 0.6,
        sparse_weight: float = 0.4,
        rrf_k: int = 60
    ):
        self.vectorstore = vectorstore
        self.documents = documents
        self.dense_k = dense_k
        self.sparse_k = sparse_k
        self.final_k = final_k
        self.dense_weight = dense_weight
        self.sparse_weight = sparse_weight
        self.rrf_k = rrf_k
        
        # Build BM25 index from document texts
        tokenized_corpus = [doc.page_content.split() for doc in documents]
        self.bm25 = BM25Okapi(tokenized_corpus)
        self._doc_id_to_index = {id(doc): i for i, doc in enumerate(documents)}
    
    def _dense_search(self, query: str) -> List[Tuple[Document, float]]:
        """Return (doc, score) pairs from vector similarity search."""
        results = self.vectorstore.similarity_search_with_score(
            query, k=self.dense_k
        )
        # FAISS returns distance (lower = better), convert to similarity
        return [(doc, 1.0 / (1.0 + score)) for doc, score in results]
    
    def _sparse_search(self, query: str) -> List[Tuple[Document, float]]:
        """Return (doc, score) pairs from BM25."""
        tokenized_query = query.split()
        scores = self.bm25.get_scores(tokenized_query)
        top_indices = np.argsort(scores)[::-1][:self.sparse_k]
        return [(self.documents[i], float(scores[i])) for i in top_indices]
    
    def _reciprocal_rank_fusion(
        self,
        dense_results: List[Tuple[Document, float]],
        sparse_results: List[Tuple[Document, float]]
    ) -> List[Document]:
        """
        RRF: score = sum(weight / (k + rank)) for each result list.
        Rank is 1-indexed position in that list.
        """
        doc_scores = {}
        
        # Dense rankings
        for rank, (doc, _) in enumerate(dense_results, 1):
            doc_id = id(doc)
            doc_scores[doc_id] = doc_scores.get(doc_id, 0) + \
                self.dense_weight / (self.rrf_k + rank)
        
        # Sparse rankings
        for rank, (doc, _) in enumerate(sparse_results, 1):
            doc_id = id(doc)
            doc_scores[doc_id] = doc_scores.get(doc_id, 0) + \
                self.sparse_weight / (self.rrf_k + rank)
        
        # Sort by fused score
        sorted_docs = sorted(
            doc_scores.items(),
            key=lambda x: x[1],
            reverse=True
        )
        
        # Return top-k documents (deduplicated by id)
        seen = set()
        final = []
        for doc_id, _ in sorted_docs:
            if doc_id not in seen:
                seen.add(doc_id)
                # Find the document object
                for doc in self.documents:
                    if id(doc) == doc_id:
                        final.append(doc)
                        break
            if len(final) >= self.final_k:
                break
        
        return final
    
    def get_relevant_documents(self, query: str) -> List[Document]:
        dense_results = self._dense_search(query)
        sparse_results = self._sparse_search(query)
        return self._reciprocal_rank_fusion(dense_results, sparse_results)
    
    # LangChain Runnable interface
    def invoke(self, input: dict) -> List[Document]:
        return self.get_relevant_documents(input["query"])

Document ingestion pipeline

Legal documents need structure-aware chunking. A 50-page contract split at arbitrary token boundaries loses section context. Use a recursive splitter that respects headers, then enrich chunks with metadata.

# ingest.py
import os
import pickle
from pathlib import Path
from langchain_community.document_loaders import (
    PyPDFLoader,
    Docx2txtLoader,
    TextLoader
)
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
from config import CONFIG

DATA_DIR = Path("data")
INDEX_DIR = Path("index")
INDEX_DIR.mkdir(exist_ok=True)

def load_documents(data_dir: Path) -> list:
    """Load all supported documents from data directory."""
    docs = []
    for file_path in data_dir.rglob("*"):
        if file_path.suffix.lower() == ".pdf":
            loader = PyPDFLoader(str(file_path))
        elif file_path.suffix.lower() in [".docx", ".doc"]:
            loader = Docx2txtLoader(str(file_path))
        elif file_path.suffix.lower() in [".txt", ".md"]:
            loader = TextLoader(str(file_path), encoding="utf-8")
        else:
            continue
        
        loaded = loader.load()
        # Tag each chunk with source metadata
        for doc in loaded:
            doc.metadata["source_file"] = file_path.name
            doc.metadata["source_path"] = str(file_path)
        docs.extend(loaded)
    return docs

def chunk_documents(documents: list) -> list:
    """Split documents preserving legal structure."""
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=CONFIG.chunk_size,
        chunk_overlap=CONFIG.chunk_overlap,
        separators=[
            "\n\nArticle ", "\n\nSection ", "\n\n§ ",
            "\n\n", "\n", ". ", " ", ""
        ],
        length_function=len,
    )
    return splitter.split_documents(documents)

def build_index(chunks: list) -> FAISS:
    """Create FAISS index with OpenAI embeddings."""
    embeddings = OpenAIEmbeddings(model=CONFIG.embedding_model)
    vectorstore = FAISS.from_documents(chunks, embeddings)
    return vectorstore

def main():
    print("Loading documents...")
    raw_docs = load_documents(DATA_DIR)
    print(f"Loaded {len(raw_docs)} raw documents")
    
    print("Chunking...")
    chunks = chunk_documents(raw_docs)
    print(f"Created {len(chunks)} chunks")
    
    print("Building vector index...")
    vectorstore = build_index(chunks)
    
    print("Saving index and chunks...")
    vectorstore.save_local(str(INDEX_DIR))
    
    # Save chunks separately for BM25 (FAISS doesn't store full docs reliably)
    with open(INDEX_DIR / "chunks.pkl", "wb") as f:
        pickle.dump(chunks, f)
    
    print(f"Index saved to {INDEX_DIR}")

if __name__ == "__main__":
    main()

Run it once:

python ingest.py

Expected output:

Loading documents...
Loaded 12 raw documents
Chunking...
Created 347 chunks
Building vector index...
Saving index and chunks...
Index saved to index

Search interface

Now wire the hybrid retriever into a query loop. This version includes optional cross-encoder reranking for the final pass — useful when precision at k=3 matters more than latency.

# search.py
import pickle
from pathlib import Path
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from hybrid_retriever import HybridRetriever
from config import CONFIG

INDEX_DIR = Path("index")

LEGAL_QA_PROMPT = ChatPromptTemplate.from_template("""You are a legal research assistant. Answer the question using only the provided context from legal documents.

Context:
{context}

Question: {question}

Instructions:
- Cite specific clauses, sections, or page references when possible
- If the context doesn't contain the answer, say so explicitly
- Distinguish between binding holdings and dicta in case law
- Note jurisdiction and date relevance for statutes/regulations

Answer:""")

def format_docs(docs: list) -> str:
    """Format retrieved documents with source metadata for the LLM."""
    formatted = []
    for i, doc in enumerate(docs, 1):
        source = doc.metadata.get("source_file", "unknown")
        page = doc.metadata.get("page", "unknown")
        formatted.append(
            f"[Source {i}: {source}, page {page}]\n{doc.page_content}"
        )
    return "\n\n---\n\n".join(formatted)

def build_chain():
    # Load vectorstore and chunks
    embeddings = OpenAIEmbeddings(model=CONFIG.embedding_model)
    vectorstore = FAISS.load_local(
        str(INDEX_DIR),
        embeddings,
        allow_dangerous_deserialization=True
    )
    
    with open(INDEX_DIR / "chunks.pkl", "rb") as f:
        chunks = pickle.load(f)
    
    # Initialize hybrid retriever
    retriever = HybridRetriever(
        vectorstore=vectorstore,
        documents=chunks,
        dense_k=CONFIG.dense_k,
        sparse_k=CONFIG.sparse_k,
        final_k=CONFIG.final_k,
        dense_weight=CONFIG.dense_weight,
        sparse_weight=CONFIG.sparse_weight,
    )
    
    # Optional reranker
    if CONFIG.use_reranker:
        from langchain_community.cross_encoders import HuggingFaceCrossEncoder
        from langchain.retrievers.document_compressors import CrossEncoderReranker
        
        cross_encoder = HuggingFaceCrossEncoder(
            model_name=CONFIG.reranker_model
        )
        reranker = CrossEncoderReranker(model=cross_encoder, top_n=CONFIG.final_k)
        
        # Wrap retriever to apply reranking
        class RerankingRetriever:
            def __init__(self, base_retriever, reranker):
                self.base = base_retriever
                self.reranker = reranker
            
            def get_relevant_documents(self, query: str):
                docs = self.base.get_relevant_documents(query)
                return self.reranker.compress_documents(docs, query)
            
            def invoke(self, input: dict):
                return self.get_relevant_documents(input["query"])
        
        retriever = RerankingRetriever(retriever, reranker)
    
    # LLM for answer generation
    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
    
    # RAG chain
    chain = (
        {"context": retriever | format_docs, "question": RunnablePassthrough()}
        | LEGAL_QA_PROMPT
        | llm
        | StrOutputParser()
    )
    
    return chain, retriever

def main():
    chain, retriever = build_chain()
    
    print("Legal Document Search Ready")
    print("Type 'exit' to quit\n")
    
    while True:
        query = input("Query: ").strip()
        if query.lower() in ("exit", "quit", "q"):
            break
        
        if not query:
            continue
        
        print("\nRetrieving...")
        # Show retrieved sources first
        docs = retriever.invoke({"query": query})
        print(f"Retrieved {len(docs)} chunks:")
        for i, doc in enumerate(docs, 1):
            source = doc.metadata.get("source_file", "unknown")
            page = doc.metadata.get("page", "unknown")
            preview = doc.page_content[:120].replace("\n", " ")
            print(f"  [{i}] {source} p.{page}: {preview}...")
        
        print("\nGenerating answer...")
        answer = chain.invoke(query)
        print(f"\nAnswer:\n{answer}\n")
        print("-" * 80)

if __name__ == "__main__":
    main()

Run the search interface:

python search.py

Example session:

Legal Document Search Ready
Type 'exit' to quit

Query: What are the force majeure notification requirements in the supplier agreement?

Retrieving...
Retrieved 5 chunks:
  [1] supplier_agreement_v3.pdf p.12: ...force majeure event, the affected party shall notify the other party in writing within five (5) business days...
  [2] supplier_agreement_v3.pdf p.13: ...notification shall describe the nature of the force majeure event, its expected duration, and the steps being taken...
  [3] msa_template.docx p.8: ...Force Majeure. Either party may terminate this Agreement without liability if performance is prevented for more than ninety (90) consecutive days...
  [4] supplier_agreement_v3.pdf p.11: ...including but not limited to acts of God, war, terrorism, labor disputes, government actions, and internet infrastructure failures...
  [5] nda_standard.pdf p.3: ...This Agreement shall be governed by the laws of the State of Delaware...

Generating answer...

Answer:
Based on the supplier agreement (v3), the force majeure notification requirements are:

1. **Timing**: The affected party must notify the other party in writing within **five (5) business days** of the force majeure event (supplier_agreement_v3.pdf, p.12).

2. **Content requirements**: The notification must describe:
   - The nature of the force majeure event
   - Its expected duration
   - The steps being taken to mitigate or resume performance (supplier_agreement_v3.pdf, p.13)

3. **Qualifying events**: The agreement includes acts of God, war, terrorism, labor disputes, government actions, and internet infrastructure failures (supplier_agreement_v3.pdf, p.11).

Note: The MSA template (p.8) contains a separate termination right if performance is prevented for more than 90 consecutive days, but this appears to be a different agreement.

--------------------------------------------------------------------------------

Dense vectors catch semantically similar clauses — “termination for convenience” matches “termination without cause” even when wording differs. BM25 catches exact legal terms — “force majeure,” “indemnification,” “material adverse change” — that embeddings sometimes blur. RRF fusion needs no training data and handles the score scale mismatch automatically.

The chunking strategy matters. Legal documents have hierarchical structure (Article → Section → Subsection). The recursive splitter with legal-aware separators keeps related provisions together. Metadata preservation (source file, page number) lets the LLM cite precisely.

Tuning levers

Parameter Effect Start here
dense_weight / sparse_weight Balance semantic vs. keyword 0.6 / 0.4
dense_k, sparse_k Candidate pool size before fusion 20 each
final_k Results passed to LLM 5
chunk_size Context window per chunk 1000 tokens
chunk_overlap Boundary continuity 150 tokens
use_reranker Precision boost at latency cost False initially

Increase sparse_weight for statute-heavy corpora where exact citation matching dominates. Increase dense_weight for case law where conceptual similarity matters more. Enable the cross-encoder reranker when you’ve validated the retrieval quality and need the last 5-10% precision gain.

Production considerations

  • Incremental updates: FAISS doesn’t support true incremental adds efficiently. Rebuild nightly or use a vector DB (Pinecone, Weaviate, Qdrant) that does.
  • Metadata filtering: Add pre-filtering by jurisdiction, date range, or document type before hybrid retrieval. FAISS supports metadata filtering via similarity_search_with_score kwargs.
  • Query rewriting: Legal queries benefit from expansion — “termination clause” → “termination clause OR termination provision OR termination section OR force majeure OR material breach.”
  • Evaluation: Build a small gold set (20-50 queries with known relevant passages). Measure recall@k and MRR before trusting the system.

The hybrid approach isn’t magic — it’s the pragmatic recognition that legal language lives in two regimes simultaneously: the semantic and the exact. Build for both.

Tagslangchainlegal-techhybrid-search

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 →