ML · Semantic Search · NextRead
Finding the right book by meaning, not by keyword
The problem with every book discovery tool is the same: they match words, not meaning. You search for "a dark novel about obsession" and you get results that contain those exact words in reviews, or you get nothing. The book you actually want — the one that feels like obsession — never surfaces.
NextRead treats book discovery as a semantic retrieval problem. You describe what you want to read in everyday natural language, and the system finds books whose descriptions are nearest in vector space to your query. It layers genre classification and emotional tone on top of that, allowing you to filter not just by category, but by how the book will make you feel. Here is how I designed and built it.
Data preparation & embedding corpus structure
The foundation of any good retrieval system is clean, well-structured data. Rather than feeding raw, noisy CSV strings directly into vector space, I pre-processed the descriptions and decoupled the text embeddings from the rich tabular metadata.
In my pipeline, descriptions are cleaned and stored in a lightweight text file (tagged_description.txt). Each line begins with the book's 13-digit ISBN followed directly by the textual description. When the app boots up, it reads this file and converts each line into a LangChain Document schema.
# Load pre-tagged descriptions for embedding
with open("tagged_description.txt", "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
# Convert each line into a LangChain Document
raw_documents = [
Document(page_content=line.strip())
for line in content.split("\n")
if line.strip()
]
By keeping the embedding corpus lightweight and stripping short, noisy descriptions early in exploration, we ensure that every document in vector space has enough semantic signal to form meaningful clusters.
Local embeddings & Chroma vector storage
For the embedding model, I chose all-MiniLM-L6-v2 from Sentence Transformers via HuggingFace. It produces 384-dimensional vectors that strike an exceptional balance between speed and semantic accuracy in English prose. Crucially, running embeddings locally removes any reliance on external API keys, rate limits, or per-token network costs.
In the current architecture, the LangChain documents are ingested directly into a Chroma vector store on startup:
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_chroma import Chroma
# Create the Chroma vector store with Hugging Face embeddings
embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
db_books = Chroma.from_documents(
raw_documents,
embedding=embeddings
)
Two-Stage recommendation: Vector retrieval + Pandas filtering
To handle category classification (Fiction vs. Non-Fiction) and emotion filtering (Joy, Surprise, Anger, Fear, Sadness) without bogging down vector similarity math, NextRead utilizes a two-stage retrieval pipeline.
books_with_emotions.csv) down to these top semantic matches.simple_categories == category.joy, fear, sadness, etc.) before returning the top 16 books.
def retrieve_semantic_recommendations(
query: str,
category: str = None,
tone: str = None,
initial_top_k: int = 50,
final_top_k: int = 16,
) -> pd.DataFrame:
recs = db_books.similarity_search_with_score(query, k=initial_top_k)
# Extract ISBN13s from the top semantically-similar chunks
books_list = [
int(doc.page_content.strip('"').split()[0])
for doc, _score in recs
]
book_recs = books[books["isbn13"].isin(books_list)].head(final_top_k)
if category and category != "All":
book_recs = book_recs[book_recs["simple_categories"] == category].head(final_top_k)
if tone == "Happy":
book_recs = book_recs.sort_values("joy", ascending=False)
elif tone == "Surprising":
book_recs = book_recs.sort_values("surprise", ascending=False)
elif tone == "Angry":
book_recs = book_recs.sort_values("anger", ascending=False)
elif tone == "Suspenseful":
book_recs = book_recs.sort_values("fear", ascending=False)
elif tone == "Sad":
book_recs = book_recs.sort_values("sadness", ascending=False)
return book_recs
A modern, glassmorphism UI with Gradio Blocks
Rather than settling for a standard, out-of-the-box interface, I built the frontend using **Gradio Blocks** with a custom Glassmorphism theme. To make the discovery experience engaging from the second the app opens, I injected custom CSS for hover-lift card animations, modern raised buttons, and a responsive layout.
The dashboard also features an automatic **Featured Books** carousel that dynamically samples 12 randomized recommendations whenever you launch or refresh the page, ensuring users always have immediate inspiration:
with gr.Blocks(theme=gr.themes.Glass(), css="""...custom CSS...""") as dashboard:
gr.Markdown("""<div id="app-header"><span>📖 NextRead</span></div>""")
# Random Featured Books Carousel
featured_gallery = gr.Gallery(
value=get_featured_books,
show_label=False, columns=6, rows=2, elem_id="featured-gallery"
)
with gr.Row():
user_query = gr.Textbox(label="✍️ Please enter a description of a book", lines=2)
category_dropdown = gr.Dropdown(choices=categories, label="🔖 Select a category:", value="All")
tone_dropdown = gr.Dropdown(choices=tones, label="🎭 Select an emotional tone:", value="All")
Performance decisions: what I shipped and what's next
Two of the three optimizations below are already in production. I'm documenting them here not as a wishlist but as an engineering record — each one targets a specific bottleneck with a concrete fix and a measurable outcome.
Generating the Chroma DB persistently to eliminate startup overhead
The original architecture read tagged_description.txt and generated the Chroma vector store fresh on every application startup. For a corpus of 7,000+ books, this meant the embedding model had to process every description at launch, introducing several minutes of cold-start latency before the first user interaction.
I decoupled embedding generation from application startup entirely by writing a standalone offline ingestion script — build_vector_db.py. This script reads the dataset once, computes all embeddings, and writes them to disk using Chroma(persist_directory="./chroma_db"). app.py now simply calls Chroma(persist_directory=persist_directory, embedding_function=embeddings) on boot, loading the pre-computed vectors from disk in under two seconds. Cold-start latency was eliminated entirely.
Injecting metadata directly into LangChain Documents for pre-retrieval filtering
The original two-stage pipeline retrieved the top 50 semantic matches by cosine similarity, then used Pandas to slice by category and sort by emotion scores after the fact. For niche categories with sparse coverage in the corpus, post-retrieval filtering would sometimes exhaust the candidate pool, returning fewer results than expected.
I resolved this by injecting the zero-shot genre classification and all five sentiment scores (joy, fear, sadness, anger, surprise) directly into each LangChain Document's metadata dict at ingestion time. With this metadata stored natively in Chroma, the app can now pass a filter={"category": selected_category} argument to similarity_search_with_score. Chroma evaluates the filter before computing vector distances, meaning 100% of returned candidates are guaranteed to match the selected category — no wasted retrieval budget.
Integrating a Cross-Encoder Re-ranking layer for production-grade precision
Cosine similarity in embedding space is an excellent first-pass retriever, but bi-encoders compress entire book descriptions into a single pooled vector, which can occasionally miss subtle nuances in writing style, narrative pacing, or thematic tone that only emerge when the query and document are read together.
The planned upgrade is introducing a lightweight cross-encoder model — such as cross-encoder/ms-marco-MiniLM-L-6-v2 — as a second-stage re-ranker. The architecture would work in two passes: Chroma fetches the top 30 candidates at high speed using vector similarity for broad recall, then the cross-encoder scores each (user_query, book_description) pair jointly, producing a much sharper relevance ranking before the final 16 results are returned. The latency overhead is minimal since cross-encoders only process a small candidate set, not the full corpus.