10 · Project — Semantic Search CLI¶
Time to combine everything from this level into one working tool: a command-line semantic search engine over your own text files.
What you're building¶
A CLI with two commands:
# 1. Index every .txt file in a folder
python search_cli.py index ./my_notes
# 2. Search across everything you've indexed
python search_cli.py search "how do I reset my vpn connection"
Example expected output for search:
$ python search_cli.py search "how do I reset my vpn connection"
1. [0.847] notes/it-setup.txt
"...if the VPN client won't connect, try restarting the adapter
and re-entering your credentials before escalating to IT..."
2. [0.621] notes/onboarding.txt
"...network access requires connecting through the company VPN,
which you'll set up during your first week..."
Requirements¶
index <folder>walks a folder, reads every.txtfile, splits each file into chunks (don't embed a whole file as one vector — see "Why chunking matters" below), embeds each chunk withsentence-transformers, and stores them in a persistent Chroma collection with metadata recording which file (and roughly which position in it) each chunk came from.search <query>embeds the query, runs it against the persisted collection, and prints the top-5 results: similarity score, source filename, and a snippet of the matching chunk.- Running
indextwice on the same folder should not duplicate entries (reuse Lesson 8'supsertor clear-and-rebuild, your choice — just avoid silent duplicates). - Use Python's
argparseorsys.argvto make this a real CLI, not just two separate scripts.
Why chunking matters¶
If you embed an entire 2,000-word file as a single vector, that vector ends up representing the average meaning of the whole file — a single paragraph about VPN setup buried inside a much longer onboarding document gets diluted into near-meaninglessness. Splitting a file into smaller chunks (e.g. every ~3-5 sentences, or every paragraph) before embedding means each chunk's vector represents one coherent idea, and your search can point directly at the relevant paragraph instead of just "somewhere in this file." You'll go much deeper on chunking strategy in Level 3, Lesson 3 — for this project, a simple paragraph-or-fixed-size split is enough.
Suggested structure¶
# search_cli.py
import argparse
import pathlib
import chromadb
from sentence_transformers import SentenceTransformer
MODEL_NAME = "all-MiniLM-L6-v2"
DB_PATH = "./search_cli_data"
COLLECTION_NAME = "notes"
CHUNK_SIZE = 500 # characters; tune as you like
model = SentenceTransformer(MODEL_NAME)
client = chromadb.PersistentClient(path=DB_PATH)
def chunk_text(text, chunk_size=CHUNK_SIZE):
"""Split text into roughly chunk_size-character pieces, breaking on
paragraph boundaries where possible."""
# Your implementation — paragraphs first, then fall back to a hard
# character split for any paragraph longer than chunk_size.
...
def cmd_index(folder):
collection = client.get_or_create_collection(name=COLLECTION_NAME)
txt_files = pathlib.Path(folder).glob("**/*.txt")
for file_path in txt_files:
text = file_path.read_text(encoding="utf-8")
chunks = chunk_text(text)
embeddings = model.encode(chunks).tolist()
ids = [f"{file_path}::{i}" for i in range(len(chunks))]
metadatas = [{"source": str(file_path), "chunk_index": i} for i in range(len(chunks))]
collection.upsert(
ids=ids,
documents=chunks,
embeddings=embeddings,
metadatas=metadatas,
)
print(f"Indexed {sum(1 for _ in pathlib.Path(folder).glob('**/*.txt'))} files.")
def cmd_search(query, top_k=5):
collection = client.get_or_create_collection(name=COLLECTION_NAME)
query_embedding = model.encode(query).tolist()
results = collection.query(query_embeddings=[query_embedding], n_results=top_k)
for rank, (doc, meta, distance) in enumerate(
zip(results["documents"][0], results["metadatas"][0], results["distances"][0]), start=1
):
similarity = 1 - distance # rough conversion if using l2; adjust for your metric
snippet = doc[:200].replace("\n", " ")
print(f"\n{rank}. [{similarity:.3f}] {meta['source']}")
print(f' "...{snippet}..."')
def main():
parser = argparse.ArgumentParser(description="Semantic search over local .txt files")
subparsers = parser.add_subparsers(dest="command", required=True)
index_parser = subparsers.add_parser("index")
index_parser.add_argument("folder")
search_parser = subparsers.add_parser("search")
search_parser.add_argument("query")
args = parser.parse_args()
if args.command == "index":
cmd_index(args.folder)
elif args.command == "search":
cmd_search(args.query)
if __name__ == "__main__":
main()
The chunk_text() function and the exact similarity-score conversion in
cmd_search are left for you to finish — that's the part of this project
that's genuinely yours to design.
Stretch goals (optional)¶
- Support
.mdfiles in addition to.txt, stripping Markdown syntax before chunking. - Add a
--top-k Nflag tosearchinstead of hardcoding 5. - Track and skip re-indexing files that haven't changed since the last run (compare file modification time, stored as metadata).
Exercise¶
Build the CLI above against a folder of at least 8-10 of your own short
.txt notes spanning a few different topics. Run at least 5 different
search queries and, for each, judge by eye whether the top result is
genuinely the most relevant note you have. Write down one query where the
tool surprised you (good or bad) and your best guess at why, based on what
you now know about embeddings and chunking.