← Query Engine

Data model

query-engine · workzone

Query Engine stores the conversation and the access signal, but not knowledge. The module's data home is conversation sessions and messages (conversations, messages), a retrieval snapshot for each grounding answer (retrieval_trace), and an aggregate of how often entities are accessed (access_counter). The entities themselves live in Knowledge Store: the ids recorded here are references, not copies. Where this line runs is at the boundary with Knowledge Store.

Conversation session: conversations

The top level is a user's conversation session on one of the surfaces (web · slack · telegram · mattermost · mcp · extension). The session owns the flow of the conversation: the message history is bound to it, and the model looks into its past messages when formulating a search query. A mutable row — the title, the selected chat model, and updated_at are edited as the conversation goes.

1 Session A conversation on one surface — owns the flow.
conversations conversation sessions
FK → users
id BigInteger PK
user_id BigInteger FK→usersIDX session owner · CASCADE · their identity is the retrieval input
surface Text NOT NULLCHECK where the conversation comes from · web · slack · telegram · mattermost · mcp · extension
title Text NULL title · auto-generated from the first message, editable
selected_model Text NULL sticky chat-model choice · validated against the Admin allow-list · NULL = default model
meta JSONB NULL flexible session attributes · surface context · for Slack, the thread key (team, channel, thread_ts); for Telegram, chat_id; for Mattermost, (channel_id, root_id)
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger · title edit and each new message
One session model for all surfaces
One conversation model per platform: the surface (web · slack · telegram · mattermost · mcp · extension) is just a field, not a separate table per client. Thin clients write to a shared contract, so history and context carry behave identically everywhere. The conversation is mutable: title is refined and updated_at is moved by each new message — hence both timestamps and the set_updated_at() trigger.
The model choice is sticky — two layers: the conversation, then the person
An employee picks a chat model from the allow-list assembled by Admin; the choice sticks to the conversation — it holds for the next answers and survives a reload, so it lives as a row on the session (selected_model) rather than a one-off client-side pick. This is the intent of “what to generate with going forward”, and it must not be confused with messages.model: that records the fact of “what a specific answer was generated with”. The same pick also sticks to the person (users.last_chat_model), seeding every new conversation ahead of the admin default — so a returning employee lands on the model they last chose, not a reset. Resolution runs most-intimate-first: this turn’s pick → the conversation’s selected_model → the person’s last_chat_model → the admin default; a stale value (removed from the allow-list) falls quietly to the next layer. An empty selected_model is therefore no longer “the admin default” but “inherit the person’s default”. This sticky pair belongs to surfaces that have a picker (Web); messengers (Slack/Telegram/Mattermost) and machine surfaces carry neither layer and always run on the admin default. Changing the model edits the row — hence the move of updated_at.
Conversation message: messages

An append-only feed of the session's messages: the user's query and the assistant's answer arrive as rows in order, and past ones are never rewritten. On an assistant message, we record which chat model generated it and how many tokens were spent (tokens_used — the whole turnaround of the message, input + output; per-person chat spend), and the same row accumulates feedback — a thumbs rating of the answer, raw material for measuring quality. A turn that ends badly is not swallowed: finish (with error_code) marks it stopped or failed — a write-once terminal stamp so a reload replays the same notice the live stream showed, never a silent question with no answer beneath it.

2 Message A conversation message — query or answer.
messages conversation messages
FK → conversations append-only
id BigInteger PK
conversation_id BigInteger FK→conversationsIDX session · CASCADE · history keyed by it
role Text NOT NULLCHECK user · assistant
content Text NOT NULL the message text
model Text NULL what the answer was generated with · fact, not intent (cf. conversations.selected_model) · assistant only
tokens_used BigInteger NULL message tokens · the whole turnaround, input + output (RAG context + answer), not output alone · set on finalization · raw material for per-person chat spend (AI usage) · assistant only
feedback SmallInteger NULLCHECK thumbs ±1 on an assistant message · edited after the fact · raw material for quality measurement
finish Text NULLCHECK how an assistant turn ended · NULL = completed cleanly · stopped (user cancelled, partial text kept) · failed (errored) · a failed turn lands a row even with empty content, so a reload replays the notice instead of a silent dangling question · assistant only
error_code Text NULL the reason behind finish='failed' (e.g. PROVIDER_UNAVAILABLE) · the replay redraws the same failure plaque from it
created_at DateTime(tz) DEFAULT now()
updated_at DateTime(tz) DEFAULT now() + trigger · only under a feedback edit
An append-only feed, but feedback is editable — hence both timestamps
The message itself is immutable: the message text and role are never rewritten — by the journal rule, a single created_at would suffice. But feedback is the exception: the user sets and changes the thumbs up/down after the message is already written, and that is an in-place UPDATE of the row. Once a row gets an UPDATE on even one field, by project convention it needs both timestamps and the set_updated_at() trigger — updated_at honestly marks the moment of rating. The message body stays unchanged: we don't edit the feed, only annotate it with a rating.
Retrieval snapshot: retrieval_trace

