← Query Engine

Модель данных

query-engine · workzone

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 правятся по мере беседы.

1 Сессия Беседа на одном surface — владеет проходом.
conversations сессии диалога
FK → users
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 · правка заголовка и нового сообщения
Сессия едина для всех surface'ов
Одна модель диалога на платформу: surface (web · slack · telegram · mattermost · mcp · расширение) — лишь поле, а не отдельная таблица на каждый клиент. Тонкие клиенты пишут в общий контракт, поэтому история и перенос контекста ведут себя одинаково всюду. Беседа изменяема: title доводится, updated_at двигает каждое новое сообщение — отсюда оба timestamp и триггер set_updated_at().
Выбор модели липкий — два слоя: беседа, затем человек
Сотрудник выбирает chat-модель из allow-list, который собирает Admin; выбор прилипает к беседе — держится для следующих ответов и переживает перезагрузку, поэтому живёт строкой на сессии (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 — оценка ответа большим пальцем, сырьё для замера качества.

2 Сообщение Реплика беседы — запрос или ответ.
messages сообщения диалога
FK → conversations append-only
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
Лента append-only, но feedback правится — отсюда оба timestamp
Само сообщение иммутабельно: текст реплики и роль не переписываются — по правилу журнала хватило бы одного created_at. Но feedback — исключение: палец вверх/вниз пользователь ставит и меняет после того, как сообщение уже записано, а это in-place UPDATE строки. Раз строка получает UPDATE хотя бы по одному полю, по конвенции проекта ей нужны оба timestamp и триггер set_updated_at()updated_at честно отмечает момент оценки. Тело сообщения при этом остаётся неизменным: ленту мы не редактируем, только аннотируем оценкой.
Снимок retrieval: retrieval_trace

На каждый grounding-ответ — когда модель позвала поиск — иммутабельный снимок того, что вернул retrieval: самостоятельный запрос, отданный инструменту, кандидаты с их скорами и цитаты, реально попавшие в ответ. Цитата держит маркер ([1], [2]…), запись-источник и победивший фрагмент-чанк — этого хватает, чтобы перерисовать историю с теми же ссылками, не переспрашивая retrieval. Разговорный ответ снимка не имеет — искать было нечего; снимок ровно один на сообщение (UNIQUE на message_id), связь 0..1. Снимок — ответная основа сигнала обращений: из citations копится частота в access_counter. Записывается один раз и не правится.

3 Снимок Что нашёл retrieval на этот ответ.
retrieval_trace снимок retrieval на сообщение
FK → messages UNIQUE (message_id) immutable
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
Trace — фотография момента retrieval: что переписали, что нашли, что взяли. После записи он не меняется — append-only журнал, по конвенции проекта ему хватает одного 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.

4 Счётчик Частота обращений к сущности — сигнал в KS.
access_counter агрегат обращений
UNIQUE (entity_ref) upsert · counter
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 · каждый инкремент
Счётчик-агрегат · upsert по сущности — отсюда оба timestamp
Одна строка на сущность: повторное обращение делает upsert по entity_refhits++ и last_accessed_at = now(). Это in-place UPDATE в чистом виде, поэтому строке нужны оба timestamp с триггером. Счётчик — агрегат спроса: ответную долю можно пересобрать из снимков retrieval_trace (по citations), а клик по цитате приходит отдельным живым событием и в снимок не пишется — потому держим агрегат как источник истины, а не только кэш над лентой; staleness читает его JOIN'ом без скана. Механику — что считаем обращением и как сигнал течёт в KS — держит сигнал обращений.
Граница с Knowledge Store: ссылки, не копии

Query Engine не хранит знаний. Id сущностей и чанков в retrieval_trace (candidates, citations) и id сущностей в access_counter (entity_ref) — это ссылки в модель данных Knowledge Store, а не копии тел сущностей. Тело знания, его текст и права — там; здесь — диалог, что нашлось на сообщение, и спрос на сущность.

Query Engine ссылается

Держит диалог (conversations, messages), снимок retrieval на сообщение и счётчик обращений. Из найденного — только id и скор: ссылка на запись и победивший чанк KS, не их тело. Сигнал спроса (access_counter) KS читает JOIN'ом при пересчёте staleness.

Knowledge Store хранит знание

Source of truth сущностей: тело, фрагменты с векторами, граф, права. По id из trace восстанавливает запись для цитирования. Принимает сигнал обращений и поднимает по нему приоритет доводки — знание остаётся целиком на стороне KS.

Граница: «знание → Knowledge Store, диалог и сигнал обращений → Query Engine». Цитирование тянет запись по entity_id из entities, а фрагмент-доказательство по chunk_id — из chunks.