?

Boss45 minIntro+60 XP

Designing a Legal Document Search System.

A Real-World Problem Covering Everything You've Learned

6 phases5 code

Boss · W07 · 6 phases · 50 min

Fight it like the real interview: each phase runs against its time budget, you write your answer in a scratchpad first, then reveal the model answer side by side and grade yourself.

or read the model answer below
watch it first · newThe whole lesson in a 21-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Intro · 1 min

The Interview Begins#

You walk into the interview room at a legal technology company. The interviewer, a Staff Engineer, smiles and gestures to the whiteboard.

Interviewer

"Thanks for coming in. Today we're going to work through a system design problem that's core to our business. I'm interested in your thought process, so please think out loud. Feel free to ask questions — this is meant to be collaborative."

They write on the whiteboard:

Design a Legal Document Search System
Context

You're building search infrastructure for a legal research platform used by law firms, corporate legal departments, and courts. Lawyers need to find relevant case law, contracts, and legal documents quickly and accurately.

Key Requirements
  1. Search across 50M+ legal documents (cases, statutes, contracts)
  2. Support complex boolean queries and proximity search
  3. Citation tracking and cross-referencing
  4. Jurisdiction and date filtering
  5. Document upload and OCR processing
  6. Relevance tuned for legal terminology
Interviewer

"Take a few minutes to think about this, then walk me through your approach. We have about 50 minutes."

ExplainerThe whole capstone: legal document search

Lawyers search 50M+ public legal documents and their firms' private uploads with boolean, proximity and citation queries; 100M documents at ~65 KB need 26 TB in Elasticsearch plus 50 TB of PDFs, and 50,000 users at 20 searches an hour make about 280 searches a second. The design puts a JWT gateway in front of search, document and citation services, with uploads flowing S3 → OCR → Kafka → indexer into a per-firm private index in 2 to 3 minutes. The deep dives cover the query parser (lexer, AST, ES bool query), five-layer tenant isolation, the citation network (Brown v. Board: 15,234 citations, 0 overruled) and ingestion triage, then 10x scale with type and date indices, hot/warm/cold tiers, alerts on any cross-tenant attempt, and building tenant isolation first.

Phase 1 · Stage 1 of 6 · 5 min budget · 3 min read

Phase 1: Requirements Clarification (5 minutes)#

Before diving in, you take a breath and start asking questions.

Your Questions#

Transcript
You

"Before I start designing, I'd like to clarify a few requirements. First, what types of legal documents are we indexing?"

Interviewer

"Good question. We have several document types:

  • Court opinions and case law (federal and state courts)
  • Statutes and regulations
  • Legal briefs and motions
  • Contracts (uploaded by clients)
  • Legal journals and secondary sources
  • Patent filings"
Transcript
You

"What's the average document size? Legal documents can be quite lengthy."

Interviewer

"They vary significantly. Court opinions average 15-20 pages, but some are 200+ pages. Contracts are typically 20-50 pages. We also have multi-volume regulatory codes. Average document is about 10,000 words, but the 99th percentile is 100,000 words."

You

"For search, what are the primary use cases? Simple keyword search, or do lawyers need more advanced query capabilities?"

Interviewer

"Lawyers need sophisticated search:

  • Boolean operators (AND, OR, NOT)
  • Proximity search ('negligence' within 10 words of 'liability')
  • Phrase search with exact matching
  • Citation search (find all cases citing '410 U.S. 113')
  • Field-specific search (judge:Ginsburg, court:Supreme Court)
  • Date and jurisdiction filters"
Transcript
You

"How fresh does the data need to be? When a new court opinion is published, how quickly should it be searchable?"

Interviewer

"For court opinions from major courts, within 1 hour of publication. For user-uploaded documents, within 5 minutes. We have partnerships with court electronic filing systems for real-time feeds."

You

"What about document security? I imagine some uploaded contracts are highly confidential."

Interviewer

"Absolutely critical. Public court documents are accessible to all subscribers. But client-uploaded documents must be strictly isolated — only that law firm's users can search them. We call these 'private workspaces'."

You

"Last question — what's our user base and expected query volume?"

Interviewer

"We have 5,000 law firm subscribers with about 200,000 individual lawyer accounts. During business hours, we see 5,000-10,000 searches per minute. Paralegals doing research can run hundreds of searches per hour."

You

"Perfect. Let me summarize the requirements."

Functional Requirements#

Functional requirements
1. Document search
  • Full-text search across all document content
  • Boolean queries (AND, OR, NOT, parentheses)
  • Proximity search (terms within N words)
  • Phrase search with exact matching
  • Wildcard and fuzzy matching
  • Field-specific search (title, judge, court, parties)
2. Filtering & facets
  • Jurisdiction (federal, state, specific courts)
  • Date range (decided date, filed date)
  • Document type (opinion, statute, contract, brief)
  • Practice area (criminal, corporate, IP, family)
  • Judge/Author
  • Citation count (highly cited cases)
3. Citation network
  • Find all documents citing a specific case
  • Find all cases cited by a document
  • Citation depth (cases citing cases that cite X)
  • Negative citations (cases that overrule or distinguish)
4. Private workspaces
  • Law firms upload their own documents
  • OCR processing for scanned documents
  • Strict tenant isolation
  • Combined search (public + private documents)
5. Document processing
  • PDF/Word/Image ingestion
  • OCR for scanned documents
  • Automatic metadata extraction
  • Citation extraction and linking
6. User features
  • Save searches and set alerts
  • Highlight and annotate documents
  • Search history and recent documents
  • Export and print formatting

Non-Functional Requirements#

Non-functional requirements
1. Scale
  • 50M+ public documents
  • ~50M private documents across tenants (varies widely)
  • 200,000 registered users
  • 10,000 searches/minute peak
2. Latency
  • Simple search: <500ms p99
  • Complex boolean: <2s p99
  • Autocomplete: <100ms p99
  • Document indexing: <5 minutes p99
3. Availability
  • 99.9% uptime (about 8.8 hours downtime/year)
  • Graceful degradation during failures
  • Read replicas for search continuity
4. Data freshness
  • Court opinions: <1 hour from publication
  • User uploads: <5 minutes
  • Citation links: <24 hours
5. Security
  • Strict tenant isolation for private docs
  • Audit logging for all access
  • Encryption at rest and in transit
  • SOC 2 Type II compliance
6. Accuracy
  • Zero false positives in tenant isolation
  • High precision for legal terminology
  • Proper handling of legal citations
Your turn · checkpoint 1not answered

Before the numbers: how big, how busy?

Take 100M documents (50M public, 50M private) averaging 10,000 words, plus a few KB each of metadata and citations. Assume the index doubles the raw text and you keep one replica. At peak, 50,000 lawyers are active, each running 20 searches an hour, and each search costs about 5 autocomplete calls. Estimate the Elasticsearch footprint and the peak searches and autocomplete calls per second.

Think it through, or jot it down. Then compare.

One strong answer

  • Per document: 10,000 words ≈ 60 KB of text, plus metadata and citations ≈ 65 KB.
  • Raw text: 100M × 65 KB ≈ 6.5 TB; × 2 for index structures = 13 TB; × 2 for the replica = 26 TB. The original PDFs live in object storage, not in the index.
  • Searches: 50,000 × 20 = 1M an hour ≈ 280/s, comfortably above today's stated peak of 10,000 a minute (≈ 170/s).
  • Autocomplete: 280 × 5 ≈ 1,400/s, the busiest path, so it has the tightest latency budget.

Phase 2 · Stage 2 of 6 · 5 min budget · 2 min read

Phase 2: Back of the Envelope Estimation (5 minutes)#

You

"Let me work through the numbers to understand the scale."

Document Volume#

Document counts
Public documents
Type Count
Court opinions 30M
Statutes & regulations 5M
Legal journals 10M
Patent filings 5M
Total public 50M documents
Private documents
Average per law firm 10,000 documents
Number of firms 5,000
Total private 50M documents (varies widely)
Growth
  • New court opinions: 50,000/month
  • New user uploads: 500,000/month

Storage Estimation#

WorksheetDocument size and storage calculation
  • Average document: 10,000 words ≈ 60KB text
  • Large documents (p99): 100,000 words ≈ 600KB text
  • Metadata per document: 2KB
  • Citation data: average 20 citations × 50 bytes = 1KB
  • Total per document: ~65KB average
  • Raw text (100M docs): 100M × 65KB = 6.5 TB
  • Elasticsearch index: with overhead (2x) = 13 TB; with replicas (2x) = 26 TB
  • Original files (PDFs): 100M × 500KB avg = 50 TB
  • Total storage: ~76 TB (26 + 50)

Search Traffic#

WorksheetSearch queries
  • Active users: 50,000 concurrent (peak)
  • Searches per user/hour: 20 (intensive research)
  • Peak searches: 1M/hour = 280/second
  • (Stated peak today: 10,000/minute ≈ 170/second; sizing for 280/second leaves ~1.7x headroom)
  • Query complexity: simple keyword 40% · boolean 35% · proximity 15% · citation search 10%
  • Autocomplete: 5 keystrokes per search
  • Autocomplete peak: 280 × 5 = 1,400/second

Key Metrics Summary#

Estimation summary
Documents
  • Total documents: 100M (50M public + 50M private)
  • New documents/month: 550K
  • Average size: 65KB indexed content
Storage
  • Elasticsearch: 26 TB (with replicas)
  • Original files: 50 TB
  • Total: ~76 TB
