typescript
56 lines · 9 steps
Building a TF-IDF search index
An inverted index maps terms to documents and ranks matches with TF-IDF scoring.
Explained by
highlit
1type Doc = { id: string; title: string; body: string };
2
3type Posting = { docId: string; frequency: number; positions: number[] };
4
5const STOP_WORDS = new Set(["a", "an", "the", "and", "or", "of", "to", "in", "is"]);
6
7function tokenize(text: string): string[] {
8 return text
9 .toLowerCase()
10 .replace(/[^\p{L}\p{N}\s]/gu, " ")
11 .split(/\s+/)
12 .filter((t) => t.length > 1 && !STOP_WORDS.has(t));
13}
14
15export class SearchIndex {
16 private index = new Map<string, Posting[]>();
17 private docCount = 0;
18
19 add(doc: Doc): void {
20 this.docCount++;
21 const tokens = tokenize(`${doc.title} ${doc.title} ${doc.body}`);
22 const seen = new Map<string, Posting>();
23
24 tokens.forEach((token, pos) => {
25 let posting = seen.get(token);
26 if (!posting) {
27 posting = { docId: doc.id, frequency: 0, positions: [] };
28 seen.set(token, posting);
29 const list = this.index.get(token) ?? [];
30 list.push(posting);
31 this.index.set(token, list);
32 }
33 posting.frequency++;
34 posting.positions.push(pos);
35 });
36 }
37
38 search(query: string): { docId: string; score: number }[] {
39 const terms = tokenize(query);
40 const scores = new Map<string, number>();
41
42 for (const term of terms) {
43 const postings = this.index.get(term);
44 if (!postings) continue;
45 const idf = Math.log(1 + this.docCount / postings.length);
46 for (const { docId, frequency } of postings) {
47 const tf = 1 + Math.log(frequency);
48 scores.set(docId, (scores.get(docId) ?? 0) + tf * idf);
49 }
50 }
51
52 return [...scores.entries()]
53 .map(([docId, score]) => ({ docId, score }))
54 .sort((a, b) => b.score - a.score);
55 }
56}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1An inverted index flips documents into a term-to-postings map so lookups touch only relevant docs.
- 2TF-IDF balances how often a term appears against how rare it is across the corpus.
- 3Weighting title tokens by repeating them is a cheap way to boost their relevance.
Related explainers
typescript
import { registerLocaleData } from '@angular/common'; import localeFr from '@angular/common/locales/fr'; import localeFrExtra from '@angular/common/locales/extra/fr'; import localeDe from '@angular/common/locales/de';
Locale-aware bootstrapping in Angular
i18n
localization
dependency-injection
Intermediate
8 steps
typescript
import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import * as Joi from 'joi';
Validating env config at boot in NestJS
configuration
schema-validation
environment-variables
Intermediate
8 steps
javascript
function evaluate(expression) { const tokens = tokenize(expression); let pos = 0;
Building a recursive descent calculator
parsing
recursion
operator-precedence
Intermediate
8 steps
typescript
import { Inject, Injectable, Logger } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { InjectRepository } from '@nestjs/typeorm';
A cache-aside country lookup in NestJS
cache-aside
dependency-injection
batch-lookup
Intermediate
8 steps
typescript
import { Injectable, effect, signal, computed } from '@angular/core'; interface Preferences { theme: 'light' | 'dark';
A signal-based preferences store in Angular
signals
state-management
persistence
Intermediate
7 steps
typescript
import { useEffect, useState } from "react"; interface Section { id: string;
Building a scroll-spy hook in React
custom-hooks
intersectionobserver
dom-observation
Intermediate
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/building-a-tf-idf-search-index-explained-typescript-0dbc/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.