LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
AI Tools & Libraries GuideIntermediate~1.5 hours

Semantic Search over Your Notes

Index a folder of Markdown notes into a Chroma vector database and search them by meaning, not exact keywords.

EmbeddingsChromaChunkingSemantic Search

Overview

Ordinary search matches words: searching "undo my last commit" will not find a note that says "git reset --soft HEAD~1". Semantic search matches meaning. Each piece of text is turned into an embedding — a list of numbers that captures what it is about — and the search returns the pieces whose embeddings are closest to the question's embedding.

In this project you build a small command-line tool that indexes a folder of Markdown and text notes into a persistent Chroma database and searches it by meaning. Chroma's built-in embedding model runs locally, so this project needs no API key and costs nothing to run. It also becomes the retrieval half of the next project, a question-answering bot.

What You'll Build
  • A paragraph-based chunker that keeps related sentences together.
  • A persistent Chroma collection stored on disk in ./notes-db.
  • An index command that upserts every chunk with source and chunk metadata.
  • A search command that returns the closest chunks with their distance scores.
  • An optional --source filter that restricts results to one file using a metadata where clause.

Prerequisites

  • Python 3.10 or newer, ideally in a virtual environment.
  • Vector databases and Chroma basics — lesson 13.
  • Embeddings and semantic search — lesson 14.
  • Comfort with argparse or reading command-line arguments.

Project Structure

notes-search/
├── notes_search.py # the CLI: index + search
├── notes/ # your Markdown / text notes
│ ├── git-cheatsheet.md
│ ├── python-tips.md
│ └── team-meeting.md
└── notes-db/ # created by Chroma on first run

notes_search.py has three layers: chunk_text() is pure text processing with no database involved, get_collection() is the only function that knows where the database lives, and index_folder()/search() combine the two. The next project imports get_collection() from this file, so keeping it separate pays off immediately.

Step 1: Install Chroma & Create Sample Notes

mkdir notes-search && cd notes-search
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install chromadb
mkdir notes

Use your own notes if you have some, or create these three sample files:

notes/git-cheatsheet.md
# Git cheatsheet
To undo the last commit but keep the changes staged, run git reset --soft HEAD~1.
This is handy when you committed too early or forgot a file.
To throw away all local changes to a file and restore the committed version,
run git restore <file>. Be careful: the discarded changes cannot be recovered.
Create a new branch and switch to it in one step with git switch -c feature-name.
notes/python-tips.md
# Python tips
Use a virtual environment for every project: python -m venv .venv keeps each
project's packages separate so upgrades never break other projects.
List comprehensions build a new list in one line, for example
squares = [n * n for n in range(10)].
Use pathlib.Path instead of string paths. Path("notes") / "todo.md" works on
Windows, macOS and Linux without worrying about slashes.
notes/team-meeting.md
# Team meeting - 1 October 2026
The mobile app release is moved to 20 October because the payment screen
still fails on older Android phones. Priya owns the fix.
We agreed to hold code reviews every Tuesday and Thursday afternoon.
Budget for the cloud servers is capped at 40,000 rupees per month until
the end of the year.

Step 2: Split Notes into Chunks

Embedding a whole document produces one blurry "average" meaning; embedding single sentences loses context. Splitting on blank lines (paragraphs) and packing paragraphs together up to a size limit is a simple, effective middle ground for notes. The limit matters: with these sample notes, 800-character chunks mixed several topics together and made matches noticeably weaker, while 300 characters keeps roughly one idea per chunk.

def chunk_text(text: str, max_chars: int = 300) -> list[str]:
"""Split on blank lines, then pack paragraphs into chunks of up to max_chars."""
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
chunks: list[str] = []
current = ""
for paragraph in paragraphs:
if current and len(current) + len(paragraph) + 2 > max_chars:
chunks.append(current)
current = paragraph
else:
current = f"{current}\n\n{paragraph}" if current else paragraph
if current:
chunks.append(current)
return chunks

Step 3: Index Chunks with Metadata

PersistentClient saves everything to disk, so you index once and search many times. Chroma computes embeddings automatically with its default local model (downloaded once on first use). upsert inserts new chunks and replaces chunks whose id already exists, so re-running index after editing a note updates it instead of creating duplicates. Each chunk gets a stable id such as notes/git-cheatsheet.md#0.

import chromadb
DB_PATH = "./notes-db"
COLLECTION = "notes"
def get_collection():
client = chromadb.PersistentClient(path=DB_PATH)
return client.get_or_create_collection(COLLECTION)
def index_folder(folder: str) -> None:
files = sorted(p for p in Path(folder).rglob("*") if p.suffix.lower() in {".md", ".txt"})
ids, documents, metadatas = [], [], []
for path in files:
for i, chunk in enumerate(chunk_text(path.read_text(encoding="utf-8"))):
ids.append(f"{path.as_posix()}#{i}")
documents.append(chunk)
metadatas.append({"source": path.name, "chunk": i})
collection = get_collection()
collection.upsert(ids=ids, documents=documents, metadatas=metadatas)

