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.
- A paragraph-based chunker that keeps related sentences together.
- A persistent Chroma collection stored on disk in
./notes-db. - An
indexcommand that upserts every chunk withsourceandchunkmetadata. - A
searchcommand that returns the closest chunks with their distance scores. - An optional
--sourcefilter that restricts results to one file using a metadatawhereclause.
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
argparseor 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 runnotes_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-searchpython -m venv .venvsource .venv/bin/activate # Windows: .venv\Scripts\activatepip install chromadbmkdir notesUse your own notes if you have some, or create these three sample files:
# 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.# Python tips
Use a virtual environment for every project: python -m venv .venv keeps eachproject's packages separate so upgrades never break other projects.
List comprehensions build a new list in one line, for examplesquares = [n * n for n in range(10)].
Use pathlib.Path instead of string paths. Path("notes") / "todo.md" works onWindows, macOS and Linux without worrying about slashes.# Team meeting - 1 October 2026
The mobile app release is moved to 20 October because the payment screenstill 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 untilthe 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 chunksStep 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 - 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 argparsefrom 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
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-distanceoption that hides weak matches instead of always returning--topresults. - 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.