supabase

Supabase база даних

src/database/database.module.ts · src/database/supabase.service.ts · supabase/migrations/*

Цей документ описує механіку бази і пам’яті: що таке vector DB, які є таблиці, які поля зберігаються, які числові ліміти діють для chunks, embeddings, snapshots і пошуку.

Роль Supabase у stack-ai

Supabase використовується як Postgres + pgvector storage для трьох різних задач:

ШарТаблиця/RPCДля чого
Vector memoryclient_memory_chunks + RPC match_client_memory_chunksДовга пам’ять діалогу RU>TU: profile/photo/notes/messages у вигляді chunks з embedding
Daily snapshotstips_daily_entity_snapshotsДенний cache/audit raw/normalized даних, які прийшли з upstream: profiles, photos, notes, dialog messages, marker daily sync
Token usagetoken_usage_logОблік LLM-викликів по request/dialog/provider/model/phase/tokens

Legend зараз не пише результати у Supabase. Legend jobs/results живуть у process memory LegendJobStore, див. legend-service.

Що таке vector DB у цьому сервісі

Vector DB тут не означає окрему базу даних. Це звичайний Postgres у Supabase з extension pgvector.

Звичайний пошук працює по точному тексту: text contains "travel". Векторний пошук працює інакше:

  1. Текст перетворюється у числовий вектор, тобто embedding.
  2. Схожі за змістом тексти отримують близькі вектори.
  3. Коли приходить новий запит, він теж перетворюється у embedding.
  4. База шукає chunks, embedding яких найближчий до embedding запиту.

Для stack-ai це потрібно, щоб знаходити не тільки точні слова, а релевантний контекст діалогу:

  • нотатка може містити “he worries about trust”;
  • новий query може бути про “serious relationship”;
  • exact text match може не спрацювати;
  • semantic search все одно може підняти цю note, бо сенс близький.

Базова логіка даних

У сервісі є два типи збереження даних.

1. Long-term memory

client_memory_chunks зберігає vector memory:

ДаніЯк пишуться
ProfileДо 10 profile lines, 1 row на кожну непорожню line
Photo1 row для main photo URL, якщо URL є
NoteText обрізається до 800 символів, потім ріжеться на chunks до 420 символів
MessageBody обрізається до 420 символів; у memory беруться останні 80 meaningful messages

Кожен row отримує embedding vector(768) і може бути знайдений через semantic search.

2. Daily audit/cache

tips_daily_entity_snapshots зберігає snapshots на 14 днів. Це не vector memory: там немає embedding і semantic search.

ДаніЯк пишуться
Profile snapshotentity_type='profile', source_role='ru' або source_role='tu'
Photo snapshotentity_type='photo', payload { url }
Note snapshotentity_type='note', payload { date, text, timestamp }
Dialog snapshotentity_type='dialog', payload { date, initiator, text }
Daily markerentity_type='daily_sync', source_id='daily-sync'

Ця таблиця потрібна для cache/audit і hash-порівняння payload, а не для prompt retrieval.

TODO: для фото потрібно додати повноцінний текстовий шар. Зараз система може зберігати URL фото або snapshot payload, але має зберігати результат vision-аналізу як client_memory_chunks.source='photo'. Деталі: photo-analysis і photo-analysis-ingest.

Ключові числа

ПараметрЗначенняДе використовується
Embedding dimensions768client_memory_chunks.embedding vector(768)
Embedding modelgemini-embedding-001ingest/search
Embedding batch size32batch document embeddings при ingest
Note input limit800 charsnormalizeNote()
Chunk split size420 charssplitLongText() для notes
Message body limit420 charsnormalizeMessage()
Messages per ingest80 latestconversation.messages.slice(-80)
Search API default limit12 chunksMemoryService.search()
Search API max limit24 chunksclamp у TypeScript
RPC hard max limit50 chunksclamp у SQL
Snapshot retention14 daysexpires_at default now() + interval '14 days'
Local fallback vector128 dimshashed lexical fallback у payload-utils

Як дані потрапляють у prompt

Скорочена схема:

upstream data
  -> normalize
  -> chunks
  -> embeddings
  -> client_memory_chunks
  -> query embedding
  -> match_client_memory_chunks
  -> memoryContext.selectedChunks
  -> AssistantService prompt

У prompt не потрапляє вся база. У prompt потрапляють тільки top-N selected chunks, які RPC знайшла як найбільш релевантні для поточного query. Зараз у tips-flow limit = 12.

Формат selected chunks після retrieval:

Selected vector memory chunks:
1. [note; score=0.91; 2026-05-20] ...
2. [conversation; score=0.86; 2026-05-21T...] ...
3. [profile; score=0.82] ...

Саме тому важливо, які chunks записані і як вони ранжуються: це напряму впливає на те, що LLM буде вважати важливим контекстом.

Підключення

Підключення створюється у DatabaseModule, який позначений @Global().

Env

URL:

  • BESCO_SUPABASE_URL;
  • fallback SUPABASE_URL.

Key, перший знайдений:

  • BESCO_SUPABASE_SERVICE_ROLE_KEY;
  • SUPABASE_SERVICE_ROLE_KEY;
  • BESCO_SUPABASE_PUBLISHABLE_KEY;
  • SUPABASE_PUBLISHABLE_KEY;
  • SUPABASE_ANON_KEY.

Якщо URL або key відсутні, backend падає на старті. Це fail-fast: без Supabase сервіс не повинен підніматися.

Клієнт створюється через createClient(url, key) і далі використовується сервісами для .from(...), .rpc(...), .upsert(...), .select(...).

Таблиця client_memory_chunks

Це основна таблиця довгої пам’яті. Вона зберігає атомарні chunks з semantic embedding. Пошук по ній іде через pgvector RPC match_client_memory_chunks.

Повної базової create-table міграції в репозиторії зараз немає. Поточні міграції очікують, що таблиця вже існує:

  • 20260519_gemini_memory_embeddings.sql:
    • вмикає extension vector;
    • робить truncate table public.client_memory_chunks;
    • пересоздає embedding vector(768) not null;
    • створює HNSW index;
    • створює RPC match_client_memory_chunks.
  • 20260520_client_memory_photo_source.sql:
    • додає photo у check constraint source;
    • пересоздає RPC з boost для photo.

Поля row

MemoryService.toMemoryRow() формує row так:

ПолеЗначення
dialog_keysha256 ключ пари RU>TU
content_hashsha256 ідемпотентності chunk
client_idRU id (manId_api/client/man у legacy назвах) або null
woman_idTU id (ladyId_api/анкета/леді у legacy назвах) або null; це технічна назва поля, не окрема доменна сутність “woman”
favorite_idfavorite id або dialog/cache fallback
sourceprofile, photo, note, conversation, summary
source_idid джерела: note timestamp/id, message id, profile:N, photo URL
textочищений текст chunk без службового incoming:/outgoing: prefix
directionincoming, outgoing, null
compose_modereply, both
embeddingpgvector literal [number,...]
embedding_modelgemini-embedding-001:768
languageзараз null
note_dateдата note
sent_atдата message
tagssource tag + regex tags
metadata{ originalChunkId, scoreAtIngestion }

Dialog key

У коді є два алгоритми:

МісцеФормулаКоментар
Новий tips-flowsha256("dialog:<ruId>:<tuId>")Використовує MemoryService.getDialogKey({ ruId, tuId })
Generic memory API fallbacksha256(["dialog", womanId, clientId].join(":"))womanId = TU, clientId = RU

Якщо request уже містить dialogKey, використовується він. Для неоднозначних payload краще передавати dialogKey явно, щоб не отримати інший порядок id.

Idempotency

content_hash рахується від стабільної комбінації:

sha256(dialogKey + source + sourceId + text)

Запис іде через upsert:

supabase.from('client_memory_chunks').upsert(rows, {
  onConflict: 'dialog_key,content_hash',
})

Один і той самий chunk при повторному ingest не створює дубль, а оновлює існуючий row.

Sources і ліміти

SourceRow count / limitЯк формується
profile0..10 rowsprofileMemoryLines() створює окремий row для кожної непорожньої line: Name, Age, Location, Occupation, Body type, Marital status, Personality type, Goals, Hobbies, Traits
photo0..1 row заразЯкщо є profile.photoUrl, пишеться Main profile photo is available: <url>. TODO: замінити/доповнити це vision summary з photo-analysis
note0..N rowsnormalizeNote() бере text/note/summary, обрізає до 800 chars, splitLongText() ріже на chunks до 420 chars
conversation0..80 rowsnormalizeMessage() бере body + attachment labels, обрізає до 420 chars, відкидає system messages, buildMemoryChunks() бере останні 80 messages
summary0 rows заразSource дозволений типами/схемою, але поточний ingest його не створює

Tags

Код завжди додає source tag, плюс regex tags:

TagRegex terms
sexualsex, sexual, horny, kiss, naked, cock, pussy, cum, orgasm, fantasy
trustwife, married, trust, honest, jealous, serious, relationship
emotionalmiss, love, feel, lonely, care, warm, soft, heart

Tags зараз тільки metadata. RPC по tags не фільтрує.

Як chunks створюються перед записом

MemoryService.ingest() приймає conversationJson, profileJson, photoUrl, ids і optional dialogKey.

  1. prepareRequest() нормалізує ids, dialogKey, composeMode, JSON payload.
  2. prepareInputs() витягує profile, notes, messages, photo URL.
  3. buildMemoryChunks() створює chunks:
    • profile lines source='profile';
    • main photo URL source='photo';
    • notes source='note';
    • останні 80 messages source='conversation'.
  4. Для кожного chunk рахується content_hash.
  5. Для кожного chunk запитується Gemini embedding.
  6. Rows upsert-яться в client_memory_chunks.

Raw snapshots з tips_daily_entity_snapshots не йдуть напряму у vector search. Для пошуку дані мають бути записані в client_memory_chunks з embedding.

Embeddings

Для запису використовується Gemini:

ПараметрЗначення
Env keyBESCO_GEMINI_API_KEY
Modelgemini-embedding-001
Dimensions768
Document taskRETRIEVAL_DOCUMENT
Batch size32
DB typevector(768)

Якщо BESCO_GEMINI_API_KEY відсутній, ingest/search падає. Якщо Gemini повернув неправильну кількість vectors або не 768 dimensions, ingest падає з BadGatewayException.

Що повертає ingest

Якщо chunks створені:

{
  favoriteId,
  dialogKey,
  inserted: rows.length,
  sources: {
    profile: 5,
    note: 8,
    conversation: 18,
    photo: 1
  },
  totalChunks: rows.length
}

Якщо chunks не створились:

{
  favoriteId,
  inserted: 0,
  message: 'No memory chunks were produced from the payload.',
  totalChunks: 0
}

RPC match_client_memory_chunks

Це SQL RPC для semantic search по client_memory_chunks.

Вхідні параметри

MemoryService.search() викликає:

supabase.rpc('match_client_memory_chunks', {
  match_client_id: parsed.clientId,
  match_compose_mode: parsed.composeMode === 'both' ? 'reply' : parsed.composeMode,
  match_count: clampInteger(request.limit, 12, 1, 24),
  match_dialog_key: parsed.dialogKey,
  query_embedding: '[...]',
  source_weights: {
    note: 0.18,
    photo: 0.05,
    profile: 0.08,
  },
})

query_embedding рахується через Gemini gemini-embedding-001, dimensions 768, task type RETRIEVAL_QUERY.

SQL-механіка пошуку

RPC робить:

  1. Фільтр по dialog_key: тільки chunks конкретної пари RU>TU.
  2. Фільтр по client_id:
    • якщо match_client_id is null, фільтр не застосовується;
    • якщо row client_id is null, row допускається;
    • інакше chunk.client_id = match_client_id.
  3. Фільтр по compose_mode:
    • якщо match_compose_mode is null, row допускається;
    • якщо chunk.compose_mode = 'both', row допускається;
    • інакше chunk.compose_mode = match_compose_mode.
  4. Cosine similarity:
1 - (chunk.embedding <=> query_embedding)
  1. Додає source boost для типів, які бізнесово важливіші за звичайний chat.
  2. Рахує weighted_score.
  3. Сортує order by weighted_score desc.
  4. Обмежує кількість: greatest(1, least(coalesce(match_count, 12), 50)).

Чому такі boosts

Базовий score - це cosine similarity. Boost не замінює semantic similarity, а лише трохи піднімає типи даних, які зазвичай корисніші для відповіді.

SourceBoostЧому
note+0.18Notes - це curated контекст від оператора або AI. Там часто є найважливіші факти: що RU любить, чого уникати, які були домовленості
profile+0.08Profile потрібен для стабільних фактів, але не має перебивати актуальні notes
photo+0.05Photo URL або майбутній vision summary може бути корисним контекстом, але не має перебивати notes

conversation не має бізнесового boost. Це baseline: якщо conversation semantic-схожий на query, він і так підніметься за cosine similarity. Додавати окремий boost для кожної звичайної репліки небезпечно, бо короткі chat messages можуть забити notes/profile просто кількістю. У SQL це технічно означає fallback 0, тобто “без додаткового підняття”.

Що повертає RPC

ПолеДля чого
idid chunk
sourceтип джерела
textтекст chunk
weighted_scorecosine similarity + source boost
note_dateдата note, якщо є
sent_atдата message, якщо є
directionincoming/outgoing, якщо є

RPC не повертає embedding, tags, metadata, favorite_id, woman_id.

POST /stack-ai/tips/memory/search обгортає RPC результат:

{
  favoriteId,
  dialogKey,
  query,
  results: [
    {
      id,
      source,
      text,
      weighted_score,
      note_date,
      sent_at,
      direction
    }
  ]
}

До Supabase enrichment prepareInputs() будує локальний memoryContext на 128-мірних hashed lexical vectors. Якщо Supabase search падає, enrichPreparedMemory() логить warning і залишає цей fallback. Це не pgvector і не semantic search.

Таблиця tips_daily_entity_snapshots

Це не vector table. Вона зберігає daily cache/audit того, що було завантажено з upstream.

Міграція: 20260520_tips_daily_entity_snapshots.sql.

Поля

ПолеЗначення
iduuid primary key
sync_dateдата синку, default current_date
dialog_keysha256 ключ пари RU>TU
familyupstream family
ru_idRU/man/client id
tu_idTU/lady/profile id
entity_typedaily_sync, dialog, note, photo, profile
source_roleru, tu, або порожній рядок
source_idid джерела всередині entity type
entity_attimestamp події, якщо є
initiatorхто ініціював dialog message
textкороткий текст для перегляду
payloadraw або normalized JSON
content_hashsha256 від відсортованого payload
created_atinsert time
updated_atupdate time
expires_atdefault now + 14 days

Індекси

Unique:

(dialog_key, sync_date, entity_type, source_role, source_id)

Lookup:

(dialog_key, entity_type, source_role, sync_date desc)

Expiry:

(expires_at)

Код зараз не видаляє expired rows. Індекс є, cleanup job у сервісах не видно.

Entity types

entity_typesource_rolesource_idpayloadtext
profileru або turu або tunormalized profileне обов’язково
photoru або turole{ url } зараз; TODO: додати payload vision-аналізуphoto URL / майбутній photo analysis
note''timestamp/date/index{ date, text, timestamp }note text
dialog''date/index:initiator{ date, initiator, text }message content
daily_sync''daily-sync{ completedAt }не обов’язково

Upsert

Snapshots пишуться через:

supabase.from('tips_daily_entity_snapshots').upsert(rows, {
  onConflict: 'dialog_key,sync_date,entity_type,source_role,source_id',
})

Помилка upsert snapshots не валить основний процес. Код пише warning і продовжує.

Hash

content_hash рахується як sha256 від JSON з відсортованими ключами:

createHash('sha256').update(JSON.stringify(sortJson(value))).digest('hex')

Це дозволяє порівнювати payload незалежно від порядку ключів.

Таблиця token_usage_log

Міграція: 20260520_token_usage_log.sql.

Поля

ПолеЗначення
iduuid primary key
attimestamp call
request_idUUID одного backend request
dialog_keydialog hash
ru_idRU id
tu_idTU id
invite_typetype з request
providergemini або xai
modelмодель
phasephoto, context, draft-batch, evaluate, repair, embed, generate
prompt_tokensprompt/input tokens
completion_tokenscompletion/output tokens
total_tokenstotal

Індекси:

  • (dialog_key, at desc);
  • (request_id).

Запис

TokenUsageService.trackWorkflowUsage() отримує workflow.tokenUsage з AssistantService.

Якщо entries є, кожен entry пишеться окремою row.

Якщо entries немає, сервіс створює fallback estimate:

  • phase='generate';
  • provider='gemini';
  • model='unknown';
  • tokens рахуються через estimateTokens().

Insert error не валить генерацію. Помилка логиться як token_usage_log insert skipped: <message>.

Читання

TokenUsageService.getDialogUsage(dialogKey, since?):

  1. Бере local in-memory entries.
  2. Читає token_usage_log по dialog_key.
  3. Якщо since переданий, додає .gte('at', sinceIso).
  4. Сумує calls, prompt, completion, total.

Публічного route для цього методу зараз не видно.

Health/status операції

GET /stack-ai/tips/supabase/health

SupabaseService.checkHealth():

  1. HTTP GET <url>/auth/v1/health з apikey.
  2. Head probe:
supabase
  .from('client_memory_chunks')
  .select('id', { count: 'exact', head: true })

ok=true тільки якщо Auth health ok і client_memory_chunks доступна.

GET /stack-ai/tips/memory/status

MemoryService.status() робить head count client_memory_chunks і повертає:

  • ready;
  • table: 'client_memory_chunks';
  • totalChunks;
  • при помилці: error, needsMigration.

POST /stack-ai/tips/status

MemoryService.getDialogStatus({ ruId, tuId }) читає:

supabase
  .from('client_memory_chunks')
  .select('source, note_date, sent_at, updated_at')
  .eq('dialog_key', dialogKey)

Повертає counts і latest dates:

  • lastNoteAt = max note_date для source='note';
  • lastMessageAt = max sent_at для source='conversation';
  • profileUpdatedAt = max updated_at для source='profile';
  • photoUpdatedAt = max updated_at для source='photo';
  • counts по profile, note, conversation, photo, summary.

Що база не робить

  • Не генерує drafts.
  • Не тримає бізнес-логіку tips pipeline.
  • Не зберігає Legend pipeline/result.
  • Не зберігає mood з TipsMemoryService.dialogMoodScores; це process Map.
  • Не зберігає generated drafts в окрему таблицю.
  • Не фільтрує retrieval по tags.
  • Не повертає embeddings назовні.
  • Не чистить tips_daily_entity_snapshots.expires_at у коді.
  • Не зберігає повний vision-аналіз фото в client_memory_chunks зараз; це описано як TODO у photo-analysis-ingest.

Поточні ризики

  • Немає базової create-table міграції client_memory_chunks.
  • 20260519_gemini_memory_embeddings.sql робить truncate client_memory_chunks.
  • Є два порядки dialog key: new tips ruId:tuId і generic memory womanId:clientId, де womanId = TU, clientId = RU.
  • tips_daily_entity_snapshots має expiry index, але cleanup job не описаний у коді.
  • token_usage_log insert errors не валять request, тому usage може бути неповним.
  • HNSW index створений без tuning параметрів у міграції.

Пов’язані документи