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.
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.
| 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 |
title is refined and
updated_at is moved by each new message — hence both
timestamps and the set_updated_at() trigger.
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.
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.
| 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 |
feedback is editable — hence both timestamps
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_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.
| 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 |
created_at only
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
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.
| 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 |
entity_ref — hits++ 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.
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.
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.
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.