Traffic
  • Peak searches: 300/second
  • Peak autocomplete: 1,500/second
  • Indexing: ~13 docs/minute average (550K/month), bursty
Infrastructure (estimated)
Role Count × size
ES data nodes 12 × 64GB RAM, 4TB SSD
ES master nodes 3 × 16GB RAM
ES coordinating 4 × 32GB RAM
Document processors 6 × 16 CPU (OCR/extraction)
Quick check2 questions · +5 XP each

Estimating legal search scale

  1. Q1/2 The estimate assumes 50,000 concurrent users at peak, each running 20 searches an hour. About what search rate is that?

    Answer & explanation

    Answer: C

    50,000 x 20 = 1M searches/hour, and 1,000,000 / 3,600 is about 280/second. At about 5 keystrokes per search, autocomplete runs roughly 5x that, about 1,400/second.

  2. Q2/2 100M documents at about 65 KB each come to about 6.5 TB of raw text. Why is the Elasticsearch footprint 26 TB?

    Answer & explanation

    Answer: A

    6.5 TB x 2 (index structures) = 13 TB, and x 2 for replicas = 26 TB. The 50 TB of original PDFs lives in S3, not in Elasticsearch.

Phase 3 · Stage 3 of 6 · 10 min budget · 3 min read

Phase 3: High-Level Design (10 minutes)#

You

"Now let me sketch out the high-level architecture."

System Architecture#

DiagramLegal document search architecture
%% title: Legal document search architecture
flowchart TD
  WEB@{ icon: "sd:browser", label: "Web App" } --> GW
  MOB@{ icon: "sd:phone", label: "Mobile App" } --> GW
  APIU@{ icon: "sd:code", label: "API Users" } --> GW
  GW@{ icon: "sd:gateway", label: "API Gateway<br/>(Auth, Rate Limit)" }
  GW --> SS["Search Service"]:::core
  GW --> DS["Document Service"]:::core
  GW --> CS["Citation Service"]:::core
  DS --> OCR@{ icon: "sd:scan", label: "OCR Pipeline" }
  DS --> ME["Metadata Extractor"]:::service
  OCR --> K@{ icon: "tech:kafka", label: "Kafka Topics<br/>(CDC Events)" }
  ME --> K
  K --> IP["Indexing Pipeline"]:::service
  subgraph ESC["Elasticsearch cluster"]
    direction TB
    PUB@{ icon: "tech:elasticsearch", label: "Public Docs Index<br/>(50M docs)" }
    PRIV@{ icon: "tech:elasticsearch", label: "Private Docs Index<br/>(per tenant)" }
    CIT@{ icon: "tech:elasticsearch", label: "Citations Index<br/>(graph)" }
  end
  SS --> ESC
  IP --> ESC
  CS --> ESC
  subgraph DATA["Supporting stores"]
    direction TB
    PG@{ icon: "tech:postgresql", label: "PostgreSQL (metadata)" }
    RD@{ icon: "tech:redis", label: "Redis (cache)" }
    S3@{ icon: "tech:s3", label: "S3 (file store)" }
  end
  ESC ~~~ DATA
  class WEB,MOB,APIU client
  class OCR service
  class K queue
  class PUB,PRIV,CIT,PG,S3 store
  class RD cache
  class GW edge

Component Breakdown#

You

"Let me walk through each component..."

1. Search Service#

Purpose: Handles all search queries from users

Key responsibilities:

  • Parse complex boolean and proximity queries
  • Route to appropriate index (public, private, or both)
  • Apply tenant isolation filters
  • Aggregate results across indices
  • Handle highlighting and snippets

Technology choice: Python/FastAPI for flexibility in query parsing

2. Document Service#

Purpose: Manages document ingestion and processing

Key responsibilities:

  • Accept document uploads (PDF, Word, images)
  • Trigger OCR for scanned documents
  • Extract metadata (dates, parties, judges)
  • Detect and link citations
  • Publish to Kafka for indexing

Technology choice: Python with Apache Tika for extraction

3. Citation Service#

Purpose: Manages the citation network

Key responsibilities:

  • Parse legal citations from text
  • Build citation graph (who cites whom)
  • Detect citation types (following, distinguishing, overruling)
  • Provide citation depth queries

Technology choice: Specialized citation parser + Neo4j or ES for graph

4. Indexing Pipeline#

Purpose: Streams documents into Elasticsearch

Key responsibilities:

  • Consume from Kafka topics
  • Transform documents for search
  • Apply legal-specific text analysis
  • Handle bulk indexing efficiently
  • Maintain citation links

Data Flow#

You

"Let me trace through a document upload flow..."

DiagramDocument upload flow
%% title: Document upload flow
sequenceDiagram
  autonumber
  participant C as Client
  participant GW as API Gateway
  participant DS as Document Service
  participant S3 as S3
  participant PX as OCR / Metadata / Citation Extractors
  participant K as Kafka
  participant IP as Indexing Pipeline
  participant CS as Citation Service
  participant ES as Elasticsearch
  Note over C,DS: Step 1 · user uploads contract PDF
  C->>GW: upload contract PDF
  GW->>DS: forward
  Note over DS,S3: Step 2 · store original file
  DS->>S3: original PDF stored
  Note over DS,PX: Step 3 · process document
  DS->>PX: OCR Pipeline (if scanned), Metadata Extractor, Citation Extractor
  Note over DS,K: Step 4 · publish for indexing
  DS->>K: document.created topic
  Note over K,ES: Step 5 · index in Elasticsearch
  K->>IP: consume
  IP->>ES: index (private_docs_tenant_123)
  Note over K,ES: Step 6 · update citation graph
  K->>CS: consume
  CS->>ES: update citations index
  Note over C,ES: Time: upload to searchable ≈ 2-3 minutes
Quick check2 questions · +5 XP each

High-level design

  1. Q1/2 The Document Service publishes processed documents to Kafka instead of writing straight to Elasticsearch. What does that buy?

    Answer & explanation

    Answer: B

    As in the Day 2 pipeline, Kafka buffers and persists events, and here both the Indexing Pipeline and the Citation Service consume document.created independently. Isolation is enforced at the search layer, not by Kafka.

  2. Q2/2 A firm uploads a scanned contract. Which order do the main steps of the upload flow run in?

    Answer & explanation

    Answer: D

    The original is saved first so nothing is lost, then text and metadata are extracted, then the processed document goes to Kafka, where the indexer writes it to private_docs_<tenant> and the citation service updates the graph.

Phase 4 · Stage 4 of 6 · 20 min budget · 31 min read

Phase 4: Deep Dives (20 minutes)#

Interviewer

"Great high-level design. Let's dive deeper into a few areas. Tell me more about how you'd handle the complex boolean and proximity queries lawyers need."

You

"Legal search requires query capabilities beyond standard full-text search. Let me explain how I'd build the query parser."

The Problem#

Legal query examples
Simple boolean
  • "negligence AND liability"
  • "contract OR agreement"
  • "patent NOT software"
Complex boolean
  • "(breach AND contract) OR (negligence AND tort)"
Proximity search
  • "negligence /10 liability" — within 10 words
  • "intellectual property /s patent" — /s = same sentence
  • "employment /p discrimination" — /p = same paragraph
Field-specific
  • "judge:Ginsburg AND civil rights"
  • "court:SCOTUS AND date:[2020 TO 2024]"
Citation search
  • "cites:410 U.S. 113" — find all citing this case
  • "citedby:410 U.S. 113" — find all cases cited by
Phrase search
  • "reasonable person standard"
  • "beyond a reasonable doubt"

The Solution#

DiagramQuery parsing pipeline (legal query parser)
%% title: Query parsing pipeline (legal query parser)
flowchart TD
  IN(["Input: (breach /5 contract) AND damages NOT punitive"]):::client
  IN --> LEX["Lexer (tokenize)"]:::core
  LEX --> TOK["Tokens:<br/>[LPAREN, TERM, PROXIMITY, TERM,<br/>RPAREN, AND, TERM, NOT, TERM]"]:::note
  TOK --> PAR["Parser (AST)"]:::core
  PAR --> AST["AST:<br/>BoolNode(AND, ProximityNode,<br/>TermNode, NotNode)"]:::note
  AST --> ESB["ES Query Builder"]:::core
  ESB --> ESQ["ES Query:<br/>bool · must: span_near, match<br/>must_not: match"]:::store

Implementation#

pythonTokenType · 459 lines
# search/legal_query_parser.py

"""
Legal query parser supporting boolean and proximity search.

Applies: Week 7, Day 3 - Query Processing
"""

from dataclasses import dataclass
from typing import List, Optional, Union
from enum import Enum, auto
import re


class TokenType(Enum):
    """Token types in legal queries."""
    TERM = auto()
    PHRASE = auto()
    AND = auto()
    OR = auto()
    NOT = auto()
    LPAREN = auto()
    RPAREN = auto()
    PROXIMITY = auto()      # /5, /10, /s, /p
    FIELD = auto()          # judge:, court:, date:
    CITATION = auto()       # cites:, citedby:
    RANGE = auto()          # [2020 TO 2024]


@dataclass
class Token:
    """A lexical token."""
    type: TokenType
    value: str
    position: int


