Chat Analytics Platform
Track who said what in a conversation, keep search up to date without blocking writes, and make analysis results reproducible when models change.
Topics: Search indexes, batch analysis, versioned results, recovery
Understanding the problem
We are designing an internal analytics platform for a company with AI chat. Analysts need to search chat history, measure what people discuss, and run jobs that find similar groups of conversations.
A user asks, "What running shoes work for flat feet?" The AI replies with several paragraphs mentioning Nike, Reebok, and Adidas. Does that count as a user discussing Adidas? The answer depends on what we are measuring. Our system must preserve who said what throughout storage, search, and analysis.
This is separate from chat serving. A chat response must remain fast while an analyst runs a broad query or a clustering job takes hours.
We receive about 2 million messages each day. Traffic lands over eight hours, roughly 70 messages per second today. At 50 percent quarterly growth, that grows to about 5x after four quarters, or about 350 messages per second during those eight hours. We plan for a 3x peak of roughly 1,000 messages per second next year. That peak is an assumption to test, not a measured fact.
The bytes are lopsided. A conversation has roughly three to five user messages of about 50 bytes and three to five AI messages of about 2 KB. One AI message carries about 40 times the text of one user message. A system that treats all text as equal will count recommendations as user interest.
The first year adds about 1.5 TB of raw text, roughly 1.48 billion messages across 185 million conversations. That is enough to make one decision early: broad historical analysis is batch work. We should not promise that every question over a year of text is a two-second request.
The throughput alone is not what makes this problem hard. A single PostgreSQL node can ingest 1,000 writes per second without strain. The hard part is that the system must preserve who said what across storage, search, and analysis. It must keep search fresh without coupling it to ingestion. And it must make reported numbers reproducible after models and data change underneath them. Those semantic and consistency problems are where the design decisions live.
Functional requirements
- Append messages and retrieve an ordered conversation history.
- Search, filter, count, and group conversations. Analysts can select user text, assistant text, or both.
- Submit clustering or approved custom-analysis jobs, then search their published assignments.
Non-functional requirements
- Ingest is durable and duplicate-safe. We target p95 acknowledgement below 500 ms. P95 means 95 percent of requests finish within that time. It is a target, not a benchmark.
- Support 100 concurrent searches. We assume p95 first page under two seconds and search freshness within five minutes. Broad exact aggregations become jobs.
- Retain one year of history and make batch analysis reproducible. A job may take minutes or hours.
Out of scope are chat generation, tenant isolation, privacy implementation, and an instant global recluster after every message. We chose batch analysis because real-time clustering is not worth the cost it imposes on the write path.
An interviewer may ask why analysis is not real time. Say what the product loses and gains. The product loses an instant group label. It gains a design where the chat path is independent from slow model work and where an analyst can explain where a number came from.
The setup
Core entities
| Entity | What it is |
|---|---|
Conversation | One ordered chat between a user and the AI. |
Message | One user or assistant entry in that conversation. |
AnalysisJob | A request to run a named, approved analysis over a chosen population. |
ClusterRun | One saved set of groups created by a named method. |
AnalysisJob and ClusterRun are separate because they have different lifecycles. A job is a request that can fail, retry, or be cancelled. A run is a completed result that persists after the job finishes. One job produces one run on success; a failed job produces no run. Keeping them apart means a failed job never leaves a half-built run for search to find.
API
Search is a POST because the request body carries a structured query with Boolean logic, role selection, date ranges, filters, and grouping instructions. That payload does not fit cleanly into URL parameters, and a POST body avoids URL length limits that would silently truncate a complex query.
text_source is a parameter rather than a separate endpoint because the query logic is identical for user text, assistant text, or both. Only the field being searched changes. Separate endpoints would duplicate the search API for each role combination.
POST /conversations/{id}/messages
Body: { message_id, sequence, role, text, occurred_at }
GET /conversations/{id}
POST /search
Body: { query, text_source, date_range, filters, group_by }
POST /analysis-jobs
Body: { pipeline, pipeline_version, population, parameters }
GET /analysis-jobs/{id}An analyst might send:
POST /search
{ query: '"running shoes" AND Reebok AND NOT Adidas',
text_source: 'user', group_by: 'week' }At first, this is literal phrase and term matching. It does not mean the vague semantic topic of running shoes. The result counts distinct conversations, not matching messages. Nike and Reebok can both count for one conversation.
With text_source: user, NOT Adidas means Adidas appears nowhere in that conversation's user messages. The assistant mentioning Adidas does not exclude the conversation. For this design, date_range and weekly grouping use conversation creation time. That is a simple definition an analyst can understand and repeat.
High-level design
We will build this in the order the data moves. Start with a durable conversation. Then make it searchable. Then add analysis.
1) Append and read ordered history
The producer sends user message m17 for conversation c42 with sequence 1. PostgreSQL owns this path because it can commit related changes together.
message_id is the idempotency key. Idempotency means a retry has the same result as the first request. Store a unique pair of conversation and message ID, and a unique pair of conversation and sequence. A retry of identical m17 returns the first receipt. Reusing m17 with different text returns HTTP 409. Sending another message at an occupied sequence also returns 409.
The producer supplies the sequence. A late sequence 2 can still be valid after sequence 10. The system orders history by the accepted sequence.
-- conceptual transaction
BEGIN;
INSERT message(c42, m17, sequence=1, role='user', text=...);
-- identical message_id returns the old receipt; conflicting data returns 409
UPDATE conversation SET source_revision = source_revision + 1 WHERE id = c42;
INSERT outbox_event(c42, source_revision, 'conversation_changed');
COMMIT;Only acknowledge after the commit. Candidates often acknowledge first because it looks faster. It creates the worst kind of data bug: the producer believes the message exists, and the database does not.
source_revision advances for any accepted change that search can show, such as a new message or a published group assignment. It lets a later search write win over an older one.
Messages and outbox events commit together; ordered history reads from PostgreSQL.
PostgreSQL is the source of truth for ordered messages. The history API reads it directly, which also lets a user see a just-written message before search catches up.
2) Search conversations
An analyst should not join billions of message rows for every search. Search needs one record per conversation with fields shaped for the questions we support. This is where we add Elasticsearch.
An indexer reads the committed conversation and writes one search record with separate user_text and assistant_text fields. The first contains the flat-feet question. The second contains the AI recommendation that mentioned several brands.
An analyst can ask what users discuss without silently counting the assistant's recommendations. It also lets the analyst explicitly search assistant recommendations when that is the question.
At low traffic, a records scan in PostgreSQL can prove the search semantics. It stops being a useful interactive approach once the year fills up. The search deep dive explains the index.
We also need a durable handoff from writing to indexing. Notice the outbox_event row in the transaction above. If the write process dies after commit, that row is how a publisher knows to retry. The publisher sends each event to an index queue, which buffers work so a slow indexer does not block ingestion. The freshness deep dive covers the full pattern and the failure it prevents.
Committed changes flow through a queue into a conversation-shaped search index.
The opening example now has a clean answer. Search user text for Adidas and c42 does not match. Search assistant text and it does. Tell an interviewer this rule before choosing a search engine. The rule matters more than the engine.
3) Submit and publish analysis
An analyst now submits a running-shoe clustering job. The job API stores its pipeline name, pipeline version, population filter, parameters, and status in PostgreSQL. Workers claim jobs with a lease so a crashed worker does not hold one forever.
The analysis worker needs a fixed dataset. It reads immutable Parquet files from S3. S3 is object storage, a service for storing and retrieving files by name. Parquet is a column-oriented file format that works well for large batch reads because a query that needs three fields can skip the rest. A separate export queue feeds an exporter that writes conversation versions to those files as they change and periodically records a completed manifest. A manifest is a list of files that make up one fixed dataset. A job pins one completed manifest. It may exclude messages still waiting for export.
The manifest can lag. Record its coverage and lag, and show a failed export rather than hiding it. This is a good place to show restraint in an interview. You do not need a streaming platform, a warehouse, and a workflow engine to explain one batch export.
The worker writes the result and its method back to durable storage. A finished global run can become searchable. A scoped running-shoe job can publish a scoped result, but it does not replace the global grouping that search uses by default.
Analysis workers use frozen inputs while ingestion and interactive search continue independently.
The job is deliberately asynchronous. The analyst gets an ID, polls GET /analysis-jobs/{id}, and sees queued, running, succeeded, or failed. That is more honest than holding an HTTP request open while model work runs.
Four deep dives
Each deep dive follows the same interview move. Name the failure. Show the first reasonable answer. Improve it. Then choose the design and explain the cost it accepts.
1) How do we search a year of conversations without scanning everything?
Problem
A row-per-message scan reads assistant-heavy text, reconstructs conversations, and removes duplicates on every request. It may be fine for an audit. It will not give a reliable first page over a year of history.
Phrase semantics are another trap. If a user says running in one message and shoes in the next, a literal search for "running shoes" should not match by accident.
Bad solution: scan messages
Approach. Scan messages, filter by role and date, then count distinct conversations.
SELECT count(DISTINCT conversation_id)
FROM messages
WHERE role = 'user' AND text ILIKE '%Reebok%';Challenges. This makes every broad request a data scan. At year-end that scan reads roughly 1.5 billion rows, most of them assistant-heavy. A single broad query can take minutes, and 100 concurrent analysts running them will compete for the same I/O. It also invites sloppy semantics when the application concatenates messages before matching phrases. A candidate who starts here is fine. A candidate who stops here has not used the scale numbers.
Good solution: PostgreSQL full-text search
Approach. Build a tsvector column on a conversation-level record. PostgreSQL can use a GIN index on tsvector to match terms without scanning every row. It supports AND, OR, and NOT.
SELECT conversation_id
FROM conversation_search_records
WHERE user_text_tsv @@ to_tsquery('Reebok & !Adidas');Challenges. This solves term matching and avoids the full scan. But tsvector does not natively support exact phrase matching across array boundaries. If user messages are concatenated into one text field, "running shoes" can match across two separate messages. The role separation we need, keeping user_text and assistant_text as independent searchable fields, is possible but increasingly awkward as query complexity grows. And at 185 million conversation records with heavy aggregation queries, PostgreSQL becomes the bottleneck for both writes and analytical reads. We are asking the same database to serve durable ingestion, interactive search, and batch export.
Great solution: Elasticsearch inverted index
Approach. Elasticsearch builds an inverted index, a data structure that maps each term to a posting list, the set of document IDs containing that term. The query engine intersects posting lists for AND and subtracts for NOT, without reading the text itself.
Keep user and assistant fields separate. Store each role as an array with one value per message. This matters for phrase matching. A position increment gap is a configurable space between array entries in Elasticsearch's term index. When set high enough, a phrase search with slop zero (meaning terms must appear immediately adjacent) cannot accidentally bridge two separate messages. Without the gap, "running shoes" could match running at the end of one message and shoes at the start of the next.
user_matches = phrase(user_text, "running shoes")
∩ term(user_text, "Reebok")
− term(user_text, "Adidas")For a search across both roles, combine each clause across roles. A brand condition becomes user.Nike OR assistant.Nike. An exclusion becomes NOT user.Adidas AND NOT assistant.Adidas. Otherwise, a match in one role can hide a forbidden term in the other.
Route a conversation to the Elasticsearch index for its creation month. It stays there when a late message arrives. Daily retention removes messages older than one year, rebuilds the remaining conversation record, and deletes the conversation once no messages remain. A retained history may start in the middle of a conversation.
Challenges. Finding the first page and proving an exact aggregate are different jobs. Posting lists can find candidate conversations without reading most text. An exact count or a large set of weekly buckets may still touch many matching documents. Cap interactive aggregation breadth and submit broad exact requests as jobs.
For a result labeled exact, request total-hit tracking and reject failed shards or timeouts. Fixed brands such as Nike, Reebok, and Adidas fit named filter buckets. One document per conversation makes each bucket count a conversation, and overlapping buckets are expected. A top terms aggregation can have approximate bucket counts, so do not present it as a complete brand census.
Use a point-in-time read with search_after for a stable result page. It is a useful cursor, not a long-term analysis record. Interviewers ask this follow-up to see whether you notice that a fast page and a defensible report have different consistency needs.
Do we need a separate analytics database for aggregations?
Probably not yet. The tempting move is to add a columnar warehouse such as ClickHouse or BigQuery for broad aggregations. But the batch export to Parquet already gives us a read-friendly historical dataset for expensive jobs, and Elasticsearch handles interactive search. Adding a third query engine creates a third copy of every conversation to keep consistent. Start with the interactive cap that routes broad queries to the job system, and add a warehouse only if the job queue becomes a bottleneck for common analytical questions.
2) How do we group conversations whose intent keeps changing?
Problem
Start with one conversation. The user says, I need running shoes. Later they add, I need them for rehabilitation after a knee injury. The conversation may reopen next week.
Its intent changed. It started as shoe shopping and became rehabilitation support. There is no useful permanent-close rule based on 30 minutes of silence.
The expensive bad answer is to rebuild all global groups after every message. It spends model work on every edit and keeps redefining every group. Splitting the data by shard and clustering each shard independently is also wrong for global groups. Group 7 on one shard has no relationship to group 7 on another.
Bad solution: embed everything, assign on every message
Approach. Concatenate user and assistant text, embed it, and assign the conversation to the nearest group.
group = nearest_center(embed(user_text + assistant_text))Challenges. The assistant has about 40 times the bytes of a user message. Its recommendations can drown out what the user asked for. At 185 million conversations per year, even a fast embedding model needs meaningful compute to re-embed every conversation after each message. Going user-only has the opposite problem. If the user asks which is cheaper?, the AI's earlier answer may be needed to resolve the reference.
Good solution: classify into fixed known categories
Approach. Define a maintained taxonomy of known topics, such as running_shoes, injury_prevention, and budget_shopping. Run a classifier that assigns each conversation to one or more of those categories. This works well for known questions and is fast enough to run on each new message.
Challenges. This only finds what we already know to look for. The whole point of the clustering requirement is to discover groups we have not named yet. An analyst who asks "what unknown subtopics exist within running-shoe conversations?" gets no answer from a fixed classifier. Classification is useful once clustering has discovered stable categories worth promoting, but it cannot replace discovery.
Great solution: role-aware summaries with reusable centers
Approach. First turn the ordered conversation into a short summary of user intent. The summary treats user text as the claim and assistant text as context. It can resolve which is cheaper? without deciding that every brand in the AI response is a user interest.
Next create an embedding, a list of numbers where similar meanings sit near each other. Group nearby embeddings with MiniBatchKMeans. The property that matters for this design is that MiniBatchKMeans produces reusable centers. We train from a reproducible sample once per night, then assign new and changed conversations by finding their nearest center without retraining. That is what makes the hourly pass cheap and the nightly pass the only expensive operation.
The algorithm gives us numbered groups, not names such as "rehabilitation support." We inspect example conversations nearest each center before giving a group that name. If the examples do not share a clear intent, the grouping needs work.
summary = summarize_user_intent(messages, assistant_context=True)
vector = embed(summary)
group = nearest_center(vector, active_global_run.centers)Every hour, summarize and assign changed conversations using the active centers. Every night, train a new global run from a reproducible sample and assign the retained history before publication. We start with 200 broad global groups. A scoped running-shoe job uses 30 groups on its own pinned population. These are starting choices to evaluate, not natural categories hidden in the data.
The running-shoes conversation can move to an existing rehabilitation group after the knee-injury update. The hourly pass handles that reassignment. If none of the current groups fits, the conversation may stay unassigned until a nightly run discovers a suitable group.
Challenges.
Groups have limits. A rare intent may never appear often enough in the training sample. A conversation can contain two intents. A conversation far from every center should stay unassigned until people review it. Distance is a measure of geometry, not confidence that a category is true.
This is where candidates often overbuild. You do not need online global reclustering, all-pairs similarity, or per-shard models. You need a representation that respects speaker roles, reusable centers, and a schedule that matches the product's freshness promise.
What if a conversation belongs to two groups?
It can. A user who asks about injury prevention and then budget options has two intents. The first version assigns the conversation to its nearest single center, which is a known simplification. Two practical extensions are soft assignment (recording distance to the top-k nearest centers) and splitting by user turn (embedding each user question independently). Both add complexity to the search view, so start with single assignment, measure how often reviewers disagree with the chosen group, and add soft assignment if the disagreement rate is high.
What if the nightly run produces worse groups than the current ones?
Do not publish automatically. Run the new centers against the held-out sample and compare group coherence with the current run. If the new groups are worse, or if too many conversations land far from every center, keep the current run active and flag the new one for review. The publication step is a gate, not a timer.
3) How do we keep search fresh without blocking ingestion?
Problem
The message can commit to PostgreSQL, then the write process can die before it tells the indexer. History is correct while search misses the conversation. A slow old indexing task can also arrive after a newer one and overwrite it.
Bad solution: commit then notify
Approach. Commit the message, then send a queue notification.
commit message
queue.send(conversation_id)Challenges. A crash between those two lines leaves work missing until some repair job happens to find it. That is why an interviewer asks about the gap. They want to know whether you treat a database commit and a queue send as one operation when they are not.
Good solution: index inside the write transaction
Approach. Send the Elasticsearch index request inside the PostgreSQL transaction, or immediately after commit as a synchronous call before acknowledging the producer.
Challenges. Now ingestion latency includes the Elasticsearch round trip. If search is slow or unavailable, message ingestion fails too. We promised that chat serving is independent from analytics. This couples them at the worst possible point: the critical write path. The system cannot accept a message unless search is healthy. At scale, this also serializes every write behind an index call that may take tens of milliseconds.
Great solution: transactional outbox with versioned index writes
Approach. Write the message and an outbox row in one PostgreSQL transaction. The transactional outbox is a reusable pattern: whenever a committed fact must reliably reach another system, record the pending work in the same transaction so no crash can lose it. A publisher later reads pending events and sends each one to the index queue.
We use SQS Standard for the queue. SQS is Amazon's managed message queue service. Standard queues are fast and scalable, but delivery can repeat and arrive out of order. The consumers must therefore be safe to run twice.
Give every search-visible source change a source_revision. The write transaction assigns it. The indexer reads one consistent source state and sends that revision with the full document. Elasticsearch accepts a strictly newer external version and rejects equal or old retries. Its index API documents this behavior. This is a second reusable pattern: an external monotonic version makes writes idempotent and order-safe without coordination between workers.
document = read_current_conversation(conversation_id)
index(document, external_version=document.source_revision)
ack queue message after success or duplicate rejectionWalk through the dangerous sequence to see why both patterns matter. Message m18 commits at source revision 2. The write process crashes before the outbox publisher runs. A second message m19 arrives, commits at revision 3, and its outbox event publishes normally. The indexer processes revision 3. Later, the outbox publisher recovers the orphaned revision 2 event. The indexer reads the current source, which is now at revision 3, and submits that. Elasticsearch sees revision 3 already indexed, rejects the duplicate, and the worker acknowledges the event. No work was lost and no stale document replaced a newer one.
Challenges. This is at-least-once delivery, not magic exactly-once delivery. Watch queue age and compare source revisions with indexed revisions in a repair job. Those checks help us meet the five-minute search freshness target.
To rebuild an index from scratch, register the new index as a target, backfill it, replay its pending changes, reconcile source revisions, then atomically switch the Elasticsearch alias. An alias is a name that points to one or more concrete indexes. Switching which index the alias points to is the blue-green swap pattern: build the replacement completely before making it live, so readers never see a half-built state. Keep the old index briefly for in-flight reads. Do not claim one commit spans PostgreSQL and Elasticsearch. It does not.
What if the queue itself goes down?
Outbox events stay in PostgreSQL until marked delivered. If the queue is unavailable, the publisher retries. Messages continue to be ingested because the write path does not depend on the queue. Search freshness degrades until the queue recovers and the backlog drains, but no work is lost. If the queue is down for hours, monitor the outbox table size and alert, because it will grow until delivery resumes.
Search freshness and cluster freshness are not the same thing
This is the place where two deep dives collide, and interviewers notice whether you see it. Search can show a new knee-injury message within minutes. The hourly cluster assignment has not run yet, so the conversation still carries its old group label. If the search result displays both the fresh text and the stale group as if they came from the same analysis, the analyst draws a wrong conclusion.
The fix is a pending state. When the indexed conversation version is newer than the version the cluster assignment was computed from, mark the group as pending in the search document. An analyst who filters by group sees only conversations whose assignment matches their current text. A conversation in flux is excluded from group counts rather than counted under a label that no longer fits.
This is a concrete example of a broader principle. Derived facts must carry the version of their input and the version of their method.
4) How do we change models without corrupting old reports?
Problem
An analyst saves a report that says a rehabilitation group contains 12,000 conversations. A week later, a better prompt or new centers produce 19,000. Both numbers may be useful. A silent overwrite makes the first report impossible to explain.
There is a second failure. A new analysis can crash halfway through. It must never mix half of the new groups with the old published groups.
Bad solution: one mutable row per conversation
Approach. Keep one mutable row per conversation with its current group.
UPDATE memberships SET group_id = :group WHERE conversation_id = :id;Challenges. The row cannot answer basic questions about a report. Which messages did it use? Which summary prompt, embedding model, grouping method, and parameters produced it? It also makes a half-built run visible too soon. If the worker crashes at row 50,000 out of 185 million, search shows a mixture of old and new assignments with no way to tell which is which.
Good solution: append-only assignments with a "current" flag
Approach. Write each assignment as a new row tagged with its run ID. Mark one run as current. Old assignments remain readable for historical reports.
INSERT INTO memberships (run_id, conversation_id, group_id, distance)
VALUES (:run, :conversation, :group, :distance);Challenges. The "current" flag is a single mutable bit that every search query must join against. If the new run is marked current before all assignments are written, search sees a partial result. If two workers try to mark different runs current at the same time, the flag becomes inconsistent. We still need an atomic publication step, and we still lack a record of what inputs and model version produced each run.
Great solution: frozen results with atomic publication
Approach. Treat every completed analysis as a frozen result. Save its input dataset, code and model version, parameters, centers, and assignments. The live search view receives hourly updates. A saved report instead pins an immutable copy of the input and assignments it counted. Pinning only a model run is not enough because hourly assignments using unchanged centers can still change the live count.
The concrete storage looks like this:
CREATE TABLE cluster_runs (
run_id UUID PRIMARY KEY,
job_id UUID REFERENCES analysis_jobs(job_id),
manifest_id UUID, -- which export snapshot was the input
summary_prompt TEXT, -- frozen prompt version
model_version TEXT, -- embedding model used
n_clusters INTEGER,
parameters JSONB, -- random seed, batch size, etc.
status TEXT, -- 'building', 'validated', 'active', 'retired'
created_at TIMESTAMPTZ
);
CREATE TABLE cluster_assignments (
run_id UUID REFERENCES cluster_runs(run_id),
conversation_id UUID,
conversation_version INTEGER, -- which version of the conversation was analyzed
group_id INTEGER,
distance FLOAT,
PRIMARY KEY (run_id, conversation_id)
);Every assignment is tied to both a run and a specific conversation version. If the conversation changes after assignment, the distance may no longer be accurate. A fresh hourly pass reassigns the changed conversation; the old assignment row stays for reproducibility.
Build a new global result in the background. Validate that it is complete. Then publish it as one new search view using an Elasticsearch alias swap, the same blue-green pattern from the freshness deep dive. If the worker crashes, leave the candidate inactive and keep the old view. A successful publication changes which complete result new searches use. It never edits the old result.
candidate = run_analysis(frozen_input, model_and_code_version)
if candidate.is_complete_and_valid():
publish(candidate.search_view) -- alias swap
else:
keep_current_search_view() -- old result stays liveHourly work only updates conversations that changed. A new model, prompt, or center definition needs the complete rebuild because it changes the meaning of every assignment. That is the useful dividing line. Do not rebuild the entire year for one new message. Do rebuild when the definition of a group changes.
Challenges. PostgreSQL records job state, S3 holds batch files, and Elasticsearch serves search. They do not share a transaction. The practical answer is ordered publication: finish the durable result, build the complete search view, then make that view active in one alias switch. A failure before the switch leaves the old result live.
Candidates sometimes spend ten minutes naming every version field. Do not. The interviewer is looking for the invariant: a reported 12,000 remains inspectable after 19,000 becomes the current answer.
What happens to in-flight searches during a publish?
An analyst whose search started before the alias switch continues reading the old index. Elasticsearch keeps a point-in-time reader attached to the segments it opened, even after the alias moves. New searches land on the new view. We keep the old index alive briefly for these in-flight reads, then delete it. There is no moment when a search sees half of one run and half of another.
Final design
The complete design separates durable ingestion, interactive search, and reproducible batch analysis.
Trace the opening conversation. The write API accepts user m17 at sequence 1. It commits the message and an outbox event. It later accepts assistant m18 at sequence 2. The indexer builds one record with the flat-feet question in user_text and the brand recommendations in assistant_text.
An analyst searches user text for Adidas. c42 does not count, because only the assistant said Adidas. That result surprises people at first. It is also the whole point of preserving roles.
Now trace the running-shoes job. The worker pins a completed export manifest, writes intent summaries and embeddings, and fits its 30-group scoped result. It saves the assignments and method. That scoped result may become searchable on its own without disturbing the global groups.
A global job follows the same path with 200 groups. It builds a complete new view and publishes it only after validation. A saved weekly report records the literal query, role selection, input, and assignments. An analyst can inspect those records when a count looks strange.
Additional deep dives
-
Semantic search. Add a related-conversation mode that embeds the query and returns nearby intent summaries. Label it related, not an exact count. It is a separate feature from literal Boolean search. The interesting design question is how to combine a semantic score with metadata filters without scanning the full vector space. An interviewer may also ask how you keep the embedding model consistent between the conversation summaries and the query.
-
Deletion and privacy. A deletion request must remove source messages and invalidate search records, snapshots, and published artifacts under a defined policy. Authorization, encryption, tenant boundaries, and legal holds need their own design. The hard part is that derived data lives in multiple stores. A search document, a Parquet export, and a cluster assignment can all reference a deleted conversation, and each needs a different removal path with its own timing guarantees.
-
Multi-tenant isolation. Carry tenant identity through every source record, queue, file, and index route. A tenant filter in a search request is not an authorization boundary. The design challenge is that clustering needs a global population to discover global groups, but tenants may not want their data grouped alongside other tenants. That tension between analytical value and isolation is worth naming even if you do not solve it.
-
Backfill without downtime. Build a new search view, replay missed changes, compare source and index revisions, validate, then switch the alias. Scheduling and rollback need an operations runbook. The nontrivial part is that new messages keep arriving while the backfill runs. The new view must catch up to live traffic before the switch, and the switch itself must not lose any writes that landed during the gap.
What is expected at each level
Mid-level
Show a complete path for one message. The client writes a sequenced message. PostgreSQL commits it before acknowledgement. The history API reads it back in order. You do not need to name every constraint, but say that retries cannot create a second message.
Show one conversation-shaped search record with separate user and assistant text. If an interviewer asks whether Adidas counts, ask which speaker the analyst means. That question shows the interviewer you understood the data before picking a tool.
Use an asynchronous job for clustering. Explain that a job may take minutes or hours and cannot block chat ingestion. A reasonable answer can stop with PostgreSQL, Elasticsearch, a queue, and batch files. Do not spend your time inventing a custom vector database or a fleet of model services.
Expect a follow-up about a crash after message commit. Name the outbox and explain it in one sentence. The message and the work notice commit together, so a later publisher can recover the notice. I do not need you to draw the outbox publisher. I need you to know why the gap exists.
Senior
Derive the scale and use it. A senior answer turns 2 million daily messages, AI-heavy text, and one-year retention into an argument for conversation records, inverted search, and batch aggregation. It does not claim index size or latency numbers that nobody measured.
Make query meaning precise. State that counts are distinct conversations, brand buckets overlap, literal phrases do not cross messages, and exclusions apply to the selected role. If asked for a broad weekly aggregation, say when you would return a background job rather than a partial interactive answer.
Drive the recovery discussion before the interviewer has to. Explain the commit-then-notify gap, the outbox, duplicate delivery, and versioned index writes. You do not need to recite queue internals. The signal is that retries and stale work cannot corrupt the search view.
For clustering, explain the role-aware summary, reusable centers, hourly assignment, and nightly rediscovery. Be ready for Why not rebuild all groups after each message? and Why not train each shard? The answers are changing definitions, unnecessary model work, and groups that cannot be compared globally. I do not mind if your first instinct is regular KMeans. Just be able to tell me why retraining from scratch on every update is expensive and what reusable centers buy you.
Staff+
Start by asking what a reported number means. Is it a live operational estimate, a saved report, or an exact historical claim? Those products have different consistency and cost needs. Staff candidates draw that line before picking a storage product.
Show how definitions change safely. A new prompt, embedding model, or grouping method creates a new result. Build it away from the live view, validate it, then publish it in one switch. Keep the old result inspectable. The useful follow-up is, What happens if the job dies halfway through? The answer is that the old complete view remains live. If you can walk through that failure without hesitation, you understand the invariant.
Explain the difference between search freshness and analysis freshness. New text can be searchable within minutes while its group assignment waits for the hourly pass. Show the pending state instead of pretending the two facts came from the same input.
Challenge needless machinery. A streaming recluster, separate shard-local groups, or a new distributed service for each stage may sound impressive and make the semantics worse. A staff answer chooses the smallest system that preserves who said what, handles recovery, and keeps reports reproducible.
Finally, name the measurements you would collect before changing the design. Search query mix, queue lag, export coverage, group review quality, assignment cost, and the rate of pending assignments tell you whether to add workers, change the schedule, or revise the representation. The strongest signal at this level is knowing which number would make you add a component.