Completed

Millie's Library - Book Search and User Pattern Analysis

Back to Projects
C++
Python
MySQL
Linux
100M+
Indexed points
1B+ records
Designed to scale to
+38%
Ranking quality
5
Searchable collections

Figures

The search stack as I built it. Full and dynamic indexing keep the index current without a nightly rebuild; the query layer handles completion, typo correction and expansion before retrieval; and the ranking layer combines field matches with engagement counted over rolling windows. Everything the log-analytics loop derives at the bottom is fed back into the query layer at the top.
The search stack as I built it. Full and dynamic indexing keep the index current without a nightly rebuild; the query layer handles completion, typo correction and expansion before retrieval; and the ranking layer combines field matches with engagement counted over rolling windows. Everything the log-analytics loop derives at the bottom is fed back into the query layer at the top.
The recommended-search-terms row, and the piece of this project I am proudest of. It was not in the original scope, the engine had no such feature, and there was no labelled data to build one from — the client asked for it anyway. What I built groups accounts by when in the day they search, then forecasts each group's term demand from its own search history, so the row is driven by search behaviour rather than editorially curated. The feature is still in the app.Open full size
The recommended-search-terms row, and the piece of this project I am proudest of. It was not in the original scope, the engine had no such feature, and there was no labelled data to build one from — the client asked for it anyway. What I built groups accounts by when in the day they search, then forecasts each group's term demand from its own search history, so the row is driven by search behaviour rather than editorially curated. The feature is still in the app.
Popular search terms with rise/fall/steady markers, timestamped to the aggregation window. Note rows 1 and 9 (오디세이 / 오디세이아) and 2 and 8 (히가시노 게이고 / 히가시노게이고): spelling and spacing variants are counted separately rather than merged. That is the raw form the log analysis needs — which variant users actually type is exactly the signal the typo-correction dictionary is extracted from, and merging them before counting would throw it away.Open full size
Popular search terms with rise/fall/steady markers, timestamped to the aggregation window. Note rows 1 and 9 (오디세이 / 오디세이아) and 2 and 8 (히가시노 게이고 / 히가시노게이고): spelling and spacing variants are counted separately rather than merged. That is the raw form the log analysis needs — which variant users actually type is exactly the signal the typo-correction dictionary is extracted from, and merging them before counting would throw it away.
Auto-completion on the partial term 이토록. The matches are whole titles containing the term, and the term is highlighted wherever it falls — mid-title in 누군가를 이토록 사랑한 적 and 심리학이 이토록 재미있을 줄이야, not just at the start. Korean prefix matching alone would have missed both. The candidate dictionary is rebuilt by a batch job that exports title and category fields ordered by recent opens, so ranking inside the suggestion list follows real demand.Open full size
Auto-completion on the partial term 이토록. The matches are whole titles containing the term, and the term is highlighted wherever it falls — mid-title in 누군가를 이토록 사랑한 적 and 심리학이 이토록 재미있을 줄이야, not just at the start. Korean prefix matching alone would have missed both. The candidate dictionary is rebuilt by a batch job that exports title and category fields ordered by recent opens, so ranking inside the suggestion list follows real demand.
Unified search across five collections — books, webtoons/webnovels, Millie Road, posts, shelves — each with its own count and its own tab. One query, five differently-shaped result sets, ranked independently and then presented together.Open full size
Unified search across five collections — books, webtoons/webnovels, Millie Road, posts, shelves — each with its own count and its own tab. One query, five differently-shaped result sets, ranked independently and then presented together.
An author query returns a curated collection card above the books. Ranking here is not pure text relevance — 97 titles match 히가시노 and the order they come back in is decided by the ranking layer, which weighs field matches against engagement signals such as recent opens, shelf adds, completion percentage and review count. Tuning those weights against a fixed list of about a hundred test queries is the work behind the 38% ranking-quality improvement.Open full size
An author query returns a curated collection card above the books. Ranking here is not pure text relevance — 97 titles match 히가시노 and the order they come back in is decided by the ranking layer, which weighs field matches against engagement signals such as recent opens, shelf adds, completion percentage and review count. Tuning those weights against a fixed list of about a hundred test queries is the work behind the 38% ranking-quality improvement.

Overview