class LegalQueryLexer:
    """
    Tokenizes legal search queries.
    """
    
    KEYWORDS = {
        'AND': TokenType.AND,
        'OR': TokenType.OR,
        'NOT': TokenType.NOT,
        'AND NOT': TokenType.NOT,
    }
    
    FIELD_PREFIXES = ['judge:', 'court:', 'date:', 'party:', 'title:']
    CITATION_PREFIXES = ['cites:', 'citedby:', 'citing:']
    
    PROXIMITY_PATTERN = re.compile(r'/(\d+|s|p)')
    
    def tokenize(self, query: str) -> List[Token]:
        """Tokenize a legal query string."""
        
        tokens = []
        position = 0
        
        while position < len(query):
            # Skip whitespace
            if query[position].isspace():
                position += 1
                continue
            
            # Parentheses
            if query[position] == '(':
                tokens.append(Token(TokenType.LPAREN, '(', position))
                position += 1
                continue
            
            if query[position] == ')':
                tokens.append(Token(TokenType.RPAREN, ')', position))
                position += 1
                continue
            
            # Phrase (quoted string)
            if query[position] == '"':
                end = query.find('"', position + 1)
                if end == -1:
                    end = len(query)
                phrase = query[position+1:end]
                tokens.append(Token(TokenType.PHRASE, phrase, position))
                position = end + 1
                continue
            
            # Proximity operator
            if query[position] == '/':
                match = self.PROXIMITY_PATTERN.match(query[position:])
                if match:
                    tokens.append(Token(TokenType.PROXIMITY, match.group(1), position))
                    position += len(match.group(0))
                    continue
            
            # Range [2020 TO 2024]
            if query[position] == '[':
                end = query.find(']', position)
                if end != -1:
                    range_expr = query[position:end+1]
                    tokens.append(Token(TokenType.RANGE, range_expr, position))
                    position = end + 1
                    continue
            
            # Word or keyword
            word_match = re.match(r'[\w\.:]+', query[position:])
            if word_match:
                word = word_match.group(0)
                
                # Check for field prefix
                for prefix in self.FIELD_PREFIXES:
                    if word.lower().startswith(prefix):
                        tokens.append(Token(TokenType.FIELD, word, position))
                        break
                else:
                    # Check for citation prefix
                    for prefix in self.CITATION_PREFIXES:
                        if word.lower().startswith(prefix):
                            # Reporter citations contain spaces: "410 U.S. 113"
                            cite = re.match(r'\d+\s+[A-Za-z][\w\.]*\s+\d+',
                                            query[position + len(prefix):])
                            if cite:
                                word = prefix + cite.group(0)
                            tokens.append(Token(TokenType.CITATION, word, position))
                            break
                    else:
                        # Check for keyword
                        upper = word.upper()
                        if upper in self.KEYWORDS:
                            tokens.append(Token(self.KEYWORDS[upper], word, position))
                        else:
                            tokens.append(Token(TokenType.TERM, word, position))
                
                position += len(word)
                continue
            
            # Unknown character, skip
            position += 1
        
        return tokens


class LegalQueryBuilder:
    """
    Builds Elasticsearch queries from parsed legal queries.
    """
    
    def __init__(self):
        self.lexer = LegalQueryLexer()
    
    def build_query(
        self,
        query_string: str,
        tenant_id: Optional[str] = None,
        search_fields: List[str] = None
    ) -> dict:
        """
        Build Elasticsearch query from legal query string.
        
        Applies concepts:
        - Query vs Filter context (Day 3)
        - Multi-match for field boosting (Day 3)
        - Span queries for proximity (new in this capstone)
        """
        
        if not search_fields:
            search_fields = ["content", "title^2", "summary^1.5"]
        
        tokens = self.lexer.tokenize(query_string)
        
        # Parse tokens into ES query
        es_query = self._parse_expression(tokens, search_fields)
        
        # Wrap with filters
        final_query = {
            "query": {
                "bool": {
                    "must": [es_query],
                    "filter": []
                }
            }
        }
        
        # Add tenant isolation filter (critical for security!)
        if tenant_id:
            final_query["query"]["bool"]["filter"].append({
                "term": {"tenant_id": tenant_id}
            })
        
        return final_query
    
    def _parse_expression(
        self,
        tokens: List[Token],
        fields: List[str]
    ) -> dict:
        """Parse tokens into ES query structure."""
        
        if not tokens:
            return {"match_all": {}}
        
        # Simple case: single term
        if len(tokens) == 1:
            return self._token_to_query(tokens[0], fields)
        
        # Handle boolean operators
        must_clauses = []
        should_clauses = []
        must_not_clauses = []
        
        i = 0
        current_operator = TokenType.AND  # Default is AND
        
        while i < len(tokens):
            token = tokens[i]
            
            if token.type == TokenType.AND:
                current_operator = TokenType.AND
                i += 1
                continue
            
            if token.type == TokenType.OR:
                current_operator = TokenType.OR
                # The left operand becomes one alternative of the OR group
                # (simplified precedence: OR groups are ANDed with the rest)
                if must_clauses and not should_clauses:
                    should_clauses.append(must_clauses.pop())
                i += 1
                continue
            
            if token.type == TokenType.NOT:
                # Next token goes to must_not
                if i + 1 < len(tokens):
                    must_not_clauses.append(
                        self._token_to_query(tokens[i + 1], fields)
                    )
                    i += 2
                    continue
            
            if token.type == TokenType.LPAREN:
                # Find matching paren and recurse
                paren_depth = 1
                j = i + 1
                while j < len(tokens) and paren_depth > 0:
                    if tokens[j].type == TokenType.LPAREN:
                        paren_depth += 1
                    elif tokens[j].type == TokenType.RPAREN:
                        paren_depth -= 1
                    j += 1
                
                # Unbalanced: auto-close the group at the end of the query
                end = j - 1 if paren_depth == 0 else j
                sub_tokens = tokens[i + 1:end]
                sub_query = self._parse_expression(sub_tokens, fields)
                
                if current_operator == TokenType.OR:
                    should_clauses.append(sub_query)
                else:
                    must_clauses.append(sub_query)
                
                i = j
                continue
            
            if token.type == TokenType.PROXIMITY:
                # Proximity: previous term /N next term
                if must_clauses and i + 1 < len(tokens):
                    prev_query = must_clauses.pop()
                    next_query = self._token_to_query(tokens[i + 1], fields)
                    
                    proximity = self._parse_proximity(token.value)
                    span_query = self._build_span_near(
                        prev_query, next_query, proximity, fields[0]
                    )
                    must_clauses.append(span_query)
                    i += 2
                    continue
            
            # Regular term/phrase
            term_query = self._token_to_query(token, fields)
            
            if current_operator == TokenType.OR:
                should_clauses.append(term_query)
            else:
                must_clauses.append(term_query)
            
            i += 1
        
        # Build final bool query
        bool_query = {"bool": {}}
        
        if must_clauses:
            bool_query["bool"]["must"] = must_clauses
        if should_clauses:
            bool_query["bool"]["should"] = should_clauses
            bool_query["bool"]["minimum_should_match"] = 1
        if must_not_clauses:
            bool_query["bool"]["must_not"] = must_not_clauses
        
        return bool_query
    
    def _token_to_query(self, token: Token, fields: List[str]) -> dict:
        """Convert a single token to ES query."""
        
        if token.type == TokenType.TERM:
            return {
                "multi_match": {
                    "query": token.value,
                    "fields": fields,
                    "type": "best_fields"
                }
            }
        
        if token.type == TokenType.PHRASE:
            return {
                "multi_match": {
                    "query": token.value,
                    "fields": fields,
                    "type": "phrase"
                }
            }
        
        if token.type == TokenType.FIELD:
            # Parse field:value
            field, value = token.value.split(':', 1)
            return {
                "match": {field: value}
            }
        
        if token.type == TokenType.CITATION:
            # Handle citation search
            prefix, citation = token.value.split(':', 1)
            if prefix.lower() == 'cites':
                return {
                    "term": {"citations.cited_id": citation}
                }
            elif prefix.lower() == 'citedby':
                return {
                    "term": {"citations.citing_id": citation}
                }
        
        if token.type == TokenType.RANGE:
            # Parse [2020 TO 2024]
            match = re.match(r'\[(\S+)\s+TO\s+(\S+)\]', token.value)
            if match:
                return {
                    "range": {
                        "date": {
                            "gte": match.group(1),
                            "lte": match.group(2)
                        }
                    }
                }
        
        return {"match_all": {}}
    
    def _parse_proximity(self, value: str) -> int:
        """Parse proximity value to integer."""
        if value == 's':
            return 15  # Same sentence ≈ 15 words
        if value == 'p':
            return 50  # Same paragraph ≈ 50 words
        return int(value)
    
    def _build_span_near(
        self,
        query1: dict,
        query2: dict,
        slop: int,
        field: str
    ) -> dict:
        """Build span_near query for proximity search."""
        
        # Extract terms from queries
        term1 = self._extract_term(query1)
        term2 = self._extract_term(query2)
        
        return {
            "span_near": {
                "clauses": [
                    {"span_term": {field: term1}},
                    {"span_term": {field: term2}}
                ],
                "slop": slop,
                "in_order": False
            }
        }
    
    def _extract_term(self, query: dict) -> str:
        """Extract term from a query structure."""
        if "multi_match" in query:
            return query["multi_match"]["query"]
        if "match" in query:
            return list(query["match"].values())[0]
        return ""


# =============================================================================
# Usage Example
# =============================================================================

