Skip to content

Building a Full-Text Search Engine from Scratch

How full-text search engines work inside: tokenization, inverted indexes, TF-IDF scoring, and building a working search engine in TypeScript step by step.

5 min read
Inverted index data structure mapping terms to document IDs with frequency scores

Every time you search in Elasticsearch, PostgreSQL full-text search, or even a simple documentation site, the same fundamental data structure sits underneath: the inverted index. Understanding how it works demystifies search behavior — why some queries are fast, why phrase matching is harder than keyword matching, and why relevance scoring sometimes surprises you.

Building a search engine from scratch — even a simple one — teaches you more about search than reading documentation ever will.

The Inverted Index

A regular index maps documents to words. An inverted index flips this: it maps words to the documents that contain them. This inversion is what makes search fast — instead of scanning every document for a keyword, you look up the keyword and immediately get a list of matching documents.

tstypescript
// Document collection
interface Document {
  id: number;
  title: string;
  body: string;
}
 
const documents: Document[] = [
  { id: 1, title: 'TypeScript Patterns', body: 'TypeScript generics enable reusable type-safe code' },
  { id: 2, title: 'Testing Guide', body: 'Testing TypeScript code requires proper type configuration' },
  { id: 3, title: 'React Performance', body: 'React memoization prevents unnecessary re-renders in code' },
];
 
// Forward index (what you have): document → words
// { 1: ['typescript', 'generics', 'enable', 'reusable', 'type-safe', 'code'] }
// { 2: ['testing', 'typescript', 'code', 'requires', 'proper', 'type', 'configuration'] }
// { 3: ['react', 'memoization', 'prevents', 'unnecessary', 're-renders', 'code'] }
 
// ✅ Inverted index (what you need): word → documents
// 'typescript' → [1, 2]
// 'code'       → [1, 2, 3]
// 'react'      → [3]
// 'testing'    → [2]
// 'generics'   → [1]

Tokenization and Normalization

Before indexing, text must be broken into tokens and normalized. This step determines what counts as a "word" and how to handle variations.

tstypescript
// Tokenizer: breaks text into searchable terms
function tokenize(text: string): string[] {
  return text
    .toLowerCase()                       // Normalize case
    .replace(/[^\w\s]/g, ' ')           // Remove punctuation
    .split(/\s+/)                        // Split on whitespace
    .filter((token) => token.length > 0); // Remove empty tokens
}
 
// Stop words: common words that add noise to search results
const STOP_WORDS = new Set([
  'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
  'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will',
  'would', 'could', 'should', 'may', 'might', 'in', 'on', 'at',
  'to', 'for', 'of', 'with', 'by', 'from', 'and', 'or', 'not',
  'it', 'this', 'that', 'these', 'those',
]);
 
function removeStopWords(tokens: string[]): string[] {
  return tokens.filter((token) => !STOP_WORDS.has(token));
}
 
// ❌ Indexing without normalization
// "TypeScript" and "typescript" become different index entries
// User searching "typescript" won't find documents with "TypeScript"
 
// ✅ Normalize before indexing AND before searching
const raw = 'TypeScript Generics enable reusable, type-safe code!';
const tokens = removeStopWords(tokenize(raw));
// Result: ['typescript', 'generics', 'enable', 'reusable', 'type', 'safe', 'code']

Building the Inverted Index

The index maps each normalized token to a list of postings — each posting records which document contains the term and how many times it appears.

tstypescript
interface Posting {
  documentId: number;
  frequency: number;    // How many times the term appears in this doc
  positions: number[];  // Where in the document (for phrase queries)
}
 
type InvertedIndex = Map<string, Posting[]>;
 
function buildIndex(documents: Document[]): InvertedIndex {
  const index: InvertedIndex = new Map();
 
  for (const doc of documents) {
    const text = `${doc.title} ${doc.body}`;
    const tokens = removeStopWords(tokenize(text));
 
    // Track positions for phrase matching
    const termPositions = new Map<string, number[]>();
    tokens.forEach((token, position) => {
      if (!termPositions.has(token)) {
        termPositions.set(token, []);
      }
      termPositions.get(token)!.push(position);
    });
 
    // Add postings to the inverted index
    for (const [term, positions] of termPositions) {
      if (!index.has(term)) {
        index.set(term, []);
      }
      index.get(term)!.push({
        documentId: doc.id,
        frequency: positions.length,
        positions,
      });
    }
  }
 
  return index;
}
 
const idx = buildIndex(documents);
// idx.get('typescript') → [{ docId: 1, freq: 2, pos: [0, ...] }, { docId: 2, freq: 1, pos: [...] }]
// idx.get('code')       → [{ docId: 1, freq: 1, pos: [...] }, { docId: 2, freq: 1, pos: [...] }, { docId: 3, freq: 1, pos: [...] }]

TF-IDF Scoring

Not all matches are equally relevant. A document about "TypeScript generics" should rank higher for the query "TypeScript generics" than a document that mentions "TypeScript" once in passing. TF-IDF (Term Frequency — Inverse Document Frequency) quantifies this intuition.

tstypescript
// TF: How often the term appears in THIS document
// Higher frequency → more relevant (this doc talks about the term a lot)
function termFrequency(termCount: number, totalTerms: number): number {
  return termCount / totalTerms;
}
 
// IDF: How rare the term is across ALL documents
// Rarer terms → more discriminating → higher weight
function inverseDocumentFrequency(
  totalDocuments: number,
  documentsWithTerm: number
): number {
  return Math.log(totalDocuments / (1 + documentsWithTerm));
}
 
