Inspiration

Online shopping search has a dirty secret, shoppers usually don't know the exact words for what they want. You know it when you see it — "something like sneakers but for hiking, not too expensive, in green" — but a search box wants keywords, not that. For TechJam 2026's Shopping Copilot track, I set out to build a conversational agent that could stand in for a helpful salesperson: ask a few smart questions, listen to the answers, and narrow a huge catalog down to the one product you actually meant, all within about ten turns of dialogue.

I also gave myself a harder constraint on purpose. Instead of leaning on an LLM to carry the conversation, I asked: how far can disciplined, classical engineering get us with zero third-party dependencies, no API keys, and no network calls? That constraint became the spine of the whole project.

What it does

Maihenduo is a conversational shopping agent that turns a back-and-forth chat into a ranked top-10 product list, searching across a 50,000-item catalog (Amazon Reviews 2023, Clothing/Shoes/Jewelry category). At each turn it:

  1. Reads the shopper's message and updates a running picture of what they want — category, gender, size, color, material, brand, budget, style, use case — while handling messy real conversation: people contradicting themselves, giving alternatives ("nike or adidas"), or completely changing their mind about the category.
  2. Retrieves candidates from a SQLite FTS5 full-text index built across product titles, categories, features, and descriptions, with category filters that gracefully relax if they're too strict.
  3. Reranks those candidates by fusing normalized text-relevance scores with real shopping signals: gender/brand/color/material match, price fit, and — the single strongest signal — how many reviews a product has, because products people actually bought are the ones other people are also looking for.
  4. Decides whether to ask another question, but only if the question would meaningfully split the remaining candidates — no filler, no fixed script.

The result: Hit Rate@10 of 0.855 and Mean Reciprocal Rank of 0.628, a 6.8x and 9.2x improvement over the challenge's weak baseline, reaching the right product in 4.43 turns on average (down from 9.81). All of this runs in ~34ms per turn, ~126MB of memory, and costs $0.00 per conversation — no LLM in the loop at all.

I also built a single-file interactive demo that exposes the agent's actual reasoning in real time: slot accumulation, the retrieval funnel, and per-product score breakdowns, running against the unmodified production agent rather than a simplified mock.

How I built it

The whole system is Python standard library only — SQLite3 for indexing and full-text search, http.server for the demo, unittest/pytest for testing. That constraint forced me to be precise about where the real value was coming from.

The architecture is a four-stage pipeline (dialog state → retrieval → reranking → clarification) that runs fresh every turn. A few decisions mattered more than the rest:

  • Popularity as a signal, not a filter. Log-compressed review counts turned out to be the single biggest score contributor (+0.113), because a product real shoppers bought and reviewed is disproportionately likely to be what a new shopper means — but only applied after conversation-driven filtering, never overriding it.
  • Multi-value attributes. Shoppers say "red or blue," not "red." It accumulates alternatives across turns instead of forcing a binary choice.
  • Intent override detection. it distinguishes erasure (shopper picks a new category, old attributes no longer apply) from rewriting (shopper swaps one detail, everything else still holds) — the category boundary is what decides which one is happening.
  • Dual-track routing. Sessions get classified as "buying" (tight, focused, ask fewer questions) versus "browsing" (looser, more diverse results, more room to explore) and get different precision/diversity tradeoffs accordingly.
  • Information-gain-driven clarification. Rather than asking questions in a fixed order, it measures which question would split the current candidate pool the most, and only ask if it's worth the turn cost.

Every tunable constant was validated with a split-half methodology across our 200-session development set — a variant only shipped if it improved both halves, which caught at least one change that looked like a win on training data but scored below baseline on held-out data.

Challenges I ran into

The biggest challenge wasn't technical execution — it was a wrong assumption I didn't even know I was making. Early on I assumed the core bottleneck was semantic: that shoppers' conversational language wouldn't lexically match product descriptions, and I'd eventually need embeddings or an LLM reranker to bridge that gap.

Instrumentation proved me wrong. Of every missed session, 100% of targets already matched the lexical query — the problem was never vocabulary, it was ordering. Most missed targets were sitting in the candidate pool, just ranked too low (below position 40 in many cases). That single measurement redirected the entire second half of the project from "expand recall" to "fix ranking," and confirmed my instinct to skip dense retrieval and LLM reranking altogether.

I also learned, sometimes the hard way, that intuitive improvements don't always help:

  • Porter stemming looked like an obvious win for matching "sneaker"/"sneakers," but in our OR-query setup it introduced enough noise to drop accuracy from 0.579 to 0.529.
  • Making gender a hard filter cost us score, because plenty of products simply don't have a gender field even when they have department info — a hard filter threw good products away.
  • Reciprocal Rank Fusion discarded score magnitude and made relevance signals indistinguishable, dropping performance from 0.607 to 0.544.
  • Stripping conversational filler from queries — which felt like an obvious cleanup — actually hurt coverage twice, because the "noise" was acting as an unintentional recall net.

Getting the intent-override logic right was its own puzzle: my first version wiped the whole dialog state whenever a shopper mentioned something new, which was too aggressive. Distinguishing genuine category changes from small attribute corrections recovered about 3.3% accuracy on override-heavy sessions.

Accomplishments that I'm proud of

Turning a 0.107 weak baseline into a 0.747 TechnicalScore — a 7x improvement — using nothing but Python's standard library is the headline result. No LLM calls, no API keys, no network dependency, and a per-conversation cost of exactly $0.00. I'm equally proud of the process behind that number: every tunable constant went through split-half validation before shipping, so the final system's gains are ones I can actually trust to generalize, not artifacts of tuning on our own test set. And the whole thing is fully deterministic — repeated runs produce byte-identical output, which made debugging and evaluation far more trustworthy.

What I learned

The most useful lesson was procedural: measure before you optimize, and let the failure analysis pick the next problem instead of your assumptions. Nearly every "obvious" idea I tried — stemming, hard filters, RRF, filler removal — sounded reasonable and measured worse. The ideas that actually moved the score (popularity priors, information-gain question selection, split-half validated constants) came from looking directly at what the system was getting wrong, not from applying a known best practice.

I also came away convinced that a lot of "AI product search" complexity is optional. Explainable, auditable ranking signals — with named contributions you can point to for any given result — got me further than a black box would have.

What's next for maihenduo

The failure analysis pointed us to a clear next step: since the bottleneck is ranking rather than recall, a learned reranker trained on the same lexically-matched candidate pools is the natural next iteration — no embeddings required. I'd also like to expand beyond the Clothing/Shoes/Jewelry catalog to test whether the same architecture generalizes to categories with very different attribute structures (electronics, groceries, home goods), and explore lightweight personalization from a shopper's earlier turns across sessions rather than resetting cold every conversation.

Built With

Share this project:

Updates

Submission history