async def search_legal_documents(
    query_string: str,
    tenant_id: str,
    filters: dict = None
) -> dict:
    """
    Execute a legal search query.
    
    Example queries:
    - "(negligence /10 liability) AND damages"
    - "breach AND contract NOT employment"
    - "cites:410 U.S. 113"
    - "judge:Ginsburg AND civil rights"
    """
    
    builder = LegalQueryBuilder()
    
    es_query = builder.build_query(
        query_string,
        tenant_id=tenant_id,
        search_fields=["content", "title^3", "summary^2", "headnotes^2"]
    )
    
    # Add additional filters
    if filters:
        if filters.get("jurisdiction"):
            es_query["query"]["bool"]["filter"].append({
                "term": {"jurisdiction": filters["jurisdiction"]}
            })
        
        if filters.get("date_from") or filters.get("date_to"):
            date_range = {}
            if filters.get("date_from"):
                date_range["gte"] = filters["date_from"]
            if filters.get("date_to"):
                date_range["lte"] = filters["date_to"]
            es_query["query"]["bool"]["filter"].append({
                "range": {"decision_date": date_range}
            })
        
        if filters.get("document_type"):
            es_query["query"]["bool"]["filter"].append({
                "term": {"doc_type": filters["document_type"]}
            })
    
    # Add highlighting for snippets
    es_query["highlight"] = {
        "fields": {
            "content": {
                "fragment_size": 200,
                "number_of_fragments": 3
            },
            "headnotes": {}
        },
        "pre_tags": ["<mark>"],
        "post_tags": ["</mark>"]
    }
    
    return es_query

Edge Cases#

Transcript
Interviewer

"What happens if a user's query has unbalanced parentheses or invalid syntax?"

You

"Good question. We handle this gracefully:"

Query error handling
1. Unbalanced parentheses

Input: "(breach AND contract"

Handling:

  • Detect during parsing
  • Auto-close open parens at end
  • OR: Fall back to simple search
  • Show warning to user: "Query may not be parsed as intended"
2. Invalid proximity

Input: "negligence /abc liability"

Handling:

  • Ignore invalid proximity operator
  • Treat as "negligence AND abc AND liability"
  • Log for analytics (common user errors)
3. Empty quotes

Input: 'contract AND ""'

Handling:

  • Ignore empty phrase
  • Process rest of query normally
4. Deeply nested

Input: "((((term))))"

Handling:

  • Set max nesting depth (10)
  • Beyond that, flatten to simple AND

Deep Dive 2: Multi-Tenant Isolation (Week 7, Day 1 & Day 3 — Tenant Filters)#

Where this comes from: Day 1's document modeling gives the per-tenant indices and Day 3's filter context the mandatory tenant filter. No Week 7 day teaches security, so the isolation layers below are new in this capstone; Week 9, Day 1 covers tenant isolation in depth.

Transcript
Interviewer

"You mentioned tenant isolation for private documents. How do you ensure a law firm can never see another firm's documents?"

You

"This is absolutely critical for a legal platform. A data leak between law firms would be catastrophic. Let me explain our defense-in-depth approach."

The Problem#

Tenant isolation requirements

Law Firm A uploads confidential merger contract. Law Firm B must NEVER be able to:

  • Search and find this document
  • Access it by ID if they guess
  • See it in autocomplete suggestions
  • See it in "related documents"
  • See aggregate stats that leak info

Even if:

  • There's a bug in our code
  • An attacker manipulates API calls
  • A developer makes a mistake
  • The query somehow bypasses filters

This is a ZERO TOLERANCE requirement.

The Solution#

You

"We implement multiple layers of isolation:"

Multi-layer tenant isolation — defense in depth
Layer 1: Separate indices

private_docs_firm_abc123 · private_docs_firm_def456 · private_docs_firm_ghi789

Each tenant has its own index - the service only ever searches the caller's own index (plus public), and callers can't name another

Layer 2: Query filter injection

Every query automatically includes: {"filter": {"term": {"tenant_id": "firm_abc123"}}}

Applied at service layer, cannot be bypassed by API

Layer 3: Document-level security

Elasticsearch document-level security (a paid-tier feature): a role query restricts each user to docs where tenant_id matches the tenant in their user metadata, set from the JWT

Layer 4: API gateway validation
  • Extract tenant_id from JWT token
  • Inject into request context
  • Cannot be overridden by request body
Layer 5: Audit logging

Every search logged with:

  • User ID, Tenant ID, Query, Results returned
  • Anomaly detection for cross-tenant access attempts
ExplainerA legal search query, gateway to audit log

The gateway verifies the JWT and fixes tenant firm_abc123 in an immutable context. The parser turns "(breach /5 contract) AND damages NOT punitive" into 9 tokens and an AST (AND of NEAR/5 breach contract, damages, NOT punitive); the builder emits an Elasticsearch bool query (span_near with slop 5, match, must_not) and the service adds the filter is_public OR tenant_id = firm_abc123. Only public_legal_docs and private_docs_firm_abc123 are searched, the search is written to search_audit_log and tenant_id is stripped from the hits. A request that slips tenant_id: firm_def456 into its filters is refused with a SecurityError, and even past that check firm_def456's index is never searched.

Implementation#

pythonAuthenticationError · 297 lines
# security/tenant_isolation.py

"""
Multi-tenant isolation for legal document search.

Applies: Week 7, Day 1 - Document modeling (a separate index per tenant)
         Week 7, Day 3 - Filter context (the mandatory tenant filter)
         Tenant security itself is new in this capstone (Week 9 goes deeper)
"""

from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional, List
from functools import wraps
import logging

logger = logging.getLogger(__name__)


class AuthenticationError(Exception):
    """Missing or invalid token."""


class SecurityError(Exception):
    """Tenant isolation violation."""


@dataclass(frozen=True)
class TenantContext:
    """Immutable tenant context from JWT."""
    tenant_id: str
    user_id: str
    roles: List[str]
    
    @property
    def private_index(self) -> str:
        """Get tenant-specific private index name."""
        return f"private_docs_{self.tenant_id}"
    
    @property
    def can_search_public(self) -> bool:
        """Check if user can search public documents."""
        return "search:public" in self.roles
    
    @property
    def can_search_private(self) -> bool:
        """Check if user can search private documents."""
        return "search:private" in self.roles


class TenantIsolationMiddleware:
    """
    Middleware to enforce tenant isolation.
    
    This runs BEFORE any search service code.
    """
    
    def __init__(self, jwt_validator):
        self.jwt_validator = jwt_validator
    
    async def __call__(self, request):
        """Extract and validate tenant context."""
        
        # Get token from header
        auth_header = request.headers.get("Authorization", "")
        if not auth_header.startswith("Bearer "):
            raise AuthenticationError("Missing token")
        
        token = auth_header[7:]
        
        # Validate and extract claims
        claims = await self.jwt_validator.validate(token)
        
        # Create immutable context
        context = TenantContext(
            tenant_id=claims["tenant_id"],
            user_id=claims["user_id"],
            roles=claims.get("roles", [])
        )
        
        # Attach to request (cannot be modified)
        request.state.tenant = context
        
        # Log for audit
        logger.info(
            "Request authenticated",
            extra={
                "tenant_id": context.tenant_id,
                "user_id": context.user_id,
                "path": request.url.path
            }
        )
        
        return context


def require_tenant_isolation(func):
    """
    Decorator ensuring tenant isolation in search functions.
    """
    
    @wraps(func)
    async def wrapper(*args, **kwargs):
        # Get tenant context (must exist)
        tenant: TenantContext = kwargs.get("tenant")
        if not tenant:
            raise SecurityError("Tenant context required")
        
        # Verify tenant_id is not overridable via the query or filters
        query = {
            "q": kwargs.get("query_string", ""),
            "f": kwargs.get("filters") or {},
        }
        if _contains_tenant_override(query):
            logger.warning(
                "Blocked tenant override attempt",
                extra={
                    "tenant_id": tenant.tenant_id,
                    "attempted_query": query
                }
            )
            raise SecurityError("Tenant override not allowed")
        
        return await func(*args, **kwargs)
    
    return wrapper


def _contains_tenant_override(query: dict) -> bool:
    """Check if query tries to override tenant filter."""
    
    query_str = str(query).lower()
    
    # Block any attempt to query tenant_id field directly
    if "tenant_id" in query_str:
        return True
    
    # Block attempts to query other tenant indices
    if "private_docs_" in query_str:
        return True
    
    return False


