ML · Semantic Search · NextRead

Finding the right book by meaning, not by keyword

Solo build Open source Python LangChain Chroma HuggingFace Gradio Blocks

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.

7K+
books in the corpus
384
embedding dimensions
5
emotional tone filters
0
external API costs

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.

1
Semantic similarity searchThe user's query is embedded, and Chroma performs a similarity search (`k=50`) to cast a wide net for books that match the thematic intent.
2
ISBN extraction & DataFrame mappingWe extract the ISBN13 from the retrieved text chunks and filter our core Pandas DataFrame (books_with_emotions.csv) down to these top semantic matches.
3
Category filteringIf a specific category is selected, Pandas slices the retrieved candidates where simple_categories == category.
4
Emotional tone sortingIf an emotional tone is requested (e.g., "Happy", "Suspenseful", "Sad"), we sort the filtered DataFrame by the pre-calculated sentiment scores (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.

Upgrade 01 — Shipped

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.

Takeaway: Never recompute what you can persist. Moving embedding generation from runtime to an offline build step is the single highest-leverage optimization in any vector search application — the vector store becomes infrastructure, not startup cost.
Upgrade 02 — Shipped

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.

Takeaway: Pre-retrieval filtering beats post-retrieval slicing every time. Think of document metadata as pre-computed index partitions — the more structured context you inject at ingestion, the more precise and efficient your query-time retrieval becomes.
Upgrade 03 — Planned

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.

Takeaway: The retrieval-then-rerank pattern is the gold standard for semantic search in production — use fast vector similarity for high recall, then a precise cross-encoder to cut noise. Separating the two concerns lets you tune each independently without sacrificing either speed or accuracy.