Semantic Search for Your Knowledge Base
Search LinkedIn posts, Instagram saves, screenshots, and notes by meaning.
Introduction
30 Second Summary
You saved a LinkedIn post about error handling last month. You screenshotted a whiteboard diagram. You bookmarked an Instagram carousel on system design. Now you need that knowledge, but it's scattered across platforms and none of them talk to each other.
In this project, you will build a semantic search engine that pulls together LinkedIn posts, Instagram saves, screenshots, and markdown notes into one searchable knowledge base. You'll create a pipeline that ingests notes and screenshots, plus a live save form where you paste any post and it's searchable in seconds.
What You'll Build
You'll type "how do I handle errors gracefully" into a search bar and watch a LinkedIn post you saved three months ago surface alongside a screenshot of a whiteboard diagram and a markdown note — all ranked by how closely they match your intent.
By the end of this project, you'll have:
- An ingestion pipeline that runs OCR on screenshots and reads markdown docs, then embeds everything into a single searchable vector database.
- A "Save to Knowledge Base" form where you paste any LinkedIn post, Instagram caption, or snippet and it's embedded and searchable in seconds.
- A polished browser-based search interface with real-time results, platform badges showing whether each result came from LinkedIn, Instagram, a screenshot, or your notes.
- Secret Mission: Add a CrossEncoder re-ranking layer that re-scores the top results for dramatically better ordering.
Do I need machine learning experience?
No. You need Python proficiency (functions, classes, and pip) but zero ML background. The project teaches you how vector embeddings and retrieval work by building them from scratch.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters to you personally.
Set Up Your Project
A multi-source semantic search engine needs data from each platform you'll pull from, plus ML libraries that can turn text and images into searchable vectors.
In this step, you will create the project folder, install dependencies (including EasyOCR for image-to-text), gather some screenshots, and create sample notes.
In this step, get ready to:
- Create the project folder and virtual environment.
- Install the ML, OCR, and web framework dependencies.
- Gather screenshots and create sample notes for your knowledge base.
Create your project folder and virtual environment
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir semantic-search
cd semantic-search
- Create and activate a virtual environment so your dependencies stay isolated from other Python projects on your machine.
macOS
- Run these commands to create and activate your virtual environment:
python3 -m venv .venv
source .venv/bin/activate
Windows
- Run these commands to create and activate your virtual environment:
python -m venv .venv
.venv\Scripts\activate
Your terminal prompt now shows (.venv) at the beginning. This confirms the virtual environment is active.
Don't see (.venv) in your prompt?
Make sure you ran the activate command (not just the python3 -m venv .venv part). The venv creation and activation are two separate steps.
On Windows, if you see an error about execution policies, run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned first, then try activating again.
help me activate my Python virtual environment
Install the dependencies
Your project needs five packages: ChromaDB for vector storage, sentence-transformers for generating embeddings, easyocr for extracting text from screenshots, FastAPI for the REST API, and uvicorn as the web server.
- Install all project dependencies by running:
pip install chromadb==1.5.9 sentence-transformers==5.6.0 easyocr "fastapi[standard]==0.141.1" uvicorn
What are these packages?
- chromadb is a vector database that stores and searches embeddings locally.
- sentence-transformers provides pre-trained models that convert text into numerical vectors (embeddings).
- easyocr uses deep learning to extract text from images. It handles varied fonts and noisy backgrounds without needing a separate system binary.
- fastapi[standard] is a modern Python web framework with automatic API documentation.
- uvicorn serves FastAPI applications over HTTP.
This download includes PyTorch and the ML model weights. It may take a few minutes depending on your internet connection.
Installation failed or timed out?
Make sure your virtual environment is active (you should see (.venv) in your prompt). Installing without a venv can cause permission errors.
If pip times out, retry the command. Large packages like PyTorch sometimes need a second attempt on slow connections.
help me troubleshoot my pip install failure
Prepare your knowledge sources
Your search engine needs content to start with. You will create markdown notes and gather a few screenshots. Later, the save form will let you add LinkedIn posts, Instagram captions, and anything else on the fly.
- Create the source folders by running:
mkdir notes screenshots
Add your screenshots
- Collect 3-5 screenshots that contain text you'd want to search later. Good examples: whiteboard photos, documentation pages, code snippets, saved tweets, or presentation slides.
- Copy or save these images into the screenshots/ folder. The pipeline accepts .png, .jpg, .jpeg, and .webp formats.
Create your sample notes
- In your code editor, create a new file at notes/python-decorators.md and paste the following content:
# Python Decorators
Decorators are a powerful Python feature that allows you to modify the behavior of functions or classes without changing their source code. They use the `@` syntax placed above a function definition.
## How Decorators Work
A decorator is simply a function that takes another function as an argument and returns a new function. When you write `@my_decorator` above a function, Python automatically passes that function to the decorator.
The most common use cases include logging, authentication checks, timing function execution, and caching results. The `@property` decorator is built into Python and turns a method into a read-only attribute.
## Writing Your Own Decorator
To create a decorator, define a function that accepts a function parameter. Inside it, define a wrapper function that calls the original. The wrapper can add behavior before or after the original function runs.
Remember to use `functools.wraps` on your wrapper function. This preserves the original function's name and docstring, which matters for debugging and documentation tools.
## Common Patterns
Decorators can accept arguments too. This requires an extra layer of nesting: a decorator factory that returns the actual decorator. Flask uses this pattern extensively with `@app.route('/path')`.
- Create a new file at notes/docker-basics.md and paste the following content:
# Docker Basics
Docker is a platform for building, running, and shipping applications in containers. A container packages your application with all its dependencies so it runs consistently across different environments.
## Images and Containers
A Docker image is a read-only template that defines what goes into a container. Think of it like a blueprint. A container is a running instance of an image. You can run multiple containers from the same image.
Images are built from a Dockerfile, which contains step-by-step instructions. Each instruction creates a layer, and Docker caches these layers to speed up subsequent builds.
## Key Commands
Use `docker build` to create an image from a Dockerfile. Use `docker run` to start a container from an image. The `docker ps` command shows running containers, and `docker logs` displays their output.
## Volumes and Networking
Containers are isolated by default. To persist data beyond a container's lifecycle, use volumes. Volumes map a directory on your host machine to a directory inside the container.
For networking, Docker creates a bridge network by default. Containers on the same network can communicate using container names as hostnames. Use `docker-compose` to define multi-container applications with shared networks.
- Confirm the first two notes exist by running:
ls notes/
You should see docker-basics.md and python-decorators.md listed.
- Create a new file at notes/error-handling.md and paste the following content:
# Exception Handling Patterns
Error handling is about anticipating what can go wrong and responding gracefully. In Python, this means using try-except blocks strategically rather than wrapping everything in broad exception handlers.
## The Basics
A try block contains code that might raise an exception. The except block catches specific exception types and handles them. Always catch the most specific exception possible rather than using a bare `except` clause.
The `finally` block runs regardless of whether an exception occurred. Use it for cleanup like closing files or database connections. The `else` block runs only when no exception was raised.
## Custom Exceptions
Define custom exception classes for your application's specific error conditions. Inherit from `Exception` or a more specific built-in exception. This makes your error handling more precise and your code more readable.
## Best Practices
Never silently swallow exceptions with an empty except block. At minimum, log the error. Use context managers (the `with` statement) for resource management instead of manual try-finally blocks.
Raise exceptions early when you detect invalid state. Handle them at the level where you have enough context to respond meaningfully. Let unexpected exceptions bubble up to a top-level handler that logs them properly.
- Create a new file at notes/git-branching.md and paste the following content:
# Git Branching Strategies
Branches in Git let you work on features, fixes, or experiments without affecting the main codebase. Each branch is a lightweight pointer to a specific commit, making branch creation nearly instantaneous.
## Creating and Switching Branches
Use `git branch feature-name` to create a branch and `git checkout feature-name` to switch to it. The shortcut `git checkout -b feature-name` does both in one step. Modern Git also supports `git switch` as a clearer alternative.
## Merge vs Rebase
Merging combines two branches by creating a merge commit that has two parents. This preserves the full history of both branches. Rebasing replays your branch's commits on top of another branch, creating a linear history.
Use merge for shared branches where history preservation matters. Use rebase for local feature branches before merging to keep the main branch history clean.
## Common Workflows
Git Flow uses long-lived develop and main branches with short-lived feature branches. GitHub Flow is simpler with just main and feature branches, relying on pull requests for code review.
## Resolving Conflicts
Conflicts happen when two branches modify the same lines. Git marks the conflicting sections in the file. You manually choose which changes to keep, then stage and commit the resolution.
- Confirm the four notes exist by running:
ls notes/
You should see docker-basics.md, error-handling.md, git-branching.md, and python-decorators.md listed.
- Create a new file at notes/api-design.md and paste the following content:
# REST API Design Principles
A well-designed API is intuitive, consistent, and follows conventions that developers already know. REST APIs use HTTP methods and URL paths to model resources and actions on those resources.
## Resource Naming
Use nouns for endpoints, not verbs. The HTTP method already provides the verb. Use plural nouns for collections: `/users` not `/user`. Nest related resources: `/users/123/orders` retrieves orders belonging to user 123.
## HTTP Methods
GET retrieves data without side effects. POST creates new resources. PUT replaces an entire resource. PATCH updates specific fields. DELETE removes a resource. Use the right method for each operation.
## Status Codes
Return appropriate HTTP status codes. Use 200 for success, 201 for resource creation, 400 for client errors, 404 for missing resources, and 500 for server errors. Never return 200 with an error message in the body.
## Pagination and Filtering
For endpoints returning lists, support pagination with query parameters like `page` and `per_page` or cursor-based pagination for large datasets. Allow filtering with query parameters that map to resource attributes.
## Versioning
Version your API from day one. Use URL path versioning like `/api/v1/users` for clarity. This lets you evolve the API without breaking existing clients.
- Create a new file at notes/testing-strategies.md and paste the following content:
# Testing Strategies for Software Projects
Testing validates that your code works correctly and continues working as you make changes. A good test suite catches bugs early, documents expected behavior, and gives you confidence to refactor.
## Unit Tests
Unit tests verify individual functions or methods in isolation. They should be fast, independent, and focused on a single behavior. Mock external dependencies like databases or APIs so tests run without infrastructure.
## Integration Tests
Integration tests verify that multiple components work together correctly. They might test that your API endpoint correctly queries the database and returns formatted results. These are slower than unit tests but catch interface mismatches.
## The Testing Pyramid
Write many unit tests, fewer integration tests, and even fewer end-to-end tests. Unit tests are fast and pinpoint failures precisely. End-to-end tests are slow but verify the full user experience.
## Test-Driven Development
TDD means writing tests before implementation. Write a failing test that defines the expected behavior. Then write the minimum code to make it pass. Finally, refactor while keeping tests green.
## What to Test
Focus on behavior, not implementation details. Test edge cases, error conditions, and boundary values. If a bug is found in production, write a test that reproduces it before fixing it. This prevents regression.
- Confirm all six notes are in place by running:
ls notes/
You should see all six files: api-design.md, docker-basics.md, error-handling.md, git-branching.md, python-decorators.md, and testing-strategies.md.
Why these specific notes?
These notes cover different programming topics with distinct vocabulary. When you search later, this diversity lets you see semantic search in action. A query like "how do decorators work" will find the Python decorators note even though that exact phrase might not appear in it.
- Create the empty project files that you will build out in later steps by running:
touch ingest.py search_api.py
mkdir static
touch static/index.html
- Confirm the project structure by running:
ls
You should see ingest.py, notes/, screenshots/, search_api.py, and static/ in the output.
Missing files or folders?
Make sure you are in the semantic-search folder when you run these commands. Run pwd to check your current directory.
If touch is not recognized on Windows, create the empty files from your code editor instead (right-click in the Explorer sidebar and select New File).
help me create my project files and folders
Verify your setup
Before you run this, do you think importing the three main libraries will work? Or is something likely to fail?
- Verify all core libraries are importable by running:
python -c "from sentence_transformers import SentenceTransformer; from chromadb import Client; from fastapi import FastAPI; import easyocr; print('All imports OK')"
You should see All imports OK printed in your terminal. This confirms that ChromaDB, sentence-transformers, EasyOCR, and FastAPI are all installed correctly.
Seeing a ModuleNotFoundError?
This means your virtual environment is not active or the packages did not install correctly. Check that (.venv) appears in your terminal prompt.
If it does not, re-activate the environment and re-run the pip install command. If it does appear but imports still fail, try running pip list to see which packages are installed.
help me fix my ModuleNotFoundError
Your environment is ready with notes and screenshots in place. Next up, you will build the ingestion pipeline that turns these into searchable vector embeddings.
Build the Ingest Pipeline
Your notes folder and screenshots are ready, but right now they're just files on disk. To search them by meaning instead of exact keywords, you need to convert each file into numerical representations called vector embeddings and store them in a searchable database.
In this step, you will build the ingestion pipeline: load notes and extract text from screenshots via OCR, split them into chunks, embed each chunk, and store everything in ChromaDB. Then you will run your first semantic query and see results ranked by meaning.
In this step, get ready to:
- Build loaders for markdown notes and screenshots (via OCR).
- Embed chunks and store them in a persistent ChromaDB collection with source metadata.
- Run a semantic search query and see results ranked by relevance.
Build the multi-source file loader
The ingestion pipeline starts with two loaders: one for markdown and text files, and one for screenshot images that need OCR processing via EasyOCR. LinkedIn posts and Instagram captions will come in later through the live save form.
- Open ingest.py in your code editor and add the imports, configuration constants, and multi-source loading functions by pasting the following code:
import os
import easyocr
import chromadb
from sentence_transformers import SentenceTransformer
# Configuration constants
NOTES_DIR = "./notes"
SCREENSHOTS_DIR = "./screenshots"
CHROMA_DIR = "./chroma_db"
COLLECTION_NAME = "knowledge_base"
CHUNK_SIZE = 500
CHUNK_OVERLAP = 0
What does this code do?
- easyocr is the OCR library that extracts text from screenshot images. It uses deep learning and handles varied fonts and backgrounds well.
- SCREENSHOTS_DIR points to the folder where you saved your screenshots. The pipeline will OCR each image to extract searchable text.
- COLLECTION_NAME is knowledge_base since this collection will store content from notes, screenshots, and anything you save via the live form later.
- Below the imports, add the two loading functions — first the notes loader, then the screenshot loader:
def load_notes(directory: str) -> list[dict]:
"""Load all .md and .txt files from a directory."""
documents = []
for filename in sorted(os.listdir(directory)):
if filename.endswith((".md", ".txt")):
filepath = os.path.join(directory, filename)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
documents.append({
"content": content,
"source": filename,
"platform": "notes"
})
return documents
def load_screenshots(directory: str) -> list[dict]:
"""Extract text from screenshot images using EasyOCR."""
documents = []
if not os.path.exists(directory):
print(f" Skipping screenshots: {directory} not found")
return documents
# Initialize EasyOCR reader (downloads model on first run)
reader = easyocr.Reader(["en"], gpu=False)
image_extensions = (".png", ".jpg", ".jpeg", ".webp")
for filename in sorted(os.listdir(directory)):
if filename.lower().endswith(image_extensions):
filepath = os.path.join(directory, filename)
# Extract text from the image
results = reader.readtext(filepath, detail=0)
content = " ".join(results).strip()
if content:
documents.append({
"content": content,
"source": filename,
"platform": "screenshot"
})
return documents
What does the screenshot loader do?
- load_screenshots() initializes EasyOCR with gpu=False (runs on CPU so no GPU setup needed), then extracts text from each image. The detail=0 flag returns just the text strings without bounding box coordinates.
- Both loaders gracefully skip if the source directory doesn't exist or is empty. This means you can run the pipeline even if you haven't added screenshots yet.
- Save ingest.py.
- Verify the notes loader works by running this quick test:
You should see output listing your note files. If you have screenshots in the folder, those will appear too. Sources that aren't present are skipped gracefully.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is active. You should see (.venv) at the start of your terminal prompt.
- Make sure you are running the command from inside the semantic-search folder where ingest.py lives.
- help me debug this ModuleNotFoundError
Embed and store in ChromaDB
Now that you can load and chunk files, the next piece ties everything together: load the embedding model, process all chunks, and store them in a persistent vector database.
- In ingest.py, add the main() function and the entry point below your chunk_document function:
def main():
print("Loading embedding model...")
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
# Load from notes and screenshots
print("Loading from all sources...")
documents = []
documents.extend(load_notes(NOTES_DIR))
documents.extend(load_screenshots(SCREENSHOTS_DIR))
print(f"Found {len(documents)} documents total")
# Chunk all documents
all_chunks = []
for doc in documents:
chunks = chunk_document(doc)
all_chunks.extend(chunks)
print(f"Created {len(all_chunks)} chunks")
# Generate embeddings for every chunk
print("Generating embeddings...")
texts = [chunk["text"] for chunk in all_chunks]
embeddings = model.encode(texts, show_progress_bar=True)
What is happening here?
SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2") loads a pre-trained model that converts text into 384-dimensional vectors. Similar texts produce vectors that are close together in this space.
model.encode() takes your list of chunk strings and returns one embedding vector per chunk. The progress bar lets you watch it process.
- Immediately below the embedding code, still inside main(), add the ChromaDB storage logic and the entry point:
# Store in ChromaDB
print("Storing in ChromaDB...")
client = chromadb.PersistentClient(path=CHROMA_DIR)
# Delete existing collection if it exists, then recreate
try:
client.delete_collection(COLLECTION_NAME)
except ValueError:
pass
collection = client.create_collection(
name=COLLECTION_NAME, metadata={"hnsw:space": "cosine"}
)
# Add all chunks with their embeddings and metadata
collection.add(
ids=[f"chunk_{i}" for i in range(len(all_chunks))],
embeddings=embeddings.tolist(),
documents=texts,
metadatas=[chunk["metadata"] for chunk in all_chunks],
)
print(f"Done! Stored {collection.count()} chunks in ChromaDB.")
if __name__ == "__main__":
main()
What does the storage step do?
- PersistentClient saves the database to disk in the ./chroma_db folder. Data survives between script runs.
- The try/except ValueError block deletes any existing collection so you start fresh each time you re-ingest. If the collection does not exist yet, ChromaDB raises a ValueError which the code safely ignores.
- metadata={"hnsw:space": "cosine"} tells ChromaDB to use cosine distance when comparing vectors. Two identical texts have distance 0.
- collection.add() stores everything together: unique IDs, the embedding vectors, the original text, and the source metadata.
- Save ingest.py.
- Before you run this, what do you think the output will show? How many chunks do you expect from 6 files of 200-400 words each, split into 500-character pieces?
- Run the ingestion script:
python ingest.py
You should see progress output showing the model loading, file count, chunk count, an embedding progress bar, and a final line confirming the stored chunk count (around 12-20 chunks depending on your note lengths).
First run downloads the model
The first time you run this script, it downloads the all-MiniLM-L6-v2 model (~90MB) from HuggingFace. Subsequent runs use the cached version and start much faster.
🙋♀️ Script hangs or errors during model download?
- Check your internet connection. The model download requires access to HuggingFace servers.
- If you see a permission error writing to the cache, make sure you have write access to ~/.cache/huggingface/.
- help me fix my sentence-transformers model download
- Confirm the chunks are stored by running:
python -c "import chromadb; c = chromadb.PersistentClient('./chroma_db'); col = c.get_collection('knowledge_base'); print(f'Stored {col.count()} chunks')"
You should see the chunk count printed, matching the number from the ingestion output.
Run a test query from a script
The whole point of semantic search is finding relevant content even when your query uses different words than the source. Time to prove it works.
- Think about this: you're about to search for "how do decorators work" across your knowledge base. The query might match a markdown note or even text extracted from a screenshot. Do you think it will find the right content even though you're not using the exact same words?
- Run a semantic search query against your collection:
python -c "
import chromadb
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
client = chromadb.PersistentClient('./chroma_db')
collection = client.get_collection('knowledge_base')
query = 'how do decorators work'
query_embedding = model.encode(query).tolist()
results = collection.query(
query_embeddings=[query_embedding],
n_results=3,
include=['documents', 'metadatas', 'distances']
)
for i in range(len(results['ids'][0])):
print(f'\n--- Result {i+1} ---')
meta = results[\"metadatas\"][0][i]
print(f'Platform: {meta[\"platform\"]}')
print(f'Source: {meta[\"source\"]}')
print(f'Distance: {results[\"distances\"][0][i]:.4f}')
print(f'Text: {results[\"documents\"][0][i][:100]}...')
"
You should see 3 results ranked by cosine distance, each showing which platform it came from (notes or screenshot). Results surface based on semantic relevance, regardless of the source format.
Why does this work without keyword matching?
The embedding model converts both the query and the stored chunks into vectors in the same 384-dimensional space. Text about decorators and a question about "how decorators work" land close together because they share semantic meaning, not because they share exact words.
The distance value tells you how far apart the vectors are. Lower distance means closer match. A distance near 0 is a near-perfect semantic match.
🙋♀️ Getting unexpected results?
- If your top result is not from python-decorators.md, check that your notes file actually contains content about decorators and is at least 200 words long.
- If all distances are very high (above 0.8), your notes content may be too short for meaningful embeddings. Make sure each file is 200-400 words.
- help me understand my semantic search results
✔️ Awesome, I've got everything!
Great. Double check you have saved ingest.py and that your chroma_db/ folder was created after running the script.
ⓧ I'd like to double check the full code
Here is the complete ingest.py at this point. Make sure yours matches:
import os
import chromadb
from sentence_transformers import SentenceTransformer
NOTES_DIR = "./notes"
CHROMA_DIR = "./chroma_db"
COLLECTION_NAME = "notes"
CHUNK_SIZE = 500
CHUNK_OVERLAP = 0
def load_files(directory: str) -> list[dict]:
documents = []
for filename in sorted(os.listdir(directory)):
if filename.endswith((".md", ".txt")):
filepath = os.path.join(directory, filename)
with open(filepath, "r", encoding="utf-8") as f:
content = f.read()
documents.append({"filename": filename, "content": content})
return documents
def chunk_document(doc: dict) -> list[dict]:
content = doc["content"]
filename = doc["filename"]
chunks = []
start = 0
while start < len(content):
end = start + CHUNK_SIZE
chunk_text = content[start:end]
enriched_chunk = f"[Source: {filename}] {chunk_text}"
chunks.append({"text": enriched_chunk, "metadata": {"source": filename, "start_char": start}})
start += CHUNK_SIZE
return chunks
def main():
print("Loading embedding model...")
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
print(f"Loading files from {NOTES_DIR}...")
documents = load_files(NOTES_DIR)
print(f"Found {len(documents)} files")
all_chunks = []
for doc in documents:
chunks = chunk_document(doc)
all_chunks.extend(chunks)
print(f"Created {len(all_chunks)} chunks")
print("Generating embeddings...")
texts = [chunk["text"] for chunk in all_chunks]
embeddings = model.encode(texts, show_progress_bar=True)
print("Storing in ChromaDB...")
client = chromadb.PersistentClient(path=CHROMA_DIR)
try:
client.delete_collection(COLLECTION_NAME)
except ValueError:
pass
collection = client.create_collection(
name=COLLECTION_NAME, metadata={"hnsw:space": "cosine"}
)
collection.add(
ids=[f"chunk_{i}" for i in range(len(all_chunks))],
embeddings=embeddings.tolist(),
documents=texts,
metadatas=[chunk["metadata"] for chunk in all_chunks],
)
print(f"Done! Stored {collection.count()} chunks in ChromaDB.")
if __name__ == "__main__":
main()
Your ingestion pipeline is working and semantic search finds relevant content across notes and screenshots. Next up, you will fix a real problem with this naive chunking approach and build something smarter.
Improve Chunking Strategy
Your multi-source ingestion pipeline works. You can search across LinkedIn posts, Instagram captions, screenshots, and notes all at once. But there is a hidden problem lurking in how you split content into chunks.
Right now, the chunker slices every 500 characters with no overlap. If a concept spans two chunks, neither chunk alone contains the full idea. The search returns a fragment that cuts off mid-sentence. In this step, you will experience that broken result firsthand, then fix it with overlapping windows so every concept boundary is captured in at least one chunk.
In this step, get ready to:
- Run a cross-boundary query and see the truncated, nonsensical result.
- Add overlapping windows to the chunking strategy.
- Verify the same query now returns full-context results with higher relevance scores.
Run a cross-boundary query
Before changing anything, you need to see the problem with your own eyes. A query that touches a concept explained across two paragraphs will hit a chunk boundary and return garbage.
Before you run this, what do you think will happen when your query needs context from two consecutive chunks that don't share any text?
- Run this query against your existing database:
python -c "
import chromadb
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
client = chromadb.PersistentClient('./chroma_db')
col = client.get_collection('knowledge_base')
emb = model.encode('what is the relationship between unit tests and integration tests').tolist()
results = col.query(query_embeddings=[emb], n_results=3, include=['documents', 'distances'])
for i in range(len(results['ids'][0])):
sim = 1 - results['distances'][0][i]
print(f'Result {i+1} (similarity: {sim:.4f}):')
print(results['documents'][0][i][:200])
print()
"
Look at the top result. You should see text that cuts off mid-sentence or starts mid-thought. The similarity score is also lower than you would expect for a relevant note. This is the chunk boundary problem in action.
Why does this happen?
With zero overlap, chunk 1 ends at exactly character 500 and chunk 2 starts at character 501. If a paragraph explaining the relationship between testing types spans characters 450-550, it gets split across two chunks. Neither chunk alone contains the full explanation, so neither is a strong semantic match for the query.
Add overlapping windows to the chunking strategy
The fix is simple. Instead of advancing the window by the full 500 characters each time, advance by 400. Each new chunk overlaps the previous one by 100 characters. Text near a boundary now appears in two adjacent chunks, so at least one chunk captures the full context.
- In ingest.py, find this line near the top of the file:
CHUNK_OVERLAP = 0
- Change 0 to 100, so the line now looks like this:
CHUNK_OVERLAP = 100
- Now scroll down to the chunk_document function. Find this line at the end of the while loop:
start += CHUNK_SIZE
- Replace it with this line, which advances by the chunk size minus the overlap:
start += CHUNK_SIZE - CHUNK_OVERLAP
How does the overlap work?
With CHUNK_SIZE = 500 and CHUNK_OVERLAP = 100, the window advances by 400 characters each iteration. Chunk 1 covers characters 0-500. Chunk 2 covers characters 400-900. The 100 characters from 400-500 appear in both chunks.
This means any concept that falls near a boundary gets captured in full by at least one chunk. The tradeoff is slightly more chunks stored (roughly 25% more), but far better retrieval quality.
- Save ingest.py.
- Re-run the ingestion pipeline to repopulate your database with the improved chunks:
python ingest.py
You should see the chunk count increase compared to last time. With 100-character overlap, the same set of notes produces roughly 25% more chunks.
Seeing the same chunk count as before?
Make sure you saved ingest.py before running. Check that the CHUNK_OVERLAP constant at the top of the file reads 100, not 0.
Also confirm the advance line says start += CHUNK_SIZE - CHUNK_OVERLAP and not start += CHUNK_SIZE.
help me debug why my chunk count hasn't changed after editing ingest.py
Verify the fix with the same query
Time to test whether the overlap actually improves retrieval. You will run the exact same cross-boundary query from earlier.
Before you run this, do you expect the similarity score to be higher or lower than before? Why?
- Run the same query against your updated database:
python -c "
import chromadb
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
client = chromadb.PersistentClient('./chroma_db')
col = client.get_collection('knowledge_base')
emb = model.encode('what is the relationship between unit tests and integration tests').tolist()
results = col.query(query_embeddings=[emb], n_results=3, include=['documents', 'distances'])
for i in range(len(results['ids'][0])):
sim = 1 - results['distances'][0][i]
print(f'Result {i+1} (similarity: {sim:.4f}):')
print(results['documents'][0][i][:200])
print()
"
This time, the top result should contain full context from both sides of the previous chunk boundary. The similarity score is higher because the chunk now captures the complete concept rather than a fragment.
What changed in the results?
With overlap, the chunk that covers the testing-relationship paragraph now includes the full explanation. The embedding for that chunk is a much stronger semantic match for your query, so it ranks higher and has a better similarity score.
This is the core tradeoff in chunking strategy: more overlap means more storage and slightly slower ingestion, but dramatically better retrieval quality for queries that touch concept boundaries.
✔️ Awesome, I've got everything!
Great. Make sure you have saved ingest.py with both changes.
ⓧ I'd like to double check the full code
Your ingest.py should have these changes compared to before:
- Line 8: CHUNK_OVERLAP = 100 (was 0).
- A docstring """Load all .md and .txt files from a directory.""" inside load_files.
- A docstring """Split a document into overlapping chunks with metadata.""" inside chunk_document.
- The advance line in the while loop: start += CHUNK_SIZE - CHUNK_OVERLAP (was start += CHUNK_SIZE).
Everything else in the file stays the same as before.
Your retrieval pipeline now handles concept boundaries properly. Next up, you will wrap this search logic in a proper HTTP endpoint so anyone can query it with a browser or API client.
Build the FastAPI Search Endpoint
Right now your search pipeline lives inside a Python script. You can query it, but only by editing code and re-running the file. That works for testing, but it is not something you can share, demo, or build a UI on top of.
In this step you will wrap your search logic in a proper HTTP endpoint using FastAPI. The result is a real REST API with typed request and response models, auto-generated interactive docs, and a URL anyone can hit with curl or a browser.
In this step, get ready to:
- Build a FastAPI app that loads your embedding model and ChromaDB collection at startup.
- Create a typed /search endpoint that accepts natural language queries and returns ranked results.
- Verify the full API works through FastAPI's interactive docs at /docs.
Build the FastAPI app with a search endpoint
The API needs two things before it can serve search requests: Pydantic models that define the shape of the response, and a startup routine that loads the embedding model and connects to ChromaDB once (not on every request).
- Open search_api.py in your editor and add the imports, constants, and response models by pasting this code:
from contextlib import asynccontextmanager
import chromadb
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer
CHROMA_DIR = "./chroma_db"
COLLECTION_NAME = "knowledge_base"
class SearchResult(BaseModel):
document: str
source: str
platform: str
score: float
chunk_id: str
class SearchResponse(BaseModel):
query: str
results: list[SearchResult]
total: int
What does this code do?
The two Pydantic models define exactly what the API returns. SearchResult represents one matched chunk (the text, its source name, the platform it came from, a similarity score, and its ID). SearchResponse wraps multiple results together with the original query and a count.
FastAPI uses these models to validate outgoing data AND to auto-generate the interactive documentation you will see shortly.
- Below the models, add the startup lifespan, app creation, static file mount, root route, and health check by pasting this code:
# Global references set during lifespan
model = None
collection = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global model, collection
print("Loading embedding model...")
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
print("Connecting to ChromaDB...")
client = chromadb.PersistentClient(path=CHROMA_DIR)
collection = client.get_collection(name=COLLECTION_NAME)
print(f"Ready! Collection has {collection.count()} chunks.")
yield
app = FastAPI(title="Semantic Search API", version="1.0.0", lifespan=lifespan)
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/", include_in_schema=False)
async def root():
return FileResponse("static/index.html")
@app.get("/health")
async def health():
return {"status": "healthy", "chunks": collection.count()}
What does this code do?
The lifespan function runs once when the server starts. It loads the SentenceTransformer model and connects to your existing ChromaDB collection so they are ready for every incoming request without reloading.
The /health endpoint gives you a quick way to confirm the server is running and how many chunks are indexed.
- Save search_api.py.
- Start the development server by running this command:
fastapi dev search_api.py
- Open http://127.0.0.1:8000/health in your browser.
You should see a JSON response like {"status": "healthy", "chunks": 15} (your chunk count depends on how many notes you created). This confirms the model loaded and ChromaDB connected successfully.
Server not starting?
- If you see ModuleNotFoundError, make sure your virtual environment is activated. You should see (.venv) in your terminal prompt.
- If you see a collection-not-found error, run python ingest.py first to populate ChromaDB.
- If port 8000 is already in use, stop any other server using that port and retry.
help me debug my FastAPI server not starting
Add the search endpoint logic
The app boots and responds to health checks. Now you need the core endpoint that accepts a natural language query, embeds it, searches ChromaDB, and returns ranked results with similarity scores.
- In your terminal, press Ctrl+C to stop the running server.
- In search_api.py, add the search endpoint below the health function by pasting this code at the bottom of the file:
@app.get("/search", response_model=SearchResponse)
async def search(query: str, top_k: int = 5):
# Embed the query
query_embedding = model.encode(query).tolist()
# Search ChromaDB
results = collection.query(
query_embeddings=[query_embedding],
n_results=top_k,
include=["documents", "metadatas", "distances"],
)
# Format results (ChromaDB returns cosine distance, convert to similarity)
search_results = []
for i in range(len(results["ids"][0])):
distance = results["distances"][0][i]
similarity = 1 - distance
meta = results["metadatas"][0][i]
search_results.append(
SearchResult(
document=results["documents"][0][i],
source=meta["source"],
platform=meta.get("platform", "notes"),
score=round(similarity, 4),
chunk_id=results["ids"][0][i],
)
)
return SearchResponse(
query=query, results=search_results, total=len(search_results)
)
What does this code do?
The endpoint takes a query string and an optional top_k count (defaulting to 5). It embeds the query with the same model used during ingestion, then asks ChromaDB for the nearest vectors. Each result includes a platform field so the frontend can display source badges (LinkedIn, Instagram, screenshot, or notes).
ChromaDB returns cosine distances (0 means identical, 2 means opposite). The line 1 - distance converts that to a similarity score where higher is better. This makes the scores intuitive for anyone reading the results.
FastAPI automatically returns a 422 response if query is missing. If ChromaDB finds no matches, the endpoint returns an empty list with a 200 status. You get proper error handling without writing any extra code.
- Save search_api.py.
- Restart the server by running:
fastapi dev search_api.py
Before you open the docs page, predict: what do you think will happen when you search for a phrase that does not appear word-for-word in any of your notes?
- Open http://127.0.0.1:8000/docs in your browser.
- Click the /search endpoint to expand it.
- Click Try it out.
- Enter a natural language query in the query field, such as how do decorators work.
- Click Execute.
You should see a JSON response with your query, a list of results each containing a document passage, the source name, the platform it came from (linkedin, instagram, screenshot, or notes), a similarity score, and a chunk ID. Results from all your sources rank together by relevance.
Getting an empty results list?
- Make sure you ran python ingest.py after implementing the overlapping chunking in Step 3. The chroma_db/ directory must contain your re-ingested chunks.
- Check your query is not blank. An empty string in the query field will return a 422 validation error, not an empty list.
help me debug empty search results from my FastAPI semantic search endpoint
💡 Why use FastAPI's lifespan instead of loading on each request?
Loading a SentenceTransformer model takes several seconds. If you loaded it inside the endpoint function, every single search request would wait for the model to load from disk. The lifespan pattern loads it once at startup and keeps it in memory for all requests.
This is the same pattern production ML APIs use. The model stays warm in memory and responds in milliseconds rather than seconds.
✔️ Awesome, I've got everything!
Great. Double check you have saved search_api.py and that http://127.0.0.1:8000/docs shows all three endpoints: /health, /search, and the hidden root /.
ⓧ I'd like to double check the full code
Here is the complete search_api.py file. Make sure yours matches exactly:
from contextlib import asynccontextmanager
import chromadb
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer
CHROMA_DIR = "./chroma_db"
COLLECTION_NAME = "knowledge_base"
class SearchResult(BaseModel):
document: str
source: str
platform: str
score: float
chunk_id: str
class SearchResponse(BaseModel):
query: str
results: list[SearchResult]
total: int
# Global references set during lifespan
model = None
collection = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global model, collection
print("Loading embedding model...")
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
print("Connecting to ChromaDB...")
client = chromadb.PersistentClient(path=CHROMA_DIR)
collection = client.get_collection(name=COLLECTION_NAME)
print(f"Ready! Collection has {collection.count()} chunks.")
yield
app = FastAPI(title="Semantic Search API", version="1.0.0", lifespan=lifespan)
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/", include_in_schema=False)
async def root():
return FileResponse("static/index.html")
@app.get("/health")
async def health():
return {"status": "healthy", "chunks": collection.count()}
@app.get("/search", response_model=SearchResponse)
async def search(query: str, top_k: int = 5):
query_embedding = model.encode(query).tolist()
results = collection.query(
query_embeddings=[query_embedding],
n_results=top_k,
include=["documents", "metadatas", "distances"],
)
search_results = []
for i in range(len(results["ids"][0])):
distance = results["distances"][0][i]
similarity = 1 - distance
meta = results["metadatas"][0][i]
search_results.append(
SearchResult(
document=results["documents"][0][i],
source=meta["source"],
platform=meta.get("platform", "notes"),
score=round(similarity, 4),
chunk_id=results["ids"][0][i],
)
)
return SearchResponse(
query=query, results=search_results, total=len(search_results)
)
Your search engine now has a proper API. Anyone on your network could hit this endpoint with a query and get structured results back. Next up, you will build a browser-based frontend that makes searching feel instant and visual.
Build the Web Frontend
Your search API returns JSON results, but sharing a curl command is not the same as showing someone a working search engine.
A browser UI turns your project into a live demo. In this step, you will build a single-page search interface that sends queries to your /search endpoint and displays ranked results with relevance scores and platform badges showing whether each result came from LinkedIn, Instagram, a screenshot, or your notes.
In this step, get ready to:
- Build a dark-themed search page with a centered search bar.
- Wire up live search with debounced API calls that render result cards.
- Verify the full search experience in your browser.
Create the search page structure and styles
Your search_api.py already mounts the static/ directory and serves static/index.html at the root route. The file is currently empty, so you will fill it with a search interface.
- Open static/index.html in your editor and add the page skeleton with base layout styles:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Semantic Search</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
}
h1 {
font-size: 2rem;
margin-bottom: 8px;
color: #ffffff;
}
.subtitle {
color: #888;
margin-bottom: 40px;
font-size: 0.95rem;
}
.search-container {
width: 100%;
max-width: 700px;
}
What does this code do?
This sets up a dark-themed page (#0f0f0f background) with flexbox centering. The .search-container is capped at 700px wide so the search bar never stretches too wide on large screens.
- Directly below that code, add the input field and result card styles:
input[type="text"] {
width: 100%;
padding: 16px 20px;
font-size: 1.1rem;
border: 1px solid #333;
border-radius: 12px;
background: #1a1a1a;
color: #fff;
outline: none;
transition: border-color 0.2s;
}
input[type="text"]:focus {
border-color: #646cff;
}
.results {
margin-top: 30px;
width: 100%;
}
.result-card {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 10px;
padding: 20px;
margin-bottom: 16px;
transition: border-color 0.2s;
}
.result-card:hover {
border-color: #444;
}
What are these styles for?
The input gets a rounded dark field with a purple focus ring (#646cff). Each result card has subtle borders and a hover highlight so results feel interactive.
- Continue adding the result detail styles directly below:
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.source-badge {
background: #646cff22;
color: #646cff;
padding: 4px 10px;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
}
.score {
color: #4ade80;
font-weight: 600;
font-size: 0.9rem;
}
.result-text {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.85rem;
line-height: 1.6;
color: #ccc;
white-space: pre-wrap;
}
- Finally, add the loading/empty state styles, close the head, and add the body HTML:
.loading {
text-align: center;
color: #666;
padding: 20px;
}
.empty {
text-align: center;
color: #555;
padding: 40px;
}
.platform-badge {
background: #4ade8022;
color: #4ade80;
padding: 4px 8px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
</style>
</head>
<body>
<h1>Semantic Search</h1>
<p class="subtitle">Search your knowledge base with natural language</p>
<div class="search-container">
<input type="text" id="searchInput" placeholder="Try: how do I handle errors in Python..." autofocus>
<div class="results" id="results"></div>
</div>
What is in the body?
The body contains a heading, a subtitle, and the search container. The searchInput element is where the user types queries. The empty results div is where JavaScript will inject result cards.
- Save static/index.html and start the server by running:
fastapi dev search_api.py
- Open http://127.0.0.1:8000 in your browser.
You should see a dark page with the title "Semantic Search" and a styled search bar in the center. Typing in it does nothing yet because there is no JavaScript wired up.
Seeing a blank page?
- Make sure you saved static/index.html before refreshing the browser.
- Check the terminal for error messages. If FastAPI cannot find the static/ directory, make sure you are running the command from your semantic-search project folder.
- Confirm the HTML is valid by checking for unclosed tags. Every <div> needs a matching </div>.
Still stuck? Help me debug why my static HTML page is not loading in FastAPI.
Add the live search JavaScript
The page looks right, but it needs JavaScript to fetch results from your API as the user types. You will add a debounced event listener that waits 300ms after the user stops typing before calling /search.
- In static/index.html, add the following script block directly below the closing </div> of search-container:
<script>
const input = document.getElementById('searchInput');
const resultsDiv = document.getElementById('results');
let debounceTimer;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
const query = input.value.trim();
if (!query) {
resultsDiv.innerHTML = '';
return;
}
resultsDiv.innerHTML = '<div class="loading">Searching...</div>';
What does this set up?
The listener fires on every keystroke but clears the previous timer each time. If the input is empty, it clears results immediately. Otherwise, it shows a "Searching..." state while waiting for the debounce to fire.
- Directly below that code (still inside the <script> tag), add the fetch logic and the escapeHtml helper:
debounceTimer = setTimeout(() => {
fetch(`/search?query=${encodeURIComponent(query)}&top_k=5`)
.then(res => res.json())
.then(data => {
if (data.results.length === 0) {
resultsDiv.innerHTML = '<div class="empty">No results found</div>';
return;
}
resultsDiv.innerHTML = data.results.map(r => `
<div class="result-card">
<div class="result-header">
<div>
<span class="platform-badge">${r.platform}</span>
<span class="source-badge">${r.source}</span>
</div>
<span class="score">${(r.score * 100).toFixed(1)}% match</span>
</div>
<div class="result-text">${escapeHtml(r.document)}</div>
</div>
`).join('');
})
.catch(() => {
resultsDiv.innerHTML = '<div class="empty">Search failed. Is the server running?</div>';
});
}, 300);
});
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>
What does this code do?
- The setTimeout with 300ms delay is the debounce. It only fires the API call once the user pauses typing.
- Each result is rendered as a card showing the source filename as a badge, the similarity score as a percentage, and the passage text in monospace.
- The escapeHtml function prevents XSS by using the browser's own text-content escaping instead of inserting raw HTML from the API response.
- Save static/index.html.
- Switch back to your browser at http://127.0.0.1:8000 and refresh the page.
Before you type a query, predict what you expect to see. Will results appear instantly, or will there be a brief delay?
- Type how do I handle errors into the search bar.
After a short 300ms pause, result cards appear below the search bar. Each card shows a green platform badge (linkedin, instagram, screenshot, or notes), a purple source badge, and a green percentage match score. Results from your LinkedIn posts rank alongside your notes and screenshots, all by semantic relevance.
No results appearing?
- Open your browser's Developer Tools (F12 or right-click and select **Inspect**) and check the **Console** tab for JavaScript errors.
- Verify the server is running in your terminal. If you stopped it, run fastapi dev search_api.py again.
- Make sure you ran python ingest.py previously so the ChromaDB collection has chunks to search.
Still stuck? Help me debug why my search results are not showing up in the browser.
✔️ Awesome, I've got everything!
Great. Double check you have saved static/index.html and your search results display correctly.
ⓧ I'd like to double check the full code
Your static/index.html is built from the chunks above. Scroll up and confirm you have, in order:
- The DOCTYPE and base layout CSS (body, h1, subtitle, search-container).
- The input and result-card styles.
- The result-header, source-badge, score, and result-text styles.
- The loading/empty styles, the closing </style></head>, and the body HTML.
- The full <script> block ending with </script></body></html>.
Your search engine has a polished browser interface with platform badges. But right now, adding new content means re-running the full pipeline. Next up, you will add real-time saving so pasting a post makes it instantly searchable.
Add Real-Time Saving
Right now, getting new content into your search engine means re-exporting data from LinkedIn or Instagram and re-running the full ingestion pipeline. That's fine for an initial load, but when you see a post worth saving while browsing, you want it searchable immediately.
In this step, you will add a "Save to Knowledge Base" form directly on your search page. You'll paste a post's text, pick the platform, and hit save. The content is embedded and stored in ChromaDB instantly, searchable within seconds.
In this step, get ready to:
- Build a POST endpoint that accepts new content and embeds it into ChromaDB on the fly.
- Add a save form to the frontend with a text area, platform selector, and submit button.
- Verify that saved content is immediately searchable without re-running the pipeline.
Build the save endpoint
The new endpoint needs to accept text content, a platform label, and an optional source name. It will chunk the text, embed it with the same model already loaded in memory, and add it to ChromaDB. No restart needed.
- Stop the running server with Ctrl+C.
- In search_api.py, add a request model and the save endpoint below your existing /search endpoint:
class SaveRequest(BaseModel):
content: str
platform: str = "notes"
source: str = "manual-save"
@app.post("/save")
async def save_content(req: SaveRequest):
# Chunk the content using the same strategy as ingest.py
chunk_size = 500
chunk_overlap = 100
chunks = []
start = 0
while start < len(req.content):
end = start + chunk_size
chunk_text = req.content[start:end]
chunks.append(chunk_text)
start += chunk_size - chunk_overlap
# Embed all chunks
embeddings = model.encode(chunks).tolist()
# Get the current count to generate unique IDs
current_count = collection.count()
# Store in ChromaDB with platform metadata
collection.add(
ids=[f"live_{current_count + i}" for i in range(len(chunks))],
embeddings=embeddings,
documents=chunks,
metadatas=[{"source": req.source, "platform": req.platform}] * len(chunks),
)
return {
"status": "saved",
"chunks_added": len(chunks),
"total_chunks": collection.count(),
}
What does this endpoint do?
The /save endpoint receives content via POST, splits it into overlapping chunks using the same 500/100 strategy as your ingestion script, embeds each chunk using the model already in memory, and adds them to the existing ChromaDB collection. The live_ prefix on IDs distinguishes real-time saves from batch-ingested content.
Because the model is already loaded from the lifespan startup, embedding happens in milliseconds. No file I/O, no pipeline restart.
- Save search_api.py.
- Restart the server by running:
fastapi dev search_api.py
- Test the new endpoint by opening http://127.0.0.1:8000/docs and expanding the /save POST endpoint.
- Click Try it out.
- Paste this test body into the request:
{
"content": "Dependency injection is a design pattern where objects receive their dependencies from outside rather than creating them internally. This makes code more testable and loosely coupled.",
"platform": "linkedin",
"source": "linkedin-saved-post"
}
- Click Execute.
You should see a response showing "status": "saved" with the number of chunks added and the new total. The content is now searchable immediately.
Getting a 422 validation error?
- Make sure your request body is valid JSON. Check for missing quotes or trailing commas.
- The content field is required. The platform and source fields are optional (they default to "notes" and "manual-save").
- help me debug my /save endpoint returning a 422 error
Add the save form to the frontend
The API endpoint works. Now you need a form on the search page so you can save content without opening the API docs every time.
- In static/index.html, find the closing </div> of search-container and add this save form block directly below it (before the <script> tag):
<div class="search-container" style="margin-top: 60px;">
<h2 style="color: #fff; font-size: 1.3rem; margin-bottom: 16px;">Save to Knowledge Base</h2>
<textarea id="saveContent" rows="4" style="width:100%; padding:12px; font-size:0.95rem; border:1px solid #333; border-radius:10px; background:#1a1a1a; color:#fff; resize:vertical; outline:none;" placeholder="Paste a LinkedIn post, Instagram caption, or any text..."></textarea>
<div style="display:flex; gap:10px; margin-top:12px;">
<select id="savePlatform" style="padding:10px; border-radius:8px; background:#1a1a1a; color:#fff; border:1px solid #333;">
<option value="linkedin">LinkedIn</option>
<option value="instagram">Instagram</option>
<option value="screenshot">Screenshot</option>
<option value="notes">Notes</option>
</select>
<input type="text" id="saveSource" placeholder="Source name (optional)" style="flex:1; padding:10px; border-radius:8px; background:#1a1a1a; color:#fff; border:1px solid #333;">
<button id="saveBtn" style="padding:10px 20px; border-radius:8px; background:#646cff; color:#fff; border:none; cursor:pointer; font-weight:600;">Save</button>
</div>
<div id="saveStatus" style="margin-top:10px; font-size:0.85rem; color:#4ade80;"></div>
</div>
- Inside the existing <script> tag, add the save button handler before the closing </script>:
// Save form handler
document.getElementById('saveBtn').addEventListener('click', () => {
const content = document.getElementById('saveContent').value.trim();
const platform = document.getElementById('savePlatform').value;
const source = document.getElementById('saveSource').value.trim() || 'manual-save';
const status = document.getElementById('saveStatus');
if (!content) {
status.style.color = '#f87171';
status.textContent = 'Please paste some content first.';
return;
}
status.style.color = '#888';
status.textContent = 'Saving...';
fetch('/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, platform, source })
})
.then(res => res.json())
.then(data => {
status.style.color = '#4ade80';
status.textContent = `Saved! ${data.chunks_added} chunk(s) added. Total: ${data.total_chunks}`;
document.getElementById('saveContent').value = '';
document.getElementById('saveSource').value = '';
})
.catch(() => {
status.style.color = '#f87171';
status.textContent = 'Save failed. Is the server running?';
});
});
What does this form do?
The form collects the text, a platform dropdown (LinkedIn, Instagram, screenshot, or notes), and an optional source label. When you click Save, it POSTs to your /save endpoint. On success, a green status message appears showing how many chunks were added. The textarea clears so you can save another post immediately.
- Save static/index.html.
- Refresh your browser at http://127.0.0.1:8000.
You should now see a "Save to Knowledge Base" form below your search bar with a text area, platform dropdown, source input, and a purple Save button.
Verify the full loop works
The real test: save new content and immediately search for it.
- In the save form, paste this text: Kubernetes uses pods as the smallest deployable unit. Each pod can contain one or more containers that share storage and network.
- Select LinkedIn from the platform dropdown.
- Click Save.
- Wait for the green confirmation message.
- Now type container orchestration in the search bar above.
The content you just saved should appear in your search results with a "linkedin" platform badge, even though you never re-ran the ingestion pipeline. It went from paste to searchable in seconds.
Saved content not appearing in search results?
- Make sure the green status message appeared after saving. If it said "Save failed", the server may have crashed.
- Check the terminal running your FastAPI server for error messages.
- Try a search query that closely matches the text you pasted. Very short pastes (under 20 characters) may not embed well.
- help me debug why my saved content isn't appearing in search results
Your knowledge base now grows in real time. Whenever you see a LinkedIn post or Instagram caption worth remembering, paste it in and it's searchable within seconds. Next up, the Secret Mission pushes search quality even further with re-ranking.
Secret mission
Add CrossEncoder Re-Ranking
Your bi-encoder search is fast, but it makes approximate matches. A CrossEncoder scores each query-document pair directly and produces dramatically better rankings. In this mission, you'll add a retrieve-then-rerank pipeline that re-scores your top results for production-grade ordering.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The semantic-search/ project folder on your Desktop (contains all source code, the virtual environment, sample notes, and the ChromaDB data).
- Cached ML model files in ~/.cache/huggingface/ (~170MB total for the embedding model and the CrossEncoder re-ranker).
Keep everything running
No action needed. Choose this if you are still actively building or plan to add more notes to your search engine.
- Your project folder stays on your Desktop, ready to use anytime.
- The cached models in ~/.cache/huggingface/ mean future runs skip the download step entirely.
- To start the search engine again, activate your venv and run fastapi dev search_api.py from inside the project folder.
Pause - I'll come back to this later
Free up disk space from the model cache while keeping your project files intact.
- Stop the FastAPI dev server if it is still running by pressing Ctrl+C in the terminal.
- Remove the cached models to free ~170MB by running the command below for your platform.
- Your project code and ChromaDB data remain on your Desktop. When you return, the models re-download automatically on first run.
macOS
rm -rf ~/.cache/huggingface/hub/models--sentence-transformers--all-MiniLM-L6-v2 ~/.cache/huggingface/hub/models--cross-encoder--ms-marco-MiniLM-L6-v2
Windows
Remove-Item -Recurse -Force "$env:USERPROFILE\.cache\huggingface\hub\models--sentence-transformers--all-MiniLM-L6-v2", "$env:USERPROFILE\.cache\huggingface\hub\models--cross-encoder--ms-marco-MiniLM-L6-v2"
Delete - I don't want to use this again
Remove all project files, the ChromaDB database, and the cached models completely.
- Stop the FastAPI dev server if it is still running by pressing Ctrl+C in the terminal.
- Delete the entire project folder (includes your code, virtual environment, notes, and ChromaDB data).
macOS
rm -rf ~/Desktop/semantic-search
Windows
Remove-Item -Recurse -Force "$env:USERPROFILE\Desktop\semantic-search"
- Delete the cached ML models to free ~170MB of disk space.
macOS
rm -rf ~/.cache/huggingface/hub/models--sentence-transformers--all-MiniLM-L6-v2 ~/.cache/huggingface/hub/models--cross-encoder--ms-marco-MiniLM-L6-v2
Windows
Remove-Item -Recurse -Force "$env:USERPROFILE\.cache\huggingface\hub\models--sentence-transformers--all-MiniLM-L6-v2", "$env:USERPROFILE\.cache\huggingface\hub\models--cross-encoder--ms-marco-MiniLM-L6-v2"
That's a Wrap!
That's a Wrap!
Nice work. You just built a production-shaped semantic search engine from scratch, proving you understand how RAG retrieval pipelines work under the hood without leaning on LangChain or LlamaIndex.
You've learned how to:
- Build a complete RAG retrieval pipeline from scratch: loading files, chunking with overlap, generating vector embeddings, and storing them in a persistent ChromaDB vector database for semantic search.
- Design a smart chunking strategy with overlapping windows and metadata enrichment, after experiencing firsthand how naive fixed-size chunks break retrieval at paragraph boundaries.
- Create a FastAPI REST API with typed Pydantic models, auto-generated OpenAPI docs, and a polished browser-based search interface with real-time results and relevance scoring.
- Implement a CrossEncoder re-ranking layer in the Secret Mission, understanding the fundamental tradeoff between bi-encoders (fast, approximate) and cross-encoders (slow, precise) in production search systems.
Ready to quiz yourself?