class IsolatedSearchService:
    """
    Search service with built-in tenant isolation.
    """
    
    def __init__(self, es_client):
        self.es = es_client
        self.public_index = "public_legal_docs"
    
    @require_tenant_isolation
    async def search(
        self,
        query_string: str,
        tenant: TenantContext,
        include_public: bool = True,
        include_private: bool = True,
        filters: dict = None
    ) -> dict:
        """
        Execute search with automatic tenant isolation.
        
        The tenant context comes from JWT, not from the request body.
        It cannot be overridden by the caller.
        """
        
        indices = []
        
        # Public documents (if permitted and requested)
        if include_public and tenant.can_search_public:
            indices.append(self.public_index)
        
        # Private documents (tenant-specific index)
        if include_private and tenant.can_search_private:
            indices.append(tenant.private_index)
        
        if not indices:
            return {"hits": [], "total": 0}
        
        # Build query with MANDATORY tenant filter
        es_query = self._build_isolated_query(
            query_string,
            tenant,
            filters
        )
        
        # Execute search
        response = await self.es.search(
            index=",".join(indices),
            body=es_query
        )
        
        # Audit log
        await self._audit_log(
            tenant=tenant,
            query=query_string,
            indices=indices,
            result_count=response["hits"]["total"]["value"]
        )
        
        return self._process_response(response)
    
    def _build_isolated_query(
        self,
        query_string: str,
        tenant: TenantContext,
        filters: dict
    ) -> dict:
        """Build query with mandatory tenant isolation."""
        
        # Parse the user's query
        parsed_query = LegalQueryBuilder().build_query(
            query_string,
            tenant_id=None,  # We add filter separately
            search_fields=["content", "title^3", "summary^2"]
        )
        
        # CRITICAL: Add tenant filter that CANNOT be bypassed
        # This filter is added by the service, not from user input
        tenant_filter = {
            "bool": {
                "should": [
                    # Public documents (no tenant restriction)
                    {"term": {"is_public": True}},
                    # Private documents (must match tenant)
                    {
                        "bool": {
                            "must": [
                                {"term": {"is_public": False}},
                                {"term": {"tenant_id": tenant.tenant_id}}
                            ]
                        }
                    }
                ],
                "minimum_should_match": 1
            }
        }
        
        # Inject filter
        parsed_query["query"]["bool"]["filter"].append(tenant_filter)
        
        # Add any additional filters
        if filters:
            for key, value in filters.items():
                if key in ["jurisdiction", "doc_type", "court"]:
                    parsed_query["query"]["bool"]["filter"].append({
                        "term": {key: value}
                    })
        
        return parsed_query
    
    async def _audit_log(
        self,
        tenant: TenantContext,
        query: str,
        indices: List[str],
        result_count: int
    ):
        """Log search for audit trail."""
        
        await self.es.index(
            index="search_audit_log",
            document={
                "timestamp": datetime.now(timezone.utc).isoformat(),
                "tenant_id": tenant.tenant_id,
                "user_id": tenant.user_id,
                "query": query,
                "indices_searched": indices,
                "result_count": result_count,
                "client_ip": "extracted_from_request"
            }
        )
    
    def _process_response(self, response: dict) -> dict:
        """Process ES response, ensuring no cross-tenant leakage."""
        
        hits = []
        for hit in response["hits"]["hits"]:
            # Double-check: never return tenant_id in response
            source = hit["_source"]
            if "tenant_id" in source:
                del source["tenant_id"]
            
            hits.append({
                "id": hit["_id"],
                "score": hit["_score"],
                "source": source,
                "highlights": hit.get("highlight", {})
            })
        
        return {
            "hits": hits,
            "total": response["hits"]["total"]["value"]
        }

Verification Tests#

You

"We also have automated tests that verify isolation:"

pythonTestTenantIsolation · 112 lines
# tests/test_tenant_isolation.py

"""
Critical security tests for tenant isolation.

These tests MUST pass before any deployment.
"""

import pytest


class TestTenantIsolation:
    """Tests that verify tenant isolation cannot be bypassed."""
    
    async def test_cannot_search_other_tenant_documents(
        self,
        search_service,
        tenant_a,
        tenant_b
    ):
        """Tenant A cannot find Tenant B's documents."""
        
        # Tenant B uploads a document
        doc = await upload_document(
            tenant=tenant_b,
            content="This is a secret merger agreement"
        )
        
        # Tenant A searches for it
        results = await search_service.search(
            query_string="secret merger agreement",
            tenant=tenant_a,
            include_private=True
        )
        
        # Must NOT find it
        assert doc["id"] not in [h["id"] for h in results["hits"]]
    
    async def test_cannot_access_by_id_guess(
        self,
        document_service,
        tenant_a,
        tenant_b
    ):
        """Tenant A cannot access Tenant B's document by ID."""
        
        # Tenant B's document
        doc = await upload_document(tenant=tenant_b, content="Secret")
        
        # Tenant A tries to access by ID
        with pytest.raises(NotFoundError):
            await document_service.get_document(
                document_id=doc["id"],
                tenant=tenant_a
            )
    
    async def test_cannot_override_tenant_filter(
        self,
        search_service,
        tenant_a
    ):
        """Query cannot override tenant filter."""
        
        # Try to inject tenant_id in query
        with pytest.raises(SecurityError):
            await search_service.search(
                query_string="contract",
                tenant=tenant_a,
                filters={"tenant_id": "other_tenant"}  # Should be blocked
            )
    
    async def test_cannot_query_other_tenant_index(
        self,
        search_service,
        tenant_a
    ):
        """Cannot query another tenant's index directly."""
        
        # Try to search other tenant's index
        with pytest.raises(SecurityError):
            await search_service.search_raw(
                index="private_docs_other_tenant",
                query={"match_all": {}},
                tenant=tenant_a
            )
    
    async def test_public_documents_visible_to_all(
        self,
        search_service,
        tenant_a,
        tenant_b
    ):
        """Public documents are searchable by all tenants."""
        
        # Search public case law
        results_a = await search_service.search(
            query_string="Roe v. Wade",
            tenant=tenant_a,
            include_public=True,
            include_private=False  # Compare public results only
        )
        
        results_b = await search_service.search(
            query_string="Roe v. Wade",
            tenant=tenant_b,
            include_public=True,
            include_private=False
        )
        
        # Both should find the same public documents
        assert results_a["total"] > 0
        assert results_a["total"] == results_b["total"]
Quick check3 questions · +5 XP each

Query parsing and tenant isolation

  1. Q1/3 A lawyer searches negligence /10 liability. Why does the builder use span_near with in_order: false rather than a phrase query?

    Answer & explanation

    Answer: A

    Proximity search is about distance, not exact wording: "liability for negligence" should match too. A phrase needs the terms adjacent and in order; span_near with slop 10 and no ordering doesn't. Both terms are still required.

  2. Q2/3 Each firm already has its own private_docs_<tenant> index. Why also inject a tenant_id filter and enforce document-level security?

    Answer & explanation

    Answer: C

    The requirement has zero tolerance even with bugs or attackers, so no single control is trusted alone. Separate indices, service-injected filters, DLS, JWT-derived tenant context and audit logs each catch a different failure.

  3. Q3/3 Where does the search service get the tenant_id it filters on?

    Answer & explanation

    Answer: B

    The tenant comes from the verified token, so the caller can't choose it. A query that tries to mention tenant_id or another tenant's index is rejected as an override attempt.

Deep Dive 3: Citation Network Search (Week 7, Day 2 & Day 4 — Indexing & Advanced Features)#

Transcript
Interviewer

"Tell me about the citation search. How do you build and query the citation network?"

You

"Citation analysis is crucial for legal research. Lawyers need to find all cases that cite a given case, understand if it's still 'good law', and explore citation chains."

The Problem#

Citation search requirements

Given a case like "Brown v. Board of Education":

1. Citing cases

Find all cases that cite this case. Expected: thousands of results

2. Cited cases (authorities)

Find all cases this case cites. Expected: dozens of results

3. Citation depth

Find cases citing cases that cite this case — "2-hop" citation network

4. Citation treatment

How was the case cited?

  • Followed (positive)
  • Distinguished (neutral)
  • Overruled (negative)
  • Mentioned (neutral)
5. Citation validity

Is this case still "good law"? Has it been overruled or limited?

The Solution#

Citation data model · citation index structure
  • Document: Brown v. Board of Education
    • ID — "347_us_483"
    • citations_outbound
      • {"id": "163_us_537", "treatment": "overruled"}
      • {"id": "305_us_337", "treatment": "followed"}
      • ...
    • citation_count_inbound — 15234
    • treatment_summary
      • "followed" — 12000
      • "distinguished" — 2500
      • "mentioned" — 734
      • "overruled" — 0
  • Separate Citation Edges Index
    • {citing_id, cited_id, treatment, context_snippet, citation_date, citing_court_rank}
    • Enables efficient graph queries

Implementation#

pythonCitationTreatment · 408 lines
# citations/citation_service.py

"""
Citation network search and analysis.

Applies: Week 7, Day 2 - Indexing Pipeline (citation extraction)
         Week 7, Day 4 - Aggregations, as in facets (treatment breakdowns)
         Graph-like queries over citation edges are new in this capstone
"""

from dataclasses import dataclass
from typing import List, Optional, Dict
from enum import Enum


class CitationTreatment(Enum):
    """How a case was cited."""
    FOLLOWED = "followed"
    DISTINGUISHED = "distinguished"
    OVERRULED = "overruled"
    QUESTIONED = "questioned"
    MENTIONED = "mentioned"


@dataclass
class Citation:
    """A citation between two legal documents."""
    citing_id: str
    citing_name: str
    cited_id: str
    cited_name: str
    treatment: CitationTreatment
    context: str  # Surrounding text
    date: str


@dataclass
class CitationAnalysis:
    """Analysis of a document's citation profile."""
    document_id: str
    document_name: str
    total_citations: int
    treatment_breakdown: Dict[str, int]
    is_good_law: bool
    overruled_by: Optional[str]
    key_citing_cases: List[dict]