Millie's Library is one of Korea's largest subscription e-book services. It was replacing the search behind the app, and the brief was not only "make search work" — two of the things the client wanted, ranking control and personalised recommended search terms, did not exist in the engine and had to be designed from scratch. I delivered the indexing pipeline, the ranking logic, and the search-log analytics the keyword features are derived from, then stayed through launch and stabilisation.

Indexing that stays current

A catalogue this size cannot be rebuilt from scratch every time a book's metadata changes, and it cannot be left stale either — a title that just went live has to be findable immediately. So indexing runs in two modes: a periodic full index that guarantees consistency, and a dynamic index that applies incremental changes as they arrive. The pipeline handles over 100M indexed points and was designed with headroom past 1B records. The other half of index quality is language. Korean is agglutinative, so substring matching gives you both false positives and misses; the win came from morphological analysis with a much wider dictionary than the previous setup had.

Ranking logic, built from nothing

There was no ranking layer to tune. Results came back in whatever order retrieval produced them, which for a catalogue of this size means the right book is somewhere on page four. What I built combines two families of signal. First, field matches — a query hitting the title should not count the same as one hitting the author or the narrator. Second, engagement, counted over rolling windows rather than all-time: opens in the last 7 and 30 days, shelf adds over the same windows, review count, completion percentage, dwell time. Recent windows are weighted more heavily than lifetime totals, which is what stops a book that was popular three years ago from permanently occupying the top slot. Every one of those signals carries its own weight, and weights interact — raising one to fix a bad result usually breaks a good one elsewhere. The client's product manager and I built a fixed list of around a hundred test queries, including spacing and spelling variants, and re-ran the whole list on every weight change so a fix could be checked against the same baseline instead of the one query that prompted it. That discipline is where the 38% ranking-quality improvement came from; it was not a single clever idea.

Deriving keyword features from the logs

Popular terms, rising terms, related terms and recommended terms are all downstream of one thing: analysis of the search logs over time. Volume per term across rolling windows is what produces both the popularity ranking and the rise/fall markers next to it. None of it is hand-curated, so it tracks what users are actually searching for rather than what someone assumed they would. The same pipeline produces two things that are less visible and more useful. The typo-correction dictionary is extracted automatically instead of being maintained by hand. And terms that are searched repeatedly but return few or no results are flagged — sometimes that means a missing dictionary entry, and sometimes it means readers want a book the catalogue does not carry, which goes to the acquisition team. Search logs turned out to be a demand signal, not just a quality signal.

The feature that had no data

Personalised recommended search terms were not in the original scope. The engine had no such capability, there was no labelled data to train anything on, and the client asked for it regardless. Nobody on the project had an approach. I was taking a forecasting course at the time, and the idea came from there: treat search history as a time series rather than as a bag of terms, and use when someone searches as the grouping variable. Accounts that search at similar times of day turn out to behave similarly, which gives you groups without needing demographic data or an explicit profile, and each group's term demand can then be projected forward from its own history. It shipped with the search relaunch, and the recommended-terms row is still in the app today.

What I took from it

Quality, and what it costs. The client's planner tested at QA depth: a fixed set of criteria re-run every time a variable changed, and a list of around a hundred search terms tried with the spacing varied each way. Thinking through side effects before changing anything became a habit on this project rather than an intention. Changing a ranking weight and watching the results reorder was also the part of the work I enjoyed most at the time.

Auto-completion

Suggestions are whole titles containing the typed term, with the match highlighted wherever it falls rather than only at the start — Korean prefix matching alone would miss a term that appears in the middle of a title, which in practice is most of them. The candidate dictionary is rebuilt by a batch job that exports the title and category fields, orders them by recent opens, and then triggers a reload of the completion module. Ordering by demand means the suggestion list is itself ranked, so the most likely completion is the first one.

What I took from it

This was the project where I learned what quality actually costs. The client tested at QA depth — the same fixed query list, the same criteria, every time a variable changed — and thinking through side effects before touching anything became a habit rather than an intention. It is also the first project where the hardest questions I raised had no answer inside my own company and had to go to the engine's R&D team, which is how I found out that being stuck is often a bug report rather than a knowledge gap. And it was fun. Watching results reorder as I moved a weight is the closest thing to a tight feedback loop I had found in production work at that point, and it is a large part of why retrieval and evaluation are still what I want to work on.