Step 4: Search by Meaning

query(query_texts=[...]) embeds the question with the same model and returns the nearest chunks. Results are nested lists (one inner list per query), so [0] selects the results for our single question. Distances are relative: lower means closer in meaning. The optional where={"source": ...} filter narrows the search using the metadata stored at index time.

results = collection.query(
query_texts=["how do I undo my last commit"],
n_results=3,
where={"source": "git-cheatsheet.md"}, # optional metadata filter
)
for doc, meta, dist in zip(results["documents"][0], results["metadatas"][0], results["distances"][0]):
print(f"{dist:.3f} {meta['source']} {doc[:60]}...")

Complete Code

notes_search.py
"""notes_search.py - semantic search over a folder of Markdown/text notes.
Usage:
python notes_search.py index ./notes
python notes_search.py search "how do I undo my last commit" [--top 3] [--source git-cheatsheet.md]
"""
import argparse
from pathlib import Path
import chromadb
DB_PATH = "./notes-db"
COLLECTION = "notes"
def chunk_text(text: str, max_chars: int = 300) -> list[str]:
"""Split on blank lines, then pack paragraphs into chunks of up to max_chars."""
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
chunks: list[str] = []
current = ""
for paragraph in paragraphs:
if current and len(current) + len(paragraph) + 2 > max_chars:
chunks.append(current)
current = paragraph
else:
current = f"{current}\n\n{paragraph}" if current else paragraph
if current:
chunks.append(current)
return chunks
def get_collection():
client = chromadb.PersistentClient(path=DB_PATH)
return client.get_or_create_collection(COLLECTION)
def index_folder(folder: str) -> None:
files = sorted(p for p in Path(folder).rglob("*") if p.suffix.lower() in {".md", ".txt"})
ids, documents, metadatas = [], [], []
for path in files:
for i, chunk in enumerate(chunk_text(path.read_text(encoding="utf-8"))):
ids.append(f"{path.as_posix()}#{i}")
documents.append(chunk)
metadatas.append({"source": path.name, "chunk": i})
if not ids:
print(f"No .md or .txt files found in {folder}")
return
collection = get_collection()
collection.upsert(ids=ids, documents=documents, metadatas=metadatas)
print(f"Indexed {len(ids)} chunks from {len(files)} files.")
print(f"The collection now holds {collection.count()} chunks.")
def search(query: str, top: int = 3, source: str | None = None) -> None:
collection = get_collection()
if collection.count() == 0:
print("The index is empty - run: python notes_search.py index ./notes")
return
results = collection.query(
query_texts=[query],
n_results=min(top, collection.count()),
where={"source": source} if source else None,
)
documents = results["documents"][0]
if not documents:
print("No matching notes.")
return
print(f'Results for: "{query}"\n')
for rank, (doc, meta, distance) in enumerate(
zip(documents, results["metadatas"][0], results["distances"][0]), start=1
):
preview = " ".join(doc.split())[:160]
print(f"{rank}. {meta['source']} (chunk {meta['chunk']}) distance {distance:.3f}")
print(f" {preview}...\n")
def main() -> None:
parser = argparse.ArgumentParser(description="Semantic search over your notes.")
commands = parser.add_subparsers(dest="command", required=True)
index_cmd = commands.add_parser("index", help="index a folder of .md/.txt files")
index_cmd.add_argument("folder")
search_cmd = commands.add_parser("search", help="search the index by meaning")
search_cmd.add_argument("query")
search_cmd.add_argument("--top", type=int, default=3)
search_cmd.add_argument("--source", help="only search one file, e.g. python-tips.md")
args = parser.parse_args()
if args.command == "index":
index_folder(args.folder)
else:
search(args.query, args.top, args.source)
if __name__ == "__main__":
main()

Sample Run

Indexing and searching the sample notes

Click Run to see what this code prints.

The second search is the interesting one: the meeting note never mentions "shipping" or a "phone app", yet the paragraph about the mobile release ranks first because the meanings match — that is semantic search at work. Notice too how far the unrelated meeting note sits from the git question (1.876 versus 0.384). Exact distance values depend on the embedding model and Chroma version, so focus on the ranking and the gap rather than the precise numbers.

Extend This Project

  • Delete stale chunks when a note gets shorter: before upserting a file, call collection.delete(where={"source": name}).
  • Add a --max-distance option that hides weak matches instead of always returning --top results.
  • Store each file's modification time in metadata and only re-index files that changed.
  • Split large Markdown files on headings (#, ##) and store the heading in metadata for better previews.
  • Swap in a different embedding model (for example an OpenAI or Voyage embedding function) and compare result quality.

Summary

You built the retrieval half of every RAG system: chunk documents sensibly, store their embeddings with useful metadata in a persistent vector database, and query by meaning with optional metadata filters. In the next project you will hand these search results to Claude so it can answer questions about your notes in full sentences, with sources.