class CitationService:
    """
    Manages citation network queries.
    """
    
    def __init__(self, es_client):
        self.es = es_client
        self.docs_index = "legal_documents"
        self.citations_index = "citation_edges"
    
    async def find_citing_cases(
        self,
        document_id: str,
        treatment: Optional[CitationTreatment] = None,
        limit: int = 100
    ) -> List[dict]:
        """
        Find all cases that cite a given document.
        
        This is the "cited by" query - who cites this case?
        """
        
        query = {
            "query": {
                "bool": {
                    "must": [
                        {"term": {"cited_id": document_id}}
                    ]
                }
            },
            "sort": [
                {"citation_date": "desc"}
            ],
            "size": limit
        }
        
        if treatment:
            query["query"]["bool"]["must"].append({
                "term": {"treatment": treatment.value}
            })
        
        response = await self.es.search(
            index=self.citations_index,
            body=query
        )
        
        # Enrich with document details
        citing_ids = [hit["_source"]["citing_id"] for hit in response["hits"]["hits"]]
        
        docs = await self._get_documents(citing_ids)
        
        results = []
        for hit in response["hits"]["hits"]:
            citation = hit["_source"]
            doc = docs.get(citation["citing_id"], {})
            
            results.append({
                "document_id": citation["citing_id"],
                "name": doc.get("name", citation["citing_id"]),
                "court": doc.get("court"),
                "date": doc.get("decision_date"),
                "treatment": citation["treatment"],
                "context": citation.get("context_snippet", "")
            })
        
        return results
    
    async def find_cited_cases(
        self,
        document_id: str,
        limit: int = 100
    ) -> List[dict]:
        """
        Find all cases cited by a given document.
        
        This is the "cites" query - what does this case cite?
        """
        
        query = {
            "query": {
                "term": {"citing_id": document_id}
            },
            "size": limit
        }
        
        response = await self.es.search(
            index=self.citations_index,
            body=query
        )
        
        # Enrich with document details
        cited_ids = [hit["_source"]["cited_id"] for hit in response["hits"]["hits"]]
        
        docs = await self._get_documents(cited_ids)
        
        results = []
        for hit in response["hits"]["hits"]:
            citation = hit["_source"]
            doc = docs.get(citation["cited_id"], {})
            
            results.append({
                "document_id": citation["cited_id"],
                "name": doc.get("name", citation["cited_id"]),
                "court": doc.get("court"),
                "date": doc.get("decision_date"),
                "treatment": citation["treatment"]
            })
        
        return results
    
    async def analyze_citation_profile(
        self,
        document_id: str
    ) -> CitationAnalysis:
        """
        Analyze the citation profile of a document.
        
        Determines if case is "good law" and how it's been treated.
        """
        
        # Get the document
        doc = await self._get_document(document_id)
        
        # Get citation treatment aggregation
        agg_query = {
            "query": {
                "term": {"cited_id": document_id}
            },
            "size": 0,
            "track_total_hits": True,  # exact count; the default caps at 10,000
            "aggs": {
                "treatments": {
                    "terms": {"field": "treatment"}
                },
                "recent_overruled": {
                    "filter": {"term": {"treatment": "overruled"}},
                    "aggs": {
                        "cases": {
                            "top_hits": {
                                "size": 1,
                                "sort": [{"citation_date": "desc"}]
                            }
                        }
                    }
                },
                "key_citations": {
                    "top_hits": {
                        "size": 10,
                        "sort": [{"citing_court_rank": "desc"}]
                    }
                }
            }
        }
        
        response = await self.es.search(
            index=self.citations_index,
            body=agg_query
        )
        
        aggs = response["aggregations"]
        
        # Build treatment breakdown
        treatment_breakdown = {
            bucket["key"]: bucket["doc_count"]
            for bucket in aggs["treatments"]["buckets"]
        }
        
        # Determine if good law
        overruled_hits = aggs["recent_overruled"]["cases"]["hits"]["hits"]
        is_overruled = len(overruled_hits) > 0
        overruled_by = None
        
        if is_overruled:
            overruled_by = overruled_hits[0]["_source"]["citing_id"]
        
        # Key citing cases
        key_cases = [
            {
                "id": hit["_source"]["citing_id"],
                "treatment": hit["_source"]["treatment"]
            }
            for hit in aggs["key_citations"]["hits"]["hits"]
        ]
        
        return CitationAnalysis(
            document_id=document_id,
            document_name=doc.get("name", ""),
            total_citations=response["hits"]["total"]["value"],
            treatment_breakdown=treatment_breakdown,
            is_good_law=not is_overruled,
            overruled_by=overruled_by,
            key_citing_cases=key_cases
        )
    
    async def find_citation_chain(
        self,
        document_id: str,
        depth: int = 2,
        limit_per_level: int = 10
    ) -> dict:
        """
        Find citation chain up to N levels deep.
        
        Example: Cases citing cases that cite the target case.
        """
        
        chain = {
            "root": document_id,
            "levels": []
        }
        
        current_ids = [document_id]
        
        for level in range(depth):
            # Find all cases citing the current level
            query = {
                "query": {
                    "terms": {"cited_id": current_ids}
                },
                "aggs": {
                    "citing_cases": {
                        "terms": {
                            "field": "citing_id",
                            "size": limit_per_level
                        }
                    }
                },
                "size": 0
            }
            
            response = await self.es.search(
                index=self.citations_index,
                body=query
            )
            
            citing_ids = [
                bucket["key"]
                for bucket in response["aggregations"]["citing_cases"]["buckets"]
            ]
            
            if not citing_ids:
                break
            
            chain["levels"].append({
                "depth": level + 1,
                "count": len(citing_ids),
                "sample_ids": citing_ids[:5]
            })
            
            current_ids = citing_ids
        
        return chain
    
    async def _get_document(self, document_id: str) -> dict:
        """Get a single document by ID."""
        try:
            response = await self.es.get(
                index=self.docs_index,
                id=document_id
            )
            return response["_source"]
        except:
            return {}
    
    async def _get_documents(self, document_ids: List[str]) -> Dict[str, dict]:
        """Get multiple documents by ID."""
        if not document_ids:
            return {}
        
        response = await self.es.mget(
            index=self.docs_index,
            body={"ids": document_ids}
        )
        
        return {
            doc["_id"]: doc["_source"]
            for doc in response["docs"]
            if doc.get("found")
        }


# =============================================================================
# Citation Extraction Pipeline
# =============================================================================

class CitationExtractor:
    """
    Extracts citations from legal document text.
    
    Applies: Week 7, Day 2 - Indexing Pipeline
    """
    
    # Patterns for different citation formats
    PATTERNS = {
        # US Supreme Court: 410 U.S. 113
        "us_reports": r"(\d+)\s+U\.S\.\s+(\d+)",
        
        # Federal Reporter: 123 F.2d 456
        "federal_reporter": r"(\d+)\s+F\.(2d|3d)?\s+(\d+)",
        
        # State reporters: 123 Cal.App.4th 456
        "state_reporter": r"(\d+)\s+([A-Z][a-z]+\.?\s*(?:App\.?)?\s*(?:\d+[a-z]+)?)\s+(\d+)",
        
        # Case names: Brown v. Board of Education
        "case_name": r"([A-Z][a-z]+)\s+v\.\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)",
    }
    
    def extract_citations(self, text: str) -> List[dict]:
        """Extract all citations from document text."""
        
        import re
        
        citations = []
        
        for pattern_name, pattern in self.PATTERNS.items():
            for match in re.finditer(pattern, text):
                # Get surrounding context
                start = max(0, match.start() - 100)
                end = min(len(text), match.end() + 100)
                context = text[start:end]
                
                # Detect treatment from context
                treatment = self._detect_treatment(context)
                
                citations.append({
                    "raw_citation": match.group(0),
                    "pattern_type": pattern_name,
                    "context": context,
                    "treatment": treatment,
                    "position": match.start()
                })
        
        return citations
    
    def _detect_treatment(self, context: str) -> str:
        """Detect how a citation is being used."""
        
        context_lower = context.lower()
        
        # Negative treatments
        if any(word in context_lower for word in 
               ["overruled", "overrule", "rejected", "abrogated"]):
            return "overruled"
        
        if any(word in context_lower for word in 
               ["questioned", "doubted", "criticized"]):
            return "questioned"
        
        # Neutral/distinguishing
        if any(word in context_lower for word in 
               ["distinguished", "distinguishing", "unlike"]):
            return "distinguished"
        
        # Positive
        if any(word in context_lower for word in 
               ["followed", "following", "accord", "see also", "affirmed"]):
            return "followed"
        
        # Default
        return "mentioned"

Deep Dive 4: Document Processing Pipeline (Week 7, Day 2 — CDC and Indexing)#

Transcript
Interviewer

"How do you handle document ingestion, especially for scanned documents that need OCR?"

You

"This is a critical part of the pipeline. Law firms often upload scanned contracts, old case files, and handwritten notes. We need robust processing."

The Pipeline#

DiagramDocument processing pipeline
%% title: Document processing pipeline
flowchart TD
  subgraph FLOW["Document ingestion flow"]
    UP@{ icon: "sd:api", label: "Upload (API)" } --> TR@{ icon: "sd:queue", label: "Triage (Queue)" }
    TR --> PROC["Process (Workers)"]:::core
    PROC --> IDX@{ icon: "tech:elasticsearch", label: "Index (ES)" }
  end
  subgraph TRIAGE["Triage decisions"]
    FT{"File type?"}
    FT -->|"PDF (text-based)"| EXT["Extract text directly"]:::service
    FT -->|"PDF (scanned)"| OCRP["OCR pipeline"]:::service
    FT -->|"Word/DOCX"| TIKA["Apache Tika extraction"]:::service
    FT -->|"Image (JPG/PNG)"| OCRP
    FT -->|"Unknown"| TIKAF["Tika with fallback"]:::service
  end
  TR -.-> FT
  subgraph OCR["OCR pipeline"]
    IE["Image Extract"]:::service --> ENH["Enhance Quality<br/>deskew, denoise"]:::service
    ENH --> OCRT["OCR (Tesseract)<br/>multi-lang confidence"]:::core
    OCRT --> PP["Post-Process<br/>spell check, legal terms"]:::service
  end
  OCRP -.-> IE
  class TR queue
  class IDX store
  class UP edge