For every grounding answer — when the model called search — an immutable snapshot of what retrieval returned: the self-contained query handed to the tool, the candidates with their scores, and the citations that actually made it into the answer. A citation holds the marker ([1], [2]…), the source record, and the winning fragment-chunk — enough to redraw the history with the same references without asking retrieval again. A conversational answer has no snapshot — there was nothing to search; there is exactly one snapshot per message (UNIQUE on message_id), a 0..1 relation. The snapshot is the basis for the access signal: frequency is accumulated in access_counter from citations. Written once and never edited.

3 Snapshot What retrieval found for this answer.
retrieval_trace retrieval snapshot per message
FK → messages UNIQUE (message_id) immutable
id BigInteger PK
message_id BigInteger FK→messagesIDX the assistant message the snapshot belongs to · CASCADE · UNIQUE → exactly one snapshot, idempotent insert
search_query Text NOT NULL the self-contained query handed to the tool · retrieval input
candidates JSONB NULLREF KS entity ids + scores · REFERENCES into KS, not data copies
citations JSONB NULLREF answer citations · marker → record (entity_id) + winning fragment (chunk_id) + score, in [1][2] order · basis for replay and the access signal
created_at DateTime(tz) DEFAULT now() · only · the snapshot is never edited
The snapshot is written once — hence created_at only
The trace is a photograph of the retrieval moment: what was rewritten, what was found, what was taken. Once written it does not change — an append-only journal; by project convention a single created_at suffices, and updated_at would be meaningless. candidates and citations hold ids of Knowledge Store entities and chunks, not their text: the body of the knowledge lives in KS, and here there is only a reference to it and a score. A citation records both the record and the winning chunk precisely so the history can be redrawn with the same references without asking retrieval again. From citations, retrieval rolls frequency into access_counter (by their entity_id) — the mechanics are held by the access signal.
Access counter: access_counter

An aggregate of how often entities are accessed: how many times an entity actually entered an answer or its citation was clicked — and when it was last. It grows by upsert from two sources: citations (what entered the answer) on each turn, and a click on a citation in the output; Knowledge Store reads it by JOIN when recomputing staleness — demand for an entity raises its curation priority. A mutable counter — hence both timestamps.

4 Counter How often an entity is accessed — a signal into KS.
access_counter access aggregate
UNIQUE (entity_ref) upsert · counter
id BigInteger PK
entity_ref BigInteger NOT NULLREF KS entity id · a REFERENCE, not an FK across the module boundary · upsert key · the index carries UNIQUE
hits BigInteger NOT NULLDEFAULT entered an answer or a citation click · incremented by upsert
last_accessed_at DateTime(tz) NOT NULL when it was last accessed · staleness input
created_at DateTime(tz) DEFAULT now() · first access to the entity
updated_at DateTime(tz) DEFAULT now() + trigger · every increment
A counter-aggregate · upsert per entity — hence both timestamps
One row per entity: a repeat access does an upsert by entity_refhits++ and last_accessed_at = now(). This is an in-place UPDATE in its purest form, so the row needs both timestamps with the trigger. The counter is a demand aggregate: the answer share could be reassembled from retrieval_trace snapshots (by citations), but a citation click arrives as a separate live event and is not written into a snapshot — which is why we keep the aggregate as the source of truth, not just a cache over the feed; staleness reads it by JOIN without a scan. The mechanics — what counts as an access and how the signal flows into KS — are held by the access signal.
Boundary with Knowledge Store: references, not copies

Query Engine stores no knowledge. The entity and chunk ids in retrieval_trace (candidates, citations) and the entity ids in access_counter (entity_ref) are references into the Knowledge Store data model, not copies of entity bodies. The body of the knowledge, its text, and its access rights are there; here are the conversation, what was found per message, and demand for an entity.

Query Engine references

Holds the conversation (conversations, messages), the per-message retrieval snapshot, and the access counter. From what was found — only the id and score: a reference to the KS record and the winning chunk, not their body. The demand signal (access_counter) is read by KS via JOIN when recomputing staleness.

Knowledge Store stores the knowledge

The source of truth for entities: body, fragments with vectors, graph, access rights. From the ids in the trace it restores the record for citation. It receives the access signal and raises the curation priority by it — the knowledge stays entirely on the KS side.

The boundary: “knowledge → Knowledge Store, conversation and access signal → Query Engine”. Citation pulls the record by entity_id from entities, and the evidence fragment by chunk_id from chunks.