Query Engine хранит диалог и сигнал обращений, но не знания. Дом данных модуля — это сессии и сообщения беседы (conversations, messages), снимок retrieval на каждый grounding-ответ (retrieval_trace) и агрегат частоты обращений к сущностям (access_counter). Сами сущности живут в Knowledge Store: id, которые здесь записаны, — ссылки, не копии. Где проходит эта линия — на границе с Knowledge Store.
conversations
Верхний уровень — сессия беседы пользователя на одном из surface'ов
(web · slack · telegram · mattermost · mcp · расширение). Сессия владеет ходом диалога: история
сообщений привязана к ней, и модель смотрит в её прошлые реплики, когда
формулирует запрос к поиску. Изменяемая строка — заголовок, выбранная
chat-модель и updated_at правятся по мере беседы.
| id | BigInteger | PK | — |
| user_id | BigInteger | FK→usersIDX | владелец сессии · CASCADE · его identity — вход retrieval |
| surface | Text | NOT NULLCHECK | откуда беседа · web · slack · telegram · mattermost · mcp · extension |
| title | Text | NULL | заголовок · автогенерится из первого сообщения, правится |
| selected_model | Text | NULL | липкий выбор chat-модели · валидируется по allow-list Admin · NULL = модель по умолчанию |
| meta | JSONB | NULL | гибкие атрибуты сессии · контекст surface'а · у Slack — ключ треда (team, channel, thread_ts), у Telegram — chat_id, у Mattermost — (channel_id, root_id) |
| created_at | DateTime(tz) | DEFAULT | now() |
| updated_at | DateTime(tz) | DEFAULT | now() + trigger · правка заголовка и нового сообщения |
title доводится, updated_at
двигает каждое новое сообщение — отсюда оба timestamp и триггер
set_updated_at().
selected_model), а не разовым выбором на
клиенте. Это намерение «чем генерировать
дальше», и его нельзя путать с messages.model: тот
фиксирует факт «чем сгенерён конкретный ответ».
Тот же выбор прилипает и к человеку
(users.last_chat_model),
заводя каждую новую беседу впереди дефолта админа — так
вернувшийся сотрудник попадает на модель, что выбрал последней, а
не на сброс. Резолв идёт от самого близкого к общему:
выбор этого хода → selected_model беседы
→ last_chat_model человека → дефолт админа;
устаревшее значение (выбыло из allow-list) тихо падает на
следующий слой. Поэтому пустой selected_model — уже
не «дефолт админа», а «наследует личный дефолт». Эта липкая пара
принадлежит surface'ам, где есть пикер (Web); мессенджеры
(Slack/Telegram/Mattermost) и машинные surface'ы не несут ни слоя
и всегда идут на дефолте админа. Смена модели правит строку —
отсюда движение updated_at.
messages
Append-only лента сообщений сессии: запрос пользователя и ответ ассистента
приходят строками по порядку, прошлые не переписываются. На
assistant-сообщении фиксируется, какая chat-модель его сгенерировала и
сколько токенов потрачено (tokens_used — весь оборот реплики,
вход + выход; расход чата по людям), и сюда же копится feedback —
оценка ответа большим пальцем, сырьё для замера качества.
| id | BigInteger | PK | — |
| conversation_id | BigInteger | FK→conversationsIDX | сессия · CASCADE · история по ней |
| role | Text | NOT NULLCHECK | user · assistant |
| content | Text | NOT NULL | текст реплики |
| model | Text | NULL | чем сгенерён ответ · факт, не намерение (ср. conversations.selected_model) · только assistant |
| tokens_used | BigInteger | NULL | токены реплики · весь оборот вход + выход (контекст RAG + ответ), не только выход · ставится при финализации · сырьё расхода чата по людям (Расходы AI) · только assistant |
| feedback | SmallInteger | NULLCHECK | thumbs ±1 на assistant-сообщение · правится постфактум · сырьё оценки качества |
| created_at | DateTime(tz) | DEFAULT | now() |
| updated_at | DateTime(tz) | DEFAULT | now() + trigger · только под правку feedback |
feedback правится — отсюда оба timestamp
created_at.
Но feedback — исключение: палец вверх/вниз
пользователь ставит и меняет после того, как
сообщение уже записано, а это in-place UPDATE строки.
Раз строка получает UPDATE хотя бы по одному полю,
по конвенции проекта ей нужны оба timestamp и
триггер set_updated_at() — updated_at
честно отмечает момент оценки. Тело сообщения при этом остаётся
неизменным: ленту мы не редактируем, только аннотируем оценкой.
retrieval_trace
На каждый grounding-ответ — когда модель позвала
поиск — иммутабельный снимок того, что вернул retrieval:
самостоятельный запрос, отданный инструменту, кандидаты с их скорами и
цитаты, реально попавшие в ответ. Цитата держит маркер
([1], [2]…), запись-источник и победивший
фрагмент-чанк — этого хватает, чтобы перерисовать историю с теми же
ссылками, не переспрашивая retrieval. Разговорный ответ снимка не
имеет — искать было нечего; снимок ровно один на
сообщение (UNIQUE на message_id), связь 0..1.
Снимок — ответная основа
сигнала
обращений: из citations копится частота в
access_counter.
Записывается один раз и не правится.
| id | BigInteger | PK | — |
| message_id | BigInteger | FK→messagesIDX | assistant-сообщение, к которому снимок · CASCADE · UNIQUE → ровно один снимок, вставка идемпотентна |
| search_query | Text | NOT NULL | самостоятельный запрос, отданный инструменту · вход в retrieval |
| candidates | JSONB | NULLREF | id сущностей KS + скоры · ССЫЛКИ в KS, не копии данных |
| citations | JSONB | NULLREF | цитаты ответа · маркер → запись (entity_id) + победивший фрагмент (chunk_id) + скор, в порядке [1][2] · основа реплея и сигнала обращений |
| created_at | DateTime(tz) | DEFAULT | now() · только · снимок не правится |
created_at
created_at,
updated_at был бы бессмысленным. candidates
и citations держат id сущностей и чанков
Knowledge Store, не их тексты: тело знания живёт в KS,
здесь — лишь ссылка на него и скор. Цитата фиксирует и запись, и
победивший чанк именно затем, чтобы историю можно было
перерисовать с теми же ссылками, не переспрашивая retrieval. Из
citations retrieval катит частоту в
access_counter
(по их entity_id) — механику держит
сигнал обращений.
access_counter
Агрегат частоты обращений к сущностям: сколько раз сущность реально вошла в ответ или по её цитате кликнули — и когда последний. Растёт upsert'ом из двух источников: citations (вошедшее в ответ) на каждом ходе и клик по цитате в выдаче; Knowledge Store читает его JOIN'ом при пересчёте staleness — спрос на сущность поднимает её приоритет доводки. Изменяемый счётчик — отсюда оба timestamp.
| id | BigInteger | PK | — |
| entity_ref | BigInteger | NOT NULLREF | id сущности KS · ССЫЛКА, не FK через границу модуля · ключ upsert · индекс несёт UNIQUE |
| hits | BigInteger | NOT NULLDEFAULT | вошла в ответ или клик по цитате · инкремент upsert'ом |
| last_accessed_at | DateTime(tz) | NOT NULL | когда обратились последний раз · вход staleness |
| created_at | DateTime(tz) | DEFAULT | now() · первое обращение к сущности |
| updated_at | DateTime(tz) | DEFAULT | now() + trigger · каждый инкремент |
entity_ref — hits++ и
last_accessed_at = now(). Это in-place
UPDATE в чистом виде, поэтому строке нужны
оба timestamp с триггером. Счётчик — агрегат
спроса: ответную долю можно пересобрать из снимков
retrieval_trace
(по citations), а клик по цитате приходит
отдельным живым событием и в снимок не пишется — потому держим
агрегат как источник истины, а не только кэш над лентой;
staleness читает его JOIN'ом без скана. Механику —
что считаем обращением и как сигнал течёт в KS — держит
сигнал обращений.
Query Engine не хранит знаний. Id сущностей и чанков в
retrieval_trace (candidates,
citations) и id сущностей в access_counter
(entity_ref) — это ссылки в
модель данных Knowledge Store, а не копии тел сущностей. Тело знания, его текст и права — там;
здесь — диалог, что нашлось на сообщение, и спрос на сущность.
Держит диалог (conversations, messages),
снимок retrieval на сообщение и счётчик обращений. Из найденного —
только id и скор: ссылка на запись и победивший
чанк KS, не их тело. Сигнал спроса (access_counter) KS читает
JOIN'ом при пересчёте staleness.
Source of truth сущностей: тело, фрагменты с векторами, граф, права. По id из trace восстанавливает запись для цитирования. Принимает сигнал обращений и поднимает по нему приоритет доводки — знание остаётся целиком на стороне KS.
Граница: «знание → Knowledge Store, диалог и сигнал обращений → Query
Engine». Цитирование тянет запись по entity_id из
entities, а фрагмент-доказательство по chunk_id — из
chunks.