Implementation#

pythonDocumentType · 263 lines
# ingestion/document_processor.py

"""
Document processing pipeline for legal documents.

Applies: Week 7, Day 2 - Indexing Pipeline
"""

from dataclasses import dataclass
from typing import Optional, List
from enum import Enum
import asyncio
import logging

logger = logging.getLogger(__name__)


class DocumentType(Enum):
    PDF_TEXT = "pdf_text"
    PDF_SCANNED = "pdf_scanned"
    WORD = "word"
    IMAGE = "image"
    UNKNOWN = "unknown"


@dataclass
class ProcessedDocument:
    """Result of document processing."""
    document_id: str
    tenant_id: str
    original_filename: str
    content: str
    metadata: dict
    citations: List[dict]
    page_count: int
    word_count: int
    ocr_confidence: Optional[float]
    processing_time_ms: int


class DocumentProcessor:
    """
    Processes uploaded documents for indexing.
    """
    
    def __init__(
        self,
        s3_client,
        tika_client,
        ocr_service,
        citation_extractor,
        kafka_producer
    ):
        self.s3 = s3_client
        self.tika = tika_client
        self.ocr = ocr_service
        self.citations = citation_extractor
        self.kafka = kafka_producer
    
    async def process_document(
        self,
        document_id: str,
        tenant_id: str,
        s3_key: str,
        filename: str
    ) -> ProcessedDocument:
        """
        Process a document through the full pipeline.
        """
        
        import time
        start_time = time.time()
        
        # Download from S3
        file_bytes = await self.s3.download(s3_key)
        
        # Determine document type
        doc_type = self._detect_document_type(filename, file_bytes)
        
        logger.info(
            f"Processing document {document_id}",
            extra={"type": doc_type.value, "size": len(file_bytes)}
        )
        
        # Extract content based on type
        if doc_type == DocumentType.PDF_TEXT:
            content, metadata = await self._extract_pdf_text(file_bytes)
            ocr_confidence = None
            
        elif doc_type == DocumentType.PDF_SCANNED:
            content, metadata, ocr_confidence = await self._process_scanned_pdf(
                file_bytes
            )
            
        elif doc_type == DocumentType.WORD:
            content, metadata = await self._extract_word(file_bytes)
            ocr_confidence = None
            
        elif doc_type == DocumentType.IMAGE:
            content, ocr_confidence = await self._process_image(file_bytes)
            metadata = {}
            
        else:
            # Fallback to Tika
            content, metadata = await self._extract_with_tika(file_bytes)
            ocr_confidence = None
        
        # Extract citations
        citations = self.citations.extract_citations(content)
        
        # Build processed document
        processing_time = int((time.time() - start_time) * 1000)
        
        processed = ProcessedDocument(
            document_id=document_id,
            tenant_id=tenant_id,
            original_filename=filename,
            content=content,
            metadata=metadata,
            citations=citations,
            page_count=metadata.get("page_count", 1),
            word_count=len(content.split()),
            ocr_confidence=ocr_confidence,
            processing_time_ms=processing_time
        )
        
        # Publish to Kafka for indexing
        await self._publish_for_indexing(processed)
        
        return processed
    
    def _detect_document_type(
        self,
        filename: str,
        file_bytes: bytes
    ) -> DocumentType:
        """Detect the type of document for processing."""
        
        ext = filename.lower().split(".")[-1]
        
        if ext in ("doc", "docx"):
            return DocumentType.WORD
        
        if ext in ("jpg", "jpeg", "png", "tiff", "bmp"):
            return DocumentType.IMAGE
        
        if ext == "pdf":
            # Check if PDF has extractable text
            if self._pdf_has_text(file_bytes):
                return DocumentType.PDF_TEXT
            else:
                return DocumentType.PDF_SCANNED
        
        return DocumentType.UNKNOWN
    
    def _pdf_has_text(self, file_bytes: bytes) -> bool:
        """Check if PDF contains extractable text or is scanned."""
        
        import fitz  # PyMuPDF
        
        doc = fitz.open(stream=file_bytes, filetype="pdf")
        
        # Check first few pages
        for page_num in range(min(3, len(doc))):
            page = doc[page_num]
            text = page.get_text()
            if len(text.strip()) > 100:
                return True
        
        return False
    
    async def _extract_pdf_text(self, file_bytes: bytes) -> tuple:
        """Extract text from text-based PDF."""
        
        import fitz
        
        doc = fitz.open(stream=file_bytes, filetype="pdf")
        
        content_parts = []
        for page in doc:
            content_parts.append(page.get_text())
        
        content = "\n\n".join(content_parts)
        
        metadata = {
            "page_count": len(doc),
            "title": doc.metadata.get("title", ""),
            "author": doc.metadata.get("author", ""),
            "creation_date": doc.metadata.get("creationDate", "")
        }
        
        return content, metadata
    
    async def _process_scanned_pdf(self, file_bytes: bytes) -> tuple:
        """Process scanned PDF through OCR."""
        
        import fitz
        
        doc = fitz.open(stream=file_bytes, filetype="pdf")
        
        content_parts = []
        confidences = []
        
        for page_num, page in enumerate(doc):
            # Extract page as image
            pix = page.get_pixmap(dpi=300)
            image_bytes = pix.tobytes("png")
            
            # OCR the image
            text, confidence = await self.ocr.process_image(image_bytes)
            
            content_parts.append(text)
            confidences.append(confidence)
        
        content = "\n\n".join(content_parts)
        avg_confidence = sum(confidences) / len(confidences) if confidences else 0
        
        metadata = {"page_count": len(doc)}
        
        return content, metadata, avg_confidence
    
    async def _process_image(self, file_bytes: bytes) -> tuple:
        """Process single image through OCR."""
        
        text, confidence = await self.ocr.process_image(file_bytes)
        return text, confidence
    
    async def _extract_word(self, file_bytes: bytes) -> tuple:
        """Extract content from Word document."""
        
        result = await self.tika.extract(file_bytes, "application/msword")
        
        return result["content"], result.get("metadata", {})
    
    async def _extract_with_tika(self, file_bytes: bytes) -> tuple:
        """Fallback extraction with Apache Tika."""
        
        result = await self.tika.extract(file_bytes)
        return result.get("content", ""), result.get("metadata", {})
    
    async def _publish_for_indexing(self, document: ProcessedDocument):
        """Publish processed document to Kafka for indexing."""
        
        event = {
            "event_type": "document.created",
            "document_id": document.document_id,
            "tenant_id": document.tenant_id,
            "content": document.content,
            "metadata": document.metadata,
            "citations": document.citations,
            "stats": {
                "page_count": document.page_count,
                "word_count": document.word_count,
                "ocr_confidence": document.ocr_confidence,
                "processing_time_ms": document.processing_time_ms
            }
        }
        
        await self.kafka.produce(
            topic="document.created",
            key=document.document_id,
            value=event
        )
Quick check2 questions · +5 XP each

Citations and document processing

  1. Q1/2 "Find every case that cites Brown v. Board" must return thousands of results fast. Why keep a separate citation-edges index of {citing_id, cited_id, treatment}?

    Answer & explanation

    Answer: C

    With each edge as its own document, both directions (cited_id for citing cases, citing_id for cited cases) are cheap term queries, with aggregations for treatment breakdowns and multi-hop chains.

  2. Q2/2 An uploaded PDF has under 100 characters of extractable text in its first pages. What does the processor do?

    Answer & explanation

    Answer: D

    Almost no text layer means the PDF is probably page images, so each page is rendered at 300 DPI and OCR'd. Text-based PDFs are extracted directly, which is faster and more accurate.

Your turn · checkpoint 3not answered

Ten times the documents: more nodes, or a new layout?

Volume grows 10x, to 1 billion documents. You could keep the same index layout and add nodes, or reorganize the indices. Roughly how much disk and how many 4 TB data nodes do you need, and what goes wrong if you only add nodes?

Think it through, or jot it down. Then compare.

One strong answer

  • Disk: 26 TB × 10 = 260 TB with replicas; at 4 TB per node kept ~75% full, that's about 90 data nodes (or bigger machines).
  • Only adding nodes: every query still fans out to every shard of ever-larger indices, and old cases nobody searches sit on the same fast, expensive disks as this year's.
  • Reorganize: split indices by document type and date so a query for 2023 cases only touches the matching indices, and use lifecycle management to move older indices to cheaper warm and cold tiers.

Phase 5 · Stage 5 of 6 · 5 min budget · 2 min read

Phase 5: Scaling and Edge Cases (5 minutes)#

Interviewer

"How would this system scale to 10x the document volume?"

Scaling Strategy#

You

"Let me walk through the scaling vectors..."

Scaling from 100M to 1B documents
Current state (100M docs) 10X scale (1B docs)
Data nodes 12 data nodes (64GB RAM, 4TB SSD each) ~90 (4 TB each, ~75% full) or larger instances
Storage 26 TB total storage with replicas 260 TB with replicas
QPS 300 QPS peak 3000 (if usage scales linearly)
Scaling approach
  1. Index sharding strategy — Current: ~13 TB of primaries / ~40 GB per shard ≈ 325 primary shards for the data volume. Per-tenant private indices add at least one primary each, so 5,000 firms means 5,000+ more shards, most of them small; watch total shard count, not just shard size. Scaled: split by document type + date

    • cases_federal_2020-2024 (5 shards)
    • cases_federal_2015-2019 (5 shards)
    • cases_state_california (3 shards)
    • statutes_federal (2 shards)
    • private_docs_* (per tenant: 1 shard for most firms, 2-3 for the largest)
  2. Tiered storage

    • Hot (last 2 years): Fast SSD, 1 replica
    • Warm (2-5 years): Standard SSD, 1 replica
    • Cold (5+ years): HDD, 1 replica, fewer nodes

    Use Index Lifecycle Management (ILM) to move automatically.

  3. Query routing — Route queries to relevant time-based indices. User searching 2023 cases doesn't hit 2010 indices

  4. Geographic read replicas (follower clusters via cross-cluster replication, a paid-tier feature)

    • Primary cluster: US East
    • Read replica: US West
    • Read replica: EU (for international firms)

