Cutting over a search index without a maintenance window
The second version of a search index is never just the first one with more fields. It's a chance to decide what should have been in it, and what should never have been indexed at all.
What was wrong with v1
The original index took every account. That sounds neutral and it isn't — an index full of near-empty records makes every query worse. A searcher wading through profiles with a name and nothing else concludes the search is broken, and they're not wrong.
The second problem was derived data. Fields like "years of experience" were computed ad hoc at different call sites, which meant two parts of the product could disagree about the same person.
A quality gate at the index boundary
The rule we settled on: a record is only indexed if it clears a completeness bar — real work history, active skills, and a short video introduction. Anything short of that isn't indexed, and anything that falls out of completeness is suppressed rather than left stale.
function isIndexable(profile) {
return profile.workExperience.length > 0
&& profile.skills.some(s => s.active)
&& Boolean(profile.pitch?.videoUrl);
}
// Suppression, not deletion: the row stays, it just stops being findable.
await index.update(
{ account: id },
{ $set: { isSearchable: isIndexable(profile) } },
);
Suppress rather than delete. A deleted index row loses the history that tells you why it was removed, and re-creating it costs another embedding call.
The uncomfortable part is that a quality gate excludes real users. Someone who hasn't finished their profile becomes invisible to searchers, and they won't necessarily be told why. That's a product decision as much as a technical one, and it needs to come with a visible path back — showing people what's missing, not silently dropping them.
The derived field that was quietly wrong
"Years of experience" looks like arithmetic and isn't. Sum the durations of every role and anyone who has held two jobs at once — a contractor, someone with a side business, anyone whose new job overlapped their notice period — gets credited twice.
The correct computation is the union of intervals, not their sum:
// Merge overlapping ranges, then total what's left.
function yearsOfExperience(roles) {
const spans = roles
.filter(r => !r.isEducation)
.map(r => [r.startedAt, r.endedAt ?? new Date()])
.sort((a, b) => a[0] - b[0]);
const merged = [];
for (const [start, end] of spans) {
const last = merged[merged.length - 1];
if (last && start <= last[1]) {
last[1] = new Date(Math.max(last[1], end)); // overlap: extend
} else {
merged.push([start, end]); // gap: new span
}
}
return totalYears(merged);
}
Excluding education matters too — a degree earned while working isn't additional professional experience, and counting it inflates exactly the people who studied part-time.
Cutting over
The switch worked because the new collection was built alongside the old one rather than replacing it in place. A separate collection, a separate vector index, populated and verified while the live product still read from v1. When it was right, reads moved across; the old collection was deprecated rather than dropped.
This costs storage for a while and buys two things worth far more: the old data is still there if the new index is wrong, and the switch is a config change rather than a migration with a rollback plan nobody has rehearsed.
What I'd watch next time
The gap between "record changed" and "index reflects it" is the number that decides whether any of this holds up — the same argument as keeping embeddings fresh. Building the index correctly is a one-off. Keeping it correct is permanent.
← All engineering notes