Build a Pokedex Search Engine
Build a CLI search engine that indexes 1025 Pokemon with BM25 ranking.
Introduction
30 Second Summary
Every time you type into a search bar, an algorithm decides which results matter most and puts them at the top. That ranking is the difference between finding what you need instantly and scrolling through a wall of irrelevant matches.
In this project, you will build a command-line Pokemon search engine from scratch. You'll fetch all 1025 Pokemon from a public API, build your own search index, and implement the same ranking algorithm that powers real search engines.
What You'll Build
Type a query like "fire dragon" into your terminal and instantly see Charizard, Reshiram, and Turtonator ranked by relevance, complete with colored type badges and stat bars.
By the end of this project, you'll have:
- A locally cached dataset of all 1025 Pokemon fetched asynchronously from PokeAPI in parallel batches, so searches run offline in milliseconds.
- A from-scratch inverted index with BM25 scoring that visibly outperforms naive substring search on multi-word queries like "electric mouse" or "water dark".
- A polished interactive CLI with colored type badges, base stat bar charts, and ranked results you can demo to anyone from the terminal.
- Secret Mission: Add fuzzy matching so typos like "pikachoo" or "charazard" still find the right Pokemon.
Are there any prerequisites?
You need Python, uv, and VS Code installed on your machine. The project fetches data from a free public API with no authentication required, so there are no accounts to create or API keys to manage.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up the Project
Before you can fetch Pokemon data or search through it, you need a project environment with the right libraries installed.
In this step, you will create a uv-managed Python project and install two libraries: aiohttp for async HTTP fetching and rich for formatted terminal output.
In this step, get ready to:
- Create and initialize a Python project with uv.
- Install the aiohttp and rich libraries.
- Create the three Python files for the project.
Create the project directory and initialize with uv
- Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir pokedex-search
cd pokedex-search
- Initialize a new Python project by running:
uv init --no-package
You should see a message like Initialized project `pokedex-search` confirming the project was created.
What did uv init create?
The --no-package flag tells uv to create a flat project layout (files at the top level instead of inside a src/ folder).
It created a pyproject.toml (your project's config file), a .python-version file, a README.md, and a default main.py with placeholder code you will replace later.
- Install the project's two dependencies by running:
uv add aiohttp rich
You should see uv resolve and install both packages, with output confirming each was added to your project.
What are aiohttp and rich?
- aiohttp is an async HTTP client. You will use it to fetch data from 1025 Pokemon URLs in parallel instead of one at a time.
- rich is a terminal formatting library. It provides colored text, tables, and progress bars so your search results look polished.
Seeing a resolution error?
Make sure you ran uv init --no-package first. The uv add command needs a pyproject.toml to exist in the current directory.
Also confirm you are inside the pokedex-search folder by running pwd. You should see a path ending in pokedex-search.
Help me debug a uv add error in my pokedex-search project.
Create the file structure
Your project needs three Python files, each with a distinct responsibility.
- Create fetcher.py and engine.py by running:
touch fetcher.py engine.py
- Confirm all three files exist by running:
ls *.py
You should see engine.py, fetcher.py, and main.py listed in the output.
What will each file do?
- fetcher.py will handle fetching Pokemon data from the API and caching it locally.
- engine.py will contain the search engine internals: the inverted index and BM25 scoring.
- main.py will be the CLI interface that ties fetching and searching together.
- Open the project in VS Code by running:
code .
VS Code should open with your pokedex-search folder in the Explorer sidebar, showing all your project files.
Command not found for code?
If code . is not recognized, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows), type Visual Studio Code, and press Enter.
Then click File, select Open Folder, and navigate to Desktop/pokedex-search.
Help me open my project folder in VS Code.
Verify the environment
Before you write any code, confirm that both libraries are importable in your project's environment.
What do you think will happen if the imports succeed? What message do you expect to see?
- Test that both libraries import correctly by running:
uv run python -c "import aiohttp; import rich; print('Ready to catch them all')"
You should see Ready to catch them all printed in your terminal. This confirms that uv can run Python with both libraries available.
Seeing a ModuleNotFoundError?
Make sure you are still inside the pokedex-search directory (run pwd to check). The uv run command looks for pyproject.toml in the current directory to find installed packages.
If the error mentions a specific package, re-run uv add aiohttp rich and check for error messages during installation.
Help me fix a ModuleNotFoundError when running my verification command.
Your project skeleton is ready. Next up, you will build the async fetcher that pulls all 1025 Pokemon from PokeAPI and caches them locally.
Fetch and Cache All 1025 Pokemon
Your project is set up, but there's nothing to search yet. You need a local dataset of all 1025 Pokemon before you can build any search logic on top of it.
Fetching 1025 Pokemon one at a time would be painfully slow. In this step, you'll build an async fetcher that pulls all Pokemon in parallel batches of 50, then caches the result to a local JSON file so you never hit the network twice.
In this step, get ready to:
- Build async helper functions that fetch Pokemon data from PokeAPI in parallel batches.
- Add a local JSON cache so subsequent runs skip the network entirely.
- Run the fetcher and watch a progress bar fill up as 1025 Pokemon are cached to disk.
Build the async helper functions
The PokeAPI returns a list of Pokemon URLs at one endpoint, then full details at each individual URL. You need two helper functions: one to fetch a single Pokemon's details, and one to extract only the fields your search engine cares about.
- Open fetcher.py in VS Code and add the following imports, constants, and helper functions:
import asyncio
import json
from pathlib import Path
import aiohttp
from rich.progress import Progress
# Path to the local cache file
CACHE_FILE = Path("pokedex.json")
# PokeAPI base URL for Pokemon resources
BASE_URL = "https://pokeapi.co/api/v2/pokemon"
# Total number of Pokemon to fetch
TOTAL_POKEMON = 1025
# How many Pokemon to fetch in each parallel batch
BATCH_SIZE = 50
async def fetch_pokemon(session: aiohttp.ClientSession, url: str) -> dict | None:
"""Fetch a single Pokemon's data from its detail URL."""
try:
async with session.get(url) as response:
if response.status == 200:
return await response.json()
except (aiohttp.ClientError, asyncio.TimeoutError):
return None
def extract_pokemon_data(raw: dict) -> dict:
"""Pull out only the fields needed for search."""
return {
"id": raw["id"],
"name": raw["name"],
"types": [t["type"]["name"] for t in raw["types"]],
"abilities": [a["ability"]["name"] for a in raw["abilities"]],
"stats": {s["stat"]["name"]: s["base_stat"] for s in raw["stats"]},
"height": raw["height"],
"weight": raw["weight"],
}
What does this code do?
CACHE_FILE and BASE_URL define where the cache lives on disk and where to find the API. BATCH_SIZE = 50 controls how many requests fly in parallel at once.
fetch_pokemon makes a single HTTP GET and returns the JSON response, or None if the request fails. extract_pokemon_data takes the raw API response and pulls out only the seven fields your search engine needs: id, name, types, abilities, stats, height, and weight.
- Save fetcher.py.
- Confirm there are no syntax errors by running this import check:
uv run python -c "from fetcher import extract_pokemon_data; print('Helper functions ready')"
You should see Helper functions ready printed in your terminal.
Seeing an import error?
Check that import aiohttp and from rich.progress import Progress are on separate lines after a blank line separating them from the standard library imports. If you see ModuleNotFoundError, make sure you're using uv run (not bare python) so the virtual environment is active.
help me fix this import error in fetcher.py
Add batch fetching and local caching
Now you need two more functions: one that orchestrates fetching all 1025 Pokemon in parallel batches with a progress bar, and one that manages the local cache so you only hit the network on the first run.
- In fetcher.py, add the fetch_all_pokemon function below extract_pokemon_data:
async def fetch_all_pokemon() -> list[dict]:
"""Fetch all Pokemon in parallel batches with a progress bar."""
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=120)
) as session:
# Get the master list of all Pokemon URLs
list_url = f"{BASE_URL}?limit={TOTAL_POKEMON}"
async with session.get(list_url) as response:
data = await response.json()
urls = [entry["url"] for entry in data["results"]]
# Fetch details in batches of BATCH_SIZE
pokemon_list = []
with Progress() as progress:
task = progress.add_task("Fetching Pokemon...", total=len(urls))
for i in range(0, len(urls), BATCH_SIZE):
batch = urls[i : i + BATCH_SIZE]
results = await asyncio.gather(
*[fetch_pokemon(session, url) for url in batch]
)
for raw in results:
if raw:
pokemon_list.append(extract_pokemon_data(raw))
progress.update(task, advance=len(batch))
return pokemon_list
What does this code do?
First, it hits https://pokeapi.co/api/v2/pokemon?limit=1025 to get a list of all Pokemon URLs in one request. The API returns an array of objects, each with a name and a detail url.
Then it loops through those URLs in batches of 50, firing off all 50 requests simultaneously with asyncio.gather. This is dramatically faster than fetching one at a time. The rich Progress bar updates after each batch so you can watch it fill up.
- Below fetch_all_pokemon, add the load_pokedex function and the standalone runner:
def load_pokedex() -> list[dict]:
"""Load Pokemon from cache, or fetch and cache if missing."""
if CACHE_FILE.exists():
with open(CACHE_FILE, "r") as f:
return json.load(f)
# First run: fetch from the API
pokemon_list = asyncio.run(fetch_all_pokemon())
pokemon_list.sort(key=lambda p: p["id"])
# Save to local cache
with open(CACHE_FILE, "w") as f:
json.dump(pokemon_list, f)
print(f"Cached {len(pokemon_list)} Pokemon to {CACHE_FILE}")
return pokemon_list
if __name__ == "__main__":
pokedex = load_pokedex()
print(f"Loaded {len(pokedex)} Pokemon")
What does this code do?
load_pokedex is the entry point other modules will call. If pokedex.json already exists, it reads from disk and returns instantly. No network needed.
On the first run, it calls fetch_all_pokemon() to pull everything from the API, sorts by ID, and writes the result to the cache file. The if __name__ == "__main__" block lets you run this file directly to populate the cache.
- Save fetcher.py.
Run the fetcher to populate the cache
Everything is wired up. Time to run the fetcher and watch it pull all 1025 Pokemon from the API.
- Before you run this, what do you think will happen? Will it be fast or slow, and why?
- Run the fetcher standalone by entering this command:
uv run python fetcher.py
You should see a progress bar filling up as batches of 50 are fetched. When it finishes, you'll see:
Cached 1025 Pokemon to pokedex.json
Loaded 1025 Pokemon
Why is this so fast?
Instead of making 1025 sequential requests (which could take minutes), asyncio.gather fires 50 requests simultaneously in each batch. That's only 21 batches total. PokeAPI has no rate limiting (removed since November 2018), so you can safely fetch this fast.
- Confirm the cache file was created by running:
ls pokedex.json
You should see pokedex.json listed in the output.
- Run the fetcher again to confirm the cache works:
uv run python fetcher.py
This time it should return almost instantly with Loaded 1025 Pokemon and no progress bar. The cache is working.
Seeing a timeout or connection error?
Make sure you have an active internet connection. The 120-second timeout in aiohttp.ClientTimeout(total=120) should be generous enough for most connections. If you're behind a corporate proxy or VPN, try disconnecting and running again.
If the script exits with fewer than 1025 Pokemon cached, delete pokedex.json and run it again. The retry logic in fetch_pokemon silently skips individual failures, so a flaky connection may result in a smaller cache.
help me debug my fetcher.py connection or timeout error
✔️ Awesome, I've got everything!
Great. Confirm you've saved fetcher.py and that pokedex.json exists in your project folder with 1025 Pokemon cached.
ⓧ I'd like to double check the full code
Your fetcher.py should contain these sections in order. Scroll up and confirm each is present:
- Imports (asyncio, json, Path, aiohttp, Progress) and four constants (CACHE_FILE, BASE_URL, TOTAL_POKEMON, BATCH_SIZE).
- fetch_pokemon function that fetches a single Pokemon URL and returns JSON or None.
- extract_pokemon_data function that pulls out the seven fields (id, name, types, abilities, stats, height, weight).
- fetch_all_pokemon async function with the batch loop and progress bar.
- load_pokedex function with the cache-check logic.
- if __name__ == "__main__" block that calls load_pokedex() and prints the count.
You now have a 1025-Pokemon dataset cached locally. Next up, you'll build a search function on top of this data and discover exactly where simple substring matching falls apart.
Build a Naive Search and Watch It Fail
You have 1025 Pokemon cached locally. The obvious next step is to search through them.
The simplest approach is substring matching: check if the query text appears anywhere in a Pokemon's data. This feels like it should work, but let's see what happens when you search for something like "fire flying" or "electric mouse."
In this step, get ready to:
- Implement a substring search class in engine.py.
- Wire it into an interactive loop in main.py.
- Test multi-word queries and observe where substring search breaks down.
Implement the NaiveSearch class
Substring matching is the most intuitive search strategy: join all of a Pokemon's fields into one string, then check if the user's query appears inside it.
- Open engine.py in VS Code and add the following code:
import math
class NaiveSearch:
def __init__(self, pokemon_list: list[dict]):
self.pokemon_list = pokemon_list
def search(self, query: str) -> list[dict]:
# Lowercase the query and check every Pokemon
query = query.lower().strip()
results = []
for pokemon in self.pokemon_list:
# Combine name, types, and abilities into one searchable string
text = " ".join(
[pokemon["name"]]
+ pokemon["types"]
+ pokemon["abilities"]
)
if query in text:
results.append(pokemon)
return results
What does this code do?
The NaiveSearch class takes the full list of Pokemon and provides a search method.
- For each Pokemon, it joins the name, types, and abilities into a single space-separated string.
- It then checks whether the entire query string appears as a substring in that combined text.
- The import math line is included because you will use it in the next step when you add scoring.
- Save engine.py.
- Verify the class loads correctly by running this import test:
uv run python -c "from engine import NaiveSearch; print('NaiveSearch ready')"
You should see NaiveSearch ready printed in your terminal.
Seeing an import error?
- Check that your file is saved as engine.py in the same pokedex-search folder where you ran the command.
- Make sure the class name is NaiveSearch with a capital N and capital S.
Help me debug my NaiveSearch import error
Wire it into an interactive loop
Now you need a way to actually type queries and see results. A simple input loop in main.py will do the job.
- Open main.py and add the following code:
from engine import NaiveSearch
from fetcher import load_pokedex
# Load all 1025 Pokemon from the local cache
pokedex = load_pokedex()
search = NaiveSearch(pokedex)
# Interactive search loop
while True:
query = input("Pokedex> ").strip()
if not query:
continue
if query.lower() in ("quit", "exit"):
break
results = search.search(query)
if not results:
print("No results found.")
for p in results:
print(p["name"], p["types"])
What does this code do?
- load_pokedex() reads the cached JSON file you created in the previous step, so there is no network call.
- The while True loop keeps prompting until you type quit or exit.
- Results print each Pokemon's name and types. No scores, no ranking.
- Save main.py.
- Run the search engine by executing this command:
uv run python main.py
You should see the Pokedex> prompt appear, waiting for your input.
- Type pikachu and press **Enter**.
You should see pikachu ['electric'] printed. The search works for exact name matches.
Not seeing the Pokedex prompt?
- Make sure pokedex.json exists in the same folder. If it is missing, run uv run python fetcher.py again.
- Check that both engine.py and main.py are saved in the pokedex-search folder.
Help me fix my main.py search loop
Watch the naive search fail
Your search found Pikachu because "pikachu" is a literal substring of the combined text. Now try something a real user would search for: a combination of type and type.
Before you type the next query, think about this: Charizard is both a fire type and a flying type. Do you think searching "fire flying" will find it?
- Type fire flying and press **Enter**.
You see No results found. printed. Zero results for a query that should match Charizard, Moltres, and others.
- Try electric mouse and press **Enter**.
Zero results again. Pikachu is an electric type and is famously a mouse Pokemon, but the search cannot combine those concepts.
- Try water dark and press **Enter**.
Zero results. Same failure. Type quit to exit the loop.
Why does this fail?
The substring search looks for the exact string "fire flying" inside the combined text. Charizard's combined text is something like charizard fire flying blaze solar-power. The substring "fire flying" does not appear because the words are separated by other tokens.
This is the fundamental limitation of substring matching. It treats the query as one atomic string. It cannot split "fire flying" into two separate terms and match them independently.
This failure is exactly the gap you will close in the next step. You need a search approach that splits queries into individual terms, matches each term independently, and ranks documents by how well they match across all terms.
✔️ Awesome, I've got everything!
Great. Make sure both engine.py and main.py are saved.
ⓧ I'd like to double check the full code
Here is the full engine.py at this point:
import math
class NaiveSearch:
def __init__(self, pokemon_list: list[dict]):
self.pokemon_list = pokemon_list
def search(self, query: str) -> list[dict]:
query = query.lower().strip()
results = []
for pokemon in self.pokemon_list:
text = " ".join(
[pokemon["name"]]
+ pokemon["types"]
+ pokemon["abilities"]
)
if query in text:
results.append(pokemon)
return results
Here is the full main.py at this point:
from engine import NaiveSearch
from fetcher import load_pokedex
pokedex = load_pokedex()
search = NaiveSearch(pokedex)
while True:
query = input("Pokedex> ").strip()
if not query:
continue
if query.lower() in ("quit", "exit"):
break
results = search.search(query)
if not results:
print("No results found.")
for p in results:
print(p["name"], p["types"])
You have proven that substring matching is a dead end for multi-term queries. Next up, you will build an inverted index with BM25 scoring that handles each query term independently and ranks results by relevance.
Build the Inverted Index with BM25 Scoring
In the previous step, you saw the naive search fail on multi-word queries like "fire flying". It returned zero results because no single field contains that exact string.
The fix is an inverted index paired with BM25 scoring. The inverted index maps each token ("fire", "flying") to every Pokemon containing that token. BM25 then scores how relevant each Pokemon is to the full query by considering term frequency, document length, and rarity.
In this step, get ready to:
- Build a tokenizer that breaks each Pokemon into weighted search terms.
- Construct an inverted index mapping terms to the documents that contain them.
- Implement BM25 scoring so multi-word queries return ranked results.
Build the tokenizer and inverted index
The tokenizer turns each Pokemon into a list of searchable terms with field-based weights. Name matches matter most, then types, then abilities.
- In engine.py, add the following class below the existing NaiveSearch class:
class PokedexSearchEngine:
def __init__(self, pokemon_list: list[dict], k1: float = 1.5, b: float = 0.75):
self.pokemon_list = pokemon_list
self.k1 = k1
self.b = b
# Data structures for the inverted index
self.index: dict[str, list[tuple[int, float]]] = {}
self.doc_lengths: dict[int, int] = {}
self.avg_doc_length = 0.0
self.num_docs = len(pokemon_list)
self._build_index()
def _tokenize_pokemon(self, pokemon: dict) -> list[tuple[str, float]]:
tokens = []
# Name gets the highest weight
tokens.append((pokemon["name"].lower(), 3.0))
# Split hyphenated names (e.g. "charizard-mega" -> "charizard", "mega")
name_parts = pokemon["name"].lower().split("-")
for part in name_parts:
if part != pokemon["name"].lower():
tokens.append((part, 2.0))
# Types get medium weight
for ptype in pokemon["types"]:
tokens.append((ptype.lower(), 2.0))
# Abilities get lower weight
for ability in pokemon["abilities"]:
ability_clean = ability.lower().replace("-", " ")
tokens.append((ability.lower(), 1.0))
for word in ability_clean.split():
tokens.append((word, 0.5))
return tokens
What does this code do?
The __init__ sets up the BM25 parameters and storage, then calls _build_index() to process all Pokemon immediately on creation.
The _tokenize_pokemon method assigns field weights: name gets 3.0 (highest priority), types get 2.0, and abilities get 1.0 or 0.5 for individual words. This means searching "pikachu" will always rank Pikachu above Pokemon that merely have a pikachu-related ability.
The class will error if you try to run it now because _build_index does not exist yet. Add it next.
- Directly below the _tokenize_pokemon method, add the _build_index method:
def _build_index(self):
total_length = 0
for i, pokemon in enumerate(self.pokemon_list):
tokens = self._tokenize_pokemon(pokemon)
self.doc_lengths[i] = len(tokens)
total_length += len(tokens)
# Keep only the highest weight per token per document
seen: dict[str, float] = {}
for token, weight in tokens:
if token not in seen or weight > seen[token]:
seen[token] = weight
# Add each unique token to the inverted index
for token, weight in seen.items():
if token not in self.index:
self.index[token] = []
self.index[token].append((i, weight))
self.avg_doc_length = total_length / self.num_docs if self.num_docs else 1
What does the inverted index look like?
After this method runs, self.index is a dictionary like {"fire": [(4, 2.0), (5, 2.0), ...], "pikachu": [(24, 3.0)]}. Each key is a term, and each value is a list of (document index, weight) pairs.
The seen dictionary ensures each token appears only once per document, keeping the highest weight if the same word shows up in multiple fields.
- Save engine.py.
- Verify the index builds correctly by running this command:
uv run python -c "from engine import PokedexSearchEngine; from fetcher import load_pokedex; e = PokedexSearchEngine(load_pokedex()); print(f'{len(e.index)} terms indexed across {e.num_docs} Pokemon')"
You should see a message like 2000+ terms indexed across 1025 Pokemon. The exact term count depends on how many unique names, types, and abilities exist in the dataset.
Seeing an AttributeError or ImportError?
- If you see AttributeError: 'PokedexSearchEngine' object has no attribute '_build_index', make sure _build_index is indented inside the class (one level deeper than def).
- If you see IndentationError, check that both methods use consistent indentation (4 spaces per level).
- Make sure pokedex.json still exists in your project folder from the previous step.
Why is my index not building?
Implement BM25 scoring
The index tells you which Pokemon contain a term. BM25 tells you how relevant each Pokemon is to the full query by weighing term frequency against document length and corpus-wide rarity.
- In engine.py, add these three methods below _build_index (still inside the PokedexSearchEngine class):
def _bm25_score(self, term: str, doc_idx: int, weight: float) -> float:
df = len(self.index.get(term, []))
if df == 0:
return 0.0
# IDF: how rare is this term across all documents
idf = math.log((self.num_docs - df + 0.5) / (df + 0.5) + 1)
doc_len = self.doc_lengths[doc_idx]
tf = weight
# BM25 TF normalization
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / self.avg_doc_length)
return idf * (numerator / denominator)
def search(self, query: str, top_k: int = 10) -> list[tuple[dict, float]]:
terms = query.lower().strip().split()
if not terms:
return []
scores: dict[int, float] = {}
# Score each document by summing BM25 across query terms
for term in terms:
if term in self.index:
for doc_idx, weight in self.index[term]:
score = self._bm25_score(term, doc_idx, weight)
scores[doc_idx] = scores.get(doc_idx, 0.0) + score
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
return [(self.pokemon_list[idx], score) for idx, score in ranked]
def get_indexed_terms(self) -> set[str]:
return set(self.index.keys())
How does BM25 scoring work?
- IDF (Inverse Document Frequency): rare terms score higher. "pikachu" appears in one document so it is very discriminating. "normal" appears in many, so it contributes less.
- TF (Term Frequency): the field weight acts as the term frequency. A name match (weight 3.0) scores higher than an ability match (weight 0.5).
- The search method splits the query into individual terms, scores each document against every term, sums the scores, and returns the top results sorted by relevance.
- Save engine.py.
- Test the search method by running:
uv run python -c "from engine import PokedexSearchEngine; from fetcher import load_pokedex; e = PokedexSearchEngine(load_pokedex()); results = e.search('fire flying'); print(results[0][0]['name'], f'score={results[0][1]:.2f}')"
You should see charizard with a BM25 score printed. The same query that returned zero results with naive search now finds the right Pokemon.
Getting a KeyError or empty results?
- If you see IndexError: list index out of range, the search returned no results. Check that _bm25_score is indented inside the class and that the method name matches exactly.
- If you see NameError: name 'math' is not defined, confirm that import math is still at the top of engine.py.
Why is my BM25 search returning empty results?
Replace the naive search in main.py
The engine works in isolation. Now swap out the naive search in your interactive loop so you can see ranked results live.
- Replace the entire contents of main.py with this updated version:
from engine import PokedexSearchEngine
from fetcher import load_pokedex
pokedex = load_pokedex()
engine = PokedexSearchEngine(pokedex)
while True:
query = input("Pokedex> ").strip()
if not query:
continue
if query.lower() in ("quit", "exit"):
break
# Search and display results with scores
results = engine.search(query)
if not results:
print("No results found.")
for p, score in results:
print(f"{p['name']} {p['types']} score={score:.2f}")
What changed from the naive version?
- The import switched from NaiveSearch to PokedexSearchEngine.
- Results are now tuples of (pokemon_dict, score) instead of plain dictionaries, so the loop unpacks both values.
- The output includes a BM25 score so you can see how the engine ranks each result.
- Save main.py.
Before you run the search engine, think about what you expect. Will "fire flying" return results now? What Pokemon should appear at the top?
- Run the interactive search by executing:
uv run python main.py
- At the Pokedex> prompt, type fire flying and press Enter.
- Try electric to see all electric-type Pokemon ranked by relevance.
- Type quit to exit.
You should see Charizard at the top of the "fire flying" results with a BM25 score. The "electric" query returns all electric-type Pokemon sorted by relevance instead of a flat unranked list.
Still seeing "No results found"?
- Make sure you saved both engine.py and main.py before running.
- Confirm the import line reads from engine import PokedexSearchEngine (not NaiveSearch).
- Check that all four methods (_tokenize_pokemon, _build_index, _bm25_score, search) are indented inside the PokedexSearchEngine class.
Help me figure out why my BM25 search engine returns no results.
✔️ Awesome, I've got everything!
Double check you have saved both engine.py and main.py.
ⓧ I'd like to double check the full code
Here is the complete main.py for comparison:
from engine import PokedexSearchEngine
from fetcher import load_pokedex
pokedex = load_pokedex()
engine = PokedexSearchEngine(pokedex)
while True:
query = input("Pokedex> ").strip()
if not query:
continue
if query.lower() in ("quit", "exit"):
break
results = engine.search(query)
if not results:
print("No results found.")
for p, score in results:
print(f"{p['name']} {p['types']} score={score:.2f}")
For engine.py, scroll up and confirm your file contains these sections in order:
- The import math statement at the top.
- The original NaiveSearch class (untouched).
- The PokedexSearchEngine class with __init__ and _tokenize_pokemon.
- The _build_index method.
- The _bm25_score, search, and get_indexed_terms methods.
Your search engine now handles multi-word queries and ranks results by relevance. Next up, you will polish the CLI with colored type badges, stat bars, and a professional interactive REPL.
Polish the CLI with Rich Output
Your search engine returns ranked results, but the raw text output is hard to scan. Names, types, and scores all blur together in plain monospace lines.
In this step, you will replace the minimal print loop with a polished CLI powered by the Rich library. Colored type badges, formatted tables, and stat bar charts will make results instantly readable.
In this step, get ready to:
- Build a display layer with colored type badges and a ranked results table.
- Add an interactive REPL with search timing and graceful exit handling.
- Add a special !stats command that renders full Pokemon stat bars.
Build the display layer with type colors
The current main.py is a bare-bones loop that dumps plain text. You will replace its entire contents, starting with the imports and a color map for all 18 Pokemon types.
- Open main.py in VS Code.
- Select all existing code and delete it.
- Paste the following imports and type-color mapping:
import time
from rich.console import Console
from rich.table import Table
from rich.text import Text
from engine import PokedexSearchEngine
from fetcher import load_pokedex
TYPE_COLORS = {
"normal": "white",
"fire": "red",
"water": "blue",
"electric": "yellow",
"grass": "green",
"ice": "cyan",
"fighting": "dark_red",
"poison": "magenta",
"ground": "orange3",
"flying": "light_sky_blue1",
"psychic": "hot_pink",
"bug": "green_yellow",
"rock": "dark_goldenrod",
"ghost": "medium_purple",
"dragon": "medium_purple1",
"dark": "grey37",
"steel": "grey70",
"fairy": "pink1",
}
console = Console()
What does this code do?
The TYPE_COLORS dictionary maps each of the 18 Pokemon types to a Rich color name. Rich uses these names to apply foreground and background coloring in the terminal.
The Console instance replaces Python's built-in print() with Rich-powered output that supports markup, tables, and styled text.
- Save main.py.
- Verify the color map loaded correctly by running:
uv run python -c "from main import TYPE_COLORS; print(len(TYPE_COLORS))"
You should see 18 printed, confirming all types are mapped.
Seeing an ImportError?
Make sure you deleted all the old main.py code before pasting the new block. Leftover references to the old input() loop can cause import conflicts.
Help me debug this ImportError.
- Add the format_types helper function below the console = Console() line:
def format_types(types: list[str]) -> Text:
# Build a styled Text object with colored badges for each type
text = Text()
for i, t in enumerate(types):
color = TYPE_COLORS.get(t, "white")
text.append(f" {t.upper()} ", style=f"bold {color} on {color}")
if i < len(types) - 1:
text.append(" ")
return text
What does this code do?
Each type name gets rendered as an uppercase badge with both foreground and background set to the type's color. This creates a colored pill effect in the terminal, similar to how type badges look in official Pokedex apps.
- Add the display_results function below format_types:
def display_results(results: list[tuple[dict, float]], elapsed: float):
if not results:
console.print("[bold red]No Pokemon found![/bold red]")
return
console.print(
f"\n[green]Found {len(results)} result(s) in {elapsed*1000:.1f}ms[/green]\n"
)
# Build a table with columns for rank, name, types, abilities, and score
table = Table(show_header=True, header_style="bold")
table.add_column("#", style="dim", width=3)
table.add_column("Name", style="bold white", min_width=12)
table.add_column("Types", min_width=16)
table.add_column("Abilities", min_width=20)
table.add_column("Score", justify="right", width=7)
for rank, (pokemon, score) in enumerate(results, 1):
types_text = format_types(pokemon["types"])
abilities = ", ".join(a.replace("-", " ").title() for a in pokemon["abilities"])
table.add_row(
str(rank),
f"#{pokemon['id']:04d} {pokemon['name'].title()}",
types_text,
abilities,
f"{score:.2f}",
)
console.print(table)
What does this code do?
This function builds a Rich Table with five columns: rank number, Pokemon name with ID, colored type badges, abilities, and BM25 score. It also prints the total result count and how many milliseconds the search took.
If no results match, it prints a red "No Pokemon found" message instead of an empty table.
Add the interactive REPL
The display functions are ready, but there is no entry point yet. You need a main() function that loads the index, times each query, and loops until the user exits.
- Add the main function and entry point at the bottom of main.py:
def main():
console.print("[bold cyan]Loading Pokedex...[/bold cyan]")
pokedex = load_pokedex()
engine = PokedexSearchEngine(pokedex)
console.print(
f"[bold green]Indexed {len(pokedex)} Pokemon with "
f"{len(engine.get_indexed_terms())} unique terms.[/bold green]\n"
)
console.print("Type a query to search, [bold]!stats <name>[/bold] for details, or [bold]quit[/bold] to exit.\n")
while True:
try:
query = console.input("[bold yellow]Pokedex>[/bold yellow] ").strip()
except (EOFError, KeyboardInterrupt):
break
if not query:
continue
if query.lower() in ("quit", "exit"):
break
start = time.perf_counter()
results = engine.search(query)
elapsed = time.perf_counter() - start
display_results(results, elapsed)
console.print("\n[bold cyan]See you next time, Trainer![/bold cyan]")
if __name__ == "__main__":
main()
What does this code do?
The main() function loads the cached Pokedex, builds the search engine, and enters an infinite loop. Each query is timed with time.perf_counter() so you can see how fast BM25 searches 1025 documents.
The console.input() method works like Python's built-in input() but renders Rich markup in the prompt. Catching EOFError and KeyboardInterrupt ensures the REPL exits cleanly if you press Ctrl+C or Ctrl+D.
- Save main.py.
Before you run the search engine, what do you expect to see when you search for "water"?
- Launch the Pokedex REPL by running:
uv run python main.py
- At the Pokedex> prompt, type water and press Enter.
You should see a formatted table with colored blue type badges, Pokemon names with IDs, abilities, and BM25 scores. The result count and search time in milliseconds appear above the table.
- Try fire dragon to confirm multi-term ranking still works with the new display.
- Type quit to exit the REPL.
Seeing a NameError for display_results?
Make sure display_results is defined above main() in the file. Python reads top to bottom, so functions must be defined before they are called.
Help me figure out what's out of order.
Add the !stats command for Pokemon detail cards
The table view is great for scanning results, but sometimes you want a deep dive on one Pokemon. A special !stats command will render a full card with all six base stats as visual bar charts.
- In main.py, add the display_stats function above the def main(): line:
def display_stats(pokemon: dict):
# Show a detailed Pokemon card with stat bars
console.print(f"\n[bold]{pokemon['name'].title()}[/bold] #{pokemon['id']:04d}")
console.print(format_types(pokemon["types"]))
console.print(f"Height: {pokemon['height']/10:.1f}m Weight: {pokemon['weight']/10:.1f}kg\n")
max_stat = 255
for stat_name, value in pokemon["stats"].items():
bar_length = int(value / max_stat * 30)
bar = "█" * bar_length + "░" * (30 - bar_length)
name_display = stat_name.replace("-", " ").title().ljust(16)
console.print(f" {name_display} {bar} {value:>3}")
console.print()
What does this code do?
Each of the six stats (hp, attack, defense, special-attack, special-defense, speed) is rendered as a 30-character bar. The bar length is proportional to 255, which is the maximum possible base stat value in Pokemon.
Block characters █ and ░ create a filled/empty visual effect that works in any modern terminal without extra dependencies.
Now you need to wire !stats into the REPL loop so it intercepts the command before the regular search runs.
- In main.py, find this line inside main():
start = time.perf_counter()
- Add the following !stats handler directly above that line:
if query.startswith("!stats "):
name = query[7:].strip().lower()
match = next((p for p in pokedex if p["name"] == name), None)
if match:
display_stats(match)
else:
console.print(f"[red]Pokemon '{name}' not found.[/red]")
continue
What does this code do?
When the input starts with !stats , the code slices off the command prefix and looks up the Pokemon by exact name. The continue statement skips the regular search path so the stat card displays on its own.
- Save main.py.
- Run the Pokedex again:
uv run python main.py
- At the Pokedex> prompt, type !stats pikachu and press Enter.
You should see Pikachu's name, colored Electric type badge, height/weight, and six stat bars rendered with block characters. The Speed stat bar should be noticeably longer than the others.
- Type quit to exit.
Seeing "Pokemon not found" for pikachu?
The !stats command requires the exact lowercase name as stored in the data. Make sure you typed !stats pikachu (all lowercase, with a space after !stats).
Help me debug why my !stats command can't find the Pokemon.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py and that your REPL shows colored type badges, timing, and stat bars.
ⓧ I'd like to double check the full code
Your main.py should contain these sections in order. Scroll through your file and confirm each is present:
- Imports (time, rich.console, rich.table, rich.text, engine, fetcher).
- TYPE_COLORS dictionary with 18 entries mapping type names to Rich color strings.
- console = Console() instance.
- format_types() function returning a styled Text object.
- display_results() function building a Rich Table with 5 columns.
- display_stats() function rendering stat bars with block characters.
- main() function with the REPL loop, !stats handler, timing, and quit/exit handling.
- if __name__ == "__main__": main() at the bottom.
Your Pokedex now looks and feels like a real tool. Next up, take on the Secret Mission and make your search engine forgiving of typos.
Secret mission
Add Fuzzy Matching for Typo-Tolerant Search
Real users make typos. Right now, searching "pikachoo" returns nothing. In this secret mission, you'll implement Levenshtein distance from scratch and integrate it with your BM25 pipeline so misspellings like "charazard" still find the right Pokemon.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files to continue experimenting, or delete them to free up disk space.
Resources you used:
- The pokedex-search project directory on your Desktop (contains all Python files and the virtual environment).
- The pokedex.json cache file inside the project directory (stores all 1025 Pokemon records locally).
Keep everything running
No action needed. Choose this if you want to keep experimenting with search queries or extending the engine.
- Your project files stay on your Desktop in the pokedex-search folder.
- The pokedex.json cache means you can re-run the search engine instantly without fetching from PokeAPI again.
- The virtual environment managed by uv lives inside the project folder and uses minimal disk space.
Pause - I'll come back to this later
There are no running processes or background services to stop. Your project is already paused by default.
- If you want to reclaim some disk space without losing your code, delete the pokedex.json cache file. It will be regenerated next time you run the search engine.
- Delete the cache file by running:
macOS
rm ~/Desktop/pokedex-search/pokedex.json
Windows
Remove-Item ~\Desktop\pokedex-search\pokedex.json
Delete - I don't want to use this again
Remove the entire project directory to delete all code, the cache file, and the virtual environment.
- Delete the pokedex-search folder by running:
macOS
rm -rf ~/Desktop/pokedex-search
Windows
Remove-Item -Recurse -Force ~\Desktop\pokedex-search
This removes all three Python files, the JSON cache, and the uv-managed virtual environment in one step.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully functional search engine from scratch that indexes 1025 Pokemon and returns ranked results in milliseconds.
You've learned how to:
- Fetch a large dataset asynchronously using aiohttp and asyncio.gather, pulling all 1025 Pokemon from PokeAPI in parallel batches and caching locally to pokedex.json.
- Build a from-scratch inverted index with multi-field tokenization and field weighting, then score results using the BM25 ranking algorithm to return relevance-ordered search results.
- Create a polished interactive CLI with colored type badges, stat bar charts, and millisecond-precision timing using the rich library.
- Secret Mission: Added Levenshtein-distance fuzzy matching so typos like "pikachoo" still find the right Pokemon, with fuzzy results receiving a reduced score so exact matches always rank higher.
Ready to quiz yourself?