Edge Cases#

Transcript
Interviewer

"What edge cases should we handle?"

You

"Several important ones for legal search:"

Edge cases
1. Very large documents

Problem: Court opinions running to hundreds of pages

Solution:

  • Split into sections for indexing
  • Store section metadata (page ranges)
  • Aggregate results by parent document
  • Lazy-load full content
2. Special characters in legal text

Problem: § (section), ¶ (paragraph), legal symbols

Solution:

  • Custom character filter in analyzer
  • Map § → "section"
  • Preserve symbols in stored content
  • Normalize for search
3. Citation variations

Problem: Same case cited many ways — "Brown v. Board", "Brown v. Bd. of Ed.", "347 U.S. 483"

Solution:

  • Citation normalization during extraction
  • Canonical ID for each case
  • Synonym expansion for case names
  • All variations map to same document
4. Tenant with millions of documents

Problem: One large law firm skews resources

Solution:

  • Per-tenant resource limits
  • Multiple shards for large tenants
  • Query timeout enforcement
  • Fair scheduling across tenants
5. Simultaneous updates to same document

Problem: Multiple users editing annotations

Solution:

  • Annotations stored separately from content
  • User-specific annotation layer
  • Optimistic locking for shared edits
  • Content itself is immutable
Quick check2 questions · +5 XP each

Scaling and edge cases

  1. Q1/2 At 10x volume, the design splits indices by document type and date (e.g. cases_federal_2020-2024). What's the main query-side benefit?

    Answer & explanation

    Answer: A

    Query routing means someone searching 2023 cases doesn't hit 2010 indices. Time-based indices also let ILM move older data to cheaper warm and cold tiers.

  2. Q2/2 "Brown v. Board", "Brown v. Bd. of Ed." and "347 U.S. 483" all refer to one case. How does the design make all three find it?

    Answer & explanation

    Answer: B

    Citation normalization during extraction maps every variant to one canonical ID, and synonym expansion covers name forms at query time. Fuzzy matching can't reliably bridge "Bd. of Ed." to "Board of Education".

Phase 6 · Stage 6 of 6 · 5 min budget · 1 min read

Phase 6: Monitoring and Operations (5 minutes)#

Interviewer

"How would you monitor this system in production?"

Key Metrics#

You

"I'd track metrics at multiple levels..."

Legal search monitoring dashboard
Search performance
Metric Value Target
Query Latency p99 320ms <500ms — ok
QPS 180
Error Rate 0.02%
Document processing
Metric Value Target
Processing Lag 45s <5m — ok
Queue Depth 23
OCR Success Rate 98.5%
Cluster health
Status Nodes Shards Disk
GREEN 12/12 100% 54%
Tenant isolation verification
  • Cross-tenant query attempts: 0 (last 24h) — ok
  • Isolation test (automated): PASSED 2 minutes ago
  • Audit log anomalies: 0 detected
Search quality
Zero Result Rate Avg Results/Query Citation Hit
3.2% 45 89%

Critical Alerts#

Alerting configuration

CRITICAL (PagerDuty)

  • Cluster status RED for >1 minute
  • Search latency p99 >2s for >5 minutes
  • Any cross-tenant data access attempt
  • Document processing queue >10,000
  • Error rate >5%

WARNING (Slack)

  • Cluster status YELLOW for >10 minutes
  • Search latency p99 >1s for >15 minutes
  • OCR success rate <95%
  • Disk usage >80%
  • Zero result rate >10%

INFO (Dashboard only)

  • New tenant onboarded
  • Large document processed (>100 pages)
  • Unusual query patterns
Quick check2 questions · +5 XP each

Monitoring legal search

  1. Q1/2 Which event pages on-call right away, even though it doesn't hurt latency or availability?

    Answer & explanation

    Answer: D

    Tenant isolation has zero tolerance, so even one cross-tenant attempt is a critical PagerDuty alert. The other three are Slack warnings.

  2. Q2/2 Why does the dashboard show an automated isolation test that ran "2 minutes ago"?

    Answer & explanation

    Answer: A

    Config changes, new code paths or mapping updates can break isolation after deploy. Running the cross-tenant tests continuously catches a regression within minutes, not at the next release.

Wrap-up · 2 min

Interview Conclusion#

Transcript
Interviewer

"Excellent work. You've covered a lot of ground — from complex query parsing to security to operations. A few final questions: What would you prioritize building first?"

You

"I'd prioritize in this order:

  1. Tenant isolation — Security is non-negotiable for a legal platform
  2. Basic search with boolean operators — Core functionality lawyers expect
  3. Document ingestion with OCR — Getting content into the system
  4. Citation extraction and linking — Differentiating feature for legal research
  5. Advanced features (proximity search, analytics) — Once the core is solid

The key insight is that legal search has unique requirements: complex queries, strict security, and domain-specific features like citations. A generic search solution wouldn't work here."

Interviewer

"Great. Any questions for me?"

You

"I'd love to hear how you currently handle the citation network — do you use a graph database, or is it all in Elasticsearch? And what's your experience with OCR accuracy on older legal documents?"

Concepts Applied Summary#

Week 7 Concepts Used#

Day Concept Application in This Design
Day 1 Inverted Index Full-text search for legal documents
Day 1 Text Analysis Legal-specific analyzers (§ symbols, citations)
Day 1 Document Modeling Separate indices for public/private/citations
Day 1 Synonym Filter Legal term synonyms ("contract"/"agreement")
Day 2 CDC Pipeline Document processing → Kafka → Elasticsearch
Day 2 Bulk Indexing Initial load of 30M court opinions
Day 2 Zero-Downtime Reindex Schema updates without service interruption
Day 3 Query vs Filter Boolean logic in query, tenant in filter
Day 3 BM25 Tuning Legal terminology relevance
Day 3 Function Scores Boost by citation count, recency
Day 4 Autocomplete Case names, frequent searches
Day 4 Multi-language N/A (English legal docs)
Day 5 Cluster Architecture Master/data/coordinating node separation
Day 5 Capacity Planning Storage and compute for 100M docs
Day 5 Shard Sizing ~40 GB primaries, ~325 for the data volume, plus at least one small primary per tenant index
Day 5 Reindexing With Aliases New legal analyzer shipped behind an atomic alias swap

Code Patterns Demonstrated#

Code patterns demonstrated
1. Legal query parser
  • Custom lexer/parser for boolean + proximity
  • Converts legal syntax to ES query DSL
2. Multi-tenant isolation
  • JWT-based tenant context
  • Mandatory filter injection
  • Per-tenant indices
  • Audit logging
3. Citation network
  • Citation extraction from text
  • Treatment detection
  • Graph queries (citing, cited-by)
4. Document processing
  • Type detection and routing
  • OCR pipeline for scanned docs
  • Kafka for async processing

Self-Assessment Checklist#

After studying this capstone, you should be able to:

After studying this capstone, you should be able to0/10 ready
  • Design a search system for domain-specific content (legal, medical, etc.)
  • Implement complex query parsing beyond simple keyword search
  • Build multi-tenant search with strict isolation guarantees
  • Create a document processing pipeline with OCR support
  • Model citation/reference networks in a search index
  • Size an Elasticsearch cluster for specific requirements
  • Design monitoring for search quality and security
  • Handle edge cases like large documents and special characters
  • Explain trade-offs between different indexing strategies
  • Articulate security considerations for sensitive data

This capstone integrates all concepts from Week 7 of the System Design Mastery Series. The legal domain demonstrates how search systems must be customized for specific use cases while maintaining security, performance, and reliability.


Next Week Preview: Week 8 — Analytics Pipeline

We'll design a data pipeline from event ingestion to queryable analytics, covering streaming vs batch processing, data modeling for OLAP, and handling late-arriving data.

this lesson in shorts · 2

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Boss · Designing a Legal Document Search System
Finish BossStages clear as you read them (0/6 so far). Skipping ahead doesn't count: marking complete unlocks after ~7 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

GitHubBlog post · ~15 min

The technology behind GitHub's new code search

Timothy Clem · 2023
What
GitHub's Blackbird search engine for code.
Key idea
Code needs exact punctuation, no stemming and regular expressions, so GitHub built ngram indexes and a query engine that intersects posting iterators.
Why here
Like legal text, code breaks default analysers; the capstone's legal query parser faces the same 'exact phrase and citation' needs.
taught here: Custom query parsing
Dream11Blog post · ~9 min

ElasticSearch @Dream11

2019
What
How Dream11's DevOps team runs Elasticsearch as a shared service for many teams.
Key idea
One platform serves logging, in-app search, WAF security logs and anomaly detection at over 500,000 events per second.
Why here
The capstone's multi-tenant deep dive: many tenants on shared clusters need sizing, isolation and per-tenant indices.
taught here: Multi-tenant search clusters

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…