// TF-IDF score for a single term in a single document
function tfidf(
  termCount: number,
  totalTermsInDoc: number,
  totalDocuments: number,
  documentsWithTerm: number
): number {
  const tf = termFrequency(termCount, totalTermsInDoc);
  const idf = inverseDocumentFrequency(totalDocuments, documentsWithTerm);
  return tf * idf;
}
 
// Search function with TF-IDF ranking
function search(
  query: string,
  index: InvertedIndex,
  documents: Document[],
  docLengths: Map<number, number>
): Array<{ documentId: number; score: number }> {
  const queryTokens = removeStopWords(tokenize(query));
  const scores = new Map<number, number>();
 
  for (const token of queryTokens) {
    const postings = index.get(token);
    if (!postings) continue;
 
    const idf = inverseDocumentFrequency(documents.length, postings.length);
 
    for (const posting of postings) {
      const docLength = docLengths.get(posting.documentId) ?? 1;
      const tf = termFrequency(posting.frequency, docLength);
      const score = tf * idf;
 
      const current = scores.get(posting.documentId) ?? 0;
      scores.set(posting.documentId, current + score);
    }
  }
 
  return Array.from(scores.entries())
    .map(([documentId, score]) => ({ documentId, score }))
    .sort((a, b) => b.score - a.score);
}

Boolean Queries: AND, OR, NOT

Multi-term queries need boolean logic. "TypeScript AND testing" should return only documents that match both terms. "TypeScript OR React" should return documents matching either.

tstypescript
// ❌ Treating multi-word queries as OR by default
// Query "TypeScript testing" returns ALL TypeScript docs AND all testing docs
// Most results aren't relevant to both topics
 
// ✅ Support explicit boolean operators
type BooleanOp = 'AND' | 'OR' | 'NOT';
 
interface QueryClause {
  term: string;
  operator: BooleanOp;
}
 
function parseQuery(query: string): QueryClause[] {
  const tokens = query.split(/\s+/);
  const clauses: QueryClause[] = [];
  let nextOp: BooleanOp = 'AND'; // Default to AND
 
  for (const token of tokens) {
    const upper = token.toUpperCase();
    if (upper === 'AND' || upper === 'OR' || upper === 'NOT') {
      nextOp = upper as BooleanOp;
      continue;
    }
    clauses.push({ term: token.toLowerCase(), operator: nextOp });
    nextOp = 'AND'; // Reset to default
  }
 
  return clauses;
}
 
function booleanSearch(
  query: string,
  index: InvertedIndex
): Set<number> {
  const clauses = parseQuery(query);
  let resultSet: Set<number> | null = null;
 
  for (const clause of clauses) {
    const postings = index.get(clause.term) ?? [];
    const matchingDocs = new Set(postings.map((p) => p.documentId));
 
    if (resultSet === null) {
      resultSet = matchingDocs;
      continue;
    }
 
    switch (clause.operator) {
      case 'AND':
        resultSet = new Set([...resultSet].filter((id) => matchingDocs.has(id)));
        break;
      case 'OR':
        matchingDocs.forEach((id) => resultSet!.add(id));
        break;
      case 'NOT':
        matchingDocs.forEach((id) => resultSet!.delete(id));
        break;
    }
  }
 
  return resultSet ?? new Set();
}
 
// "typescript AND code" → documents containing BOTH terms
// "typescript OR react" → documents containing EITHER term
// "typescript NOT testing" → TypeScript docs that don't mention testing

Putting It All Together

Here is the complete search engine in one cohesive interface:

tstypescript
class SearchEngine {
  private index: InvertedIndex = new Map();
  private documents: Map<number, Document> = new Map();
  private docLengths: Map<number, number> = new Map();
 
  addDocument(doc: Document): void {
    this.documents.set(doc.id, doc);
    const text = `${doc.title} ${doc.body}`;
    const tokens = removeStopWords(tokenize(text));
    this.docLengths.set(doc.id, tokens.length);
 
    const termPositions = new Map<string, number[]>();
    tokens.forEach((token, pos) => {
      if (!termPositions.has(token)) termPositions.set(token, []);
      termPositions.get(token)!.push(pos);
    });
 
    for (const [term, positions] of termPositions) {
      if (!this.index.has(term)) this.index.set(term, []);
      this.index.get(term)!.push({
        documentId: doc.id,
        frequency: positions.length,
        positions,
      });
    }
  }
 
  search(query: string, limit = 10): Array<{ document: Document; score: number }> {
    const results = search(
      query,
      this.index,
      Array.from(this.documents.values()),
      this.docLengths
    );
 
    return results.slice(0, limit).map(({ documentId, score }) => ({
      document: this.documents.get(documentId)!,
      score,
    }));
  }
}
 
// Usage
const engine = new SearchEngine();
engine.addDocument({ id: 1, title: 'TypeScript Patterns', body: '...' });
engine.addDocument({ id: 2, title: 'Testing Guide', body: '...' });
const results = engine.search('typescript generics');

Key Takeaways

  1. Inverted indexes map terms to documents — this inversion makes keyword lookup O(1) instead of scanning every document
  2. Tokenization and normalization determine what counts as a match — case folding, stop word removal, and punctuation handling are essential preprocessing steps
  3. TF-IDF scores rank relevance — frequent terms in a document (TF) weighted by term rarity across all documents (IDF)
  4. Boolean operators combine term matches — AND narrows results, OR broadens them, NOT excludes
  5. Position information enables phrase queries — storing where terms appear allows matching "full text search" as a phrase
  6. Production search engines (Elasticsearch, Lucene) add stemming, synonyms, and BM25 scoring on top of these same fundamentals
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX