CORTEX — Arquitetura de RAG e Ingestão Vetorial
Versão: 1.1.1 · Data: 2026-06-18 · Status: Rascunho (em revisão) · Owner: Plataforma Caminho canônico:
cortex-docs/architecture/RAG_AND_INGESTION.mdDocumentos irmãos:cortex-docs/ECOSYSTEM.md(fronteiras),cortex-docs/standards/UNISCALE_ENGINEERING_STANDARDS.md(padrões técnicos)
Este documento desenha o subsistema de busca semântica (RAG) e a ingestão vetorial das IAs especializadas do CORTEX: como o conhecimento vira vetor, como Postgres e Qdrant ficam consistentes, e como dependências externas lentas saem do caminho crítico. Não repete fronteiras (ECOSYSTEM) nem convenções de nomenclatura/tipo (UNISCALE) — referencia ambos. Tudo aqui é rascunho pendente de revisão humana; nada vira migration ou produção sem revisão da equipe.
1. Princípios travados
- Embedding não se converte entre modelos. Trocar de modelo = reembeddar a partir do texto. Por isso o texto canônico é o que importa.
- Postgres é a fonte da verdade (texto canônico do chunk + metadados). Qdrant é reconstruível (vetor + ponteiro + campos filtráveis). Em divergência, Postgres vence.
embedding-serviceé casca única sobre o modelo (hoje BGE-M3, 1024d). Recebetexto + collection-alvo, resolvecollection → modelo → dime devolve vetor. O mesmo serviço atende ingestão e query — é o que garante o invariante (query e índice no mesmo espaço). É enforcer do invariante, não roteador de destino: não escreve no Qdrant, não busca, não decide para onde o dado vai.- Embedding é desacoplado da geração. O modelo de embedding (BGE-M3, tem dimensão, alimenta o Qdrant) nunca se mistura com o LLM de geração (GLM via OpenRouter, texto entra/sai). A LLM tem embeddings internos próprios e nunca toca os vetores do RAG.
- BGE-M3 (1024d) é o padrão em todas as IAs. Trocar só por collection específica, se o recall exigir — decisão isolada, atrás do
embedding-service. - Quem busca é a IA especializada; quem escreve no Qdrant é o worker de ingestão do domínio. O
embedding-servicesó faztexto → vetor.
1.1. Donos do diálogo — três planos (fronteira)
O diálogo com o usuário se divide em três planos distintos, sem duplicação de dono:
messaging-service— gerência de entrega. Decide como/quando/onde a mensagem chega ao canal (template, janela de 24h, opt-in, status de entrega, roteamento por canal). É gerenciador, não dono do conteúdo — indiferente ao que está escrito. A API/webhook da Meta (WhatsApp) só move texto/template; não conhece chunk nem vetor. (Status: omessaging-serviceé recomendado, não adotado na fundação atual — ECOSYSTEM §4. Aqui, "messaging-service" é a camada de entrega, que por decisão atual é o connector de canal; o modelo de três planos do diálogo abaixo não muda.)ai-orchestrator— dono do diálogo completo. Mantém o transcript inteiro da conversa com o usuário, atravessando todas as especializadas (orchestrator_db, contexto geral, sem banco vetorial). Sem regra de negócio além de rotear (ECOSYSTEM §4–§5).- IA especializada — dona da sua fatia + estado de negócio. Guarda só o pedaço do diálogo que a orquestradora lhe entregou (e o que respondeu) e o estado da sua tarefa (ex.: vendeu/não, oferta, objeção, quando, para quem). É a dona do RAG (Postgres de conhecimento + Qdrant) — o foco deste documento.
São planos distintos: messaging = entrega; orquestradora = diálogo; especializada = sua fatia + conhecimento/tarefa. O vetor de cada IA é alimentado só com o que ela precisa para dialogar e montar a resposta. As tabelas de diálogo (transcript na orquestradora, fatia + estado na especializada) não fazem parte do modelo de conhecimento abaixo e têm schema a desenhar (§7).
2. Topologia
WhatsApp ──► whatsapp-connector ──► messaging-service ──► ai-orchestrator ──► IA especializada
(entrega) (roteia+diálogo) (ai-sales, ai-secretary, …)
│
┌────────────────────────────────────────┤
▼ ▼
Postgres local embedding-service
(documents, chunks, outbox) (casca BGE-M3, texto→vetor)
│ │
│ outbox (mesma transação) │ vetor
▼ ▼
<domain>-ingestion-worker ───── upsert/delete ──► Qdrant local
(relê estado, idempotente) (vetor + ponteiro)
- Cada IA especializada tem Postgres próprio + Qdrant próprio, isolados por domínio (UNISCALE §21).
- O
embedding-serviceé compartilhado (FastAPI), sem banco (UNISCALE §93 / ECOSYSTEM). O mapacollection → modelo → dimé configuração de plataforma, não banco — hoje hardcoded em BGE-M3/1024d. Determinismo do vetor é propriedade da função do modelo (mesmo texto + mesmo modelo+versão → mesmo vetor); não exige estado persistido. - O worker de ingestão é por domínio (UNISCALE §7:
<domain>-ingestion-worker, ex.:ai-sales-ingestion-worker). É o único que escreve no Qdrant daquele domínio.
3. Modelo de dados (Postgres por domínio)
Esquema de referência que toda IA especializada implementa no seu banco. Schema = domínio (ai_sales, ai_secretary, …). Tipos, colunas-base, autoria, soft delete e nomes de constraint seguem UNISCALE §22–§24. Registro a registro, imutável; nunca blob mutável.
3.1. documents — fonte de conhecimento (pai do chunk)
Unidade de conhecimento ingerida (config do institutional distribuída à IA, FAQ, política). É partida em chunks.
| Coluna | Tipo | Regra |
|---|---|---|
id |
uuid (v7) |
PK. UNISCALE §23.1. |
tenant_id |
uuid |
§23.3. |
title |
varchar(255) |
Nome curto (§22). |
status |
varchar + check |
active, archived. Vocabulário fechado (§23.4). |
metadata |
jsonb |
Extensão sem migração (§23.4). |
created_by / updated_by |
uuid |
Conta de serviço do worker (§23.6) — rastreia a origem da gravação. |
created_at / updated_at / deleted_at |
timestamptz |
Auditoria + soft delete de negócio (§23.1–§23.2). |
Constraints/índices: pk_documents, ix_documents__tenant_id.
3.2. chunks — texto canônico vetorizável
O id do chunk é o point_id no Qdrant (UNISCALE §26.3) — mesmo registro entre Postgres e collection segue o mesmo formato (UUIDv7). É o que torna o upsert idempotente.
| Coluna | Tipo | Regra |
|---|---|---|
id |
uuid (v7) |
PK. É o point_id no Qdrant (§26.3). |
tenant_id |
uuid |
§23.3. |
document_id |
uuid |
FK → documents(id) (§24.3). |
seq |
integer |
Ordem do chunk no documento. Ordering, não identidade. |
content |
text |
Texto canônico — fonte da verdade. |
content_hash |
char(64) |
SHA-256 hex do content. Dirige a reindexação incremental (diff). |
embedding_model |
varchar(100) |
Modelo que deve embeddar (bge-m3). Denormalizado para decisão de reindex. |
model_version |
varchar(20) |
Versão do modelo. |
embedding_dimension |
integer |
1024. |
created_by / updated_by |
uuid |
Conta de serviço do worker (§23.6). |
created_at / updated_at / deleted_at |
timestamptz |
Auditoria + soft delete (tombstone) — dispara o delete do point (§5.3). |
Constraints/índices:
pk_chunks;fk_chunks__documents(comix_chunks__document_id, §24.4).ux_chunks__document_id__seq— unique parcialWHERE deleted_at IS NULL(§24.4).ix_chunks__tenant_id;ix_chunks__content_hash(diff de reindexação).
content_hashusa hex de tamanho fixo (char(64)). Hash não está no catálogo de tipos (UNISCALE §22) — candidato a entrar como tipo canônico numa próxima revisão da UNISCALE.
3.3. Diálogo — fora deste modelo (aposenta messages)
A tabela messages da versão anterior foi aposentada: o nome colidia com o messaging-service e o dono estava errado. Pelo modelo de três planos (§1.1), o diálogo completo é da orquestradora e a fatia + estado de negócio é de cada especializada — nenhum dos dois é o modelo de conhecimento (RAG) que este documento desenha. Esses schemas ficam adiados (§7), fora de documents/chunks/outbox.
3.4. outbox — ponte transacional Postgres → Qdrant
Garante que a gravação do chunk e a intenção de sincronizar o Qdrant commitam juntas (§5.1). Tabela técnica: sem soft delete, sem autoria; retida por TTL.
| Coluna | Tipo | Regra |
|---|---|---|
id |
uuid (v7) |
PK (§23.1). |
tenant_id |
uuid |
§23.3. |
chunk_id |
uuid |
FK → chunks(id). Ponteiro (= point_id). |
collection |
varchar(150) |
Collection-alvo (§4.1). Denormalizada para o worker agir sem reresolver. |
status |
varchar + check |
pending, processed, failed. |
retry_count |
integer |
Default 0. Backoff até o limite (§29.3). |
last_error |
text |
Diagnóstico da última falha (nullable). |
created_at |
timestamptz |
§23.1. |
processed_at |
timestamptz |
Quando o worker concluiu (nullable). |
Constraints/índices:
pk_outbox;fk_outbox__chunks(comix_outbox__chunks).ix_outbox__pending—(created_at)parcialWHERE status = 'pending'(polling FIFO eficiente).ix_outbox__tenant_id.
O evento é ponteiro, não payload. A linha diz "o chunk X mudou"; o worker relê o estado atual no Postgres. Isso difere por desenho do contrato serviço→core (UNISCALE §28, event-carried state transfer): aquele plano é a fronteira externa com o core via RabbitMQ; este é sincronização intra-serviço Postgres↔Qdrant, onde ponteiro + releitura sempre converge e nunca fica stale (§5).
3.5. Retenção e LGPD
- O modelo registro a registro permite exclusão cirúrgica por usuário/documento — é o motivo de não usar blob mutável.
- Fluxo normal: soft delete (tombstone) no chunk → linha de
outbox→ worker apaga o point no Qdrant (§5.3). FK preservada. - Direito ao esquecimento (LGPD): um job de erasure hard-deleta as linhas do usuário onde existirem — no diálogo (transcript na orquestradora; fatia + estado de negócio na especializada) e em
chunks/documentsquando aplicável — no Postgres e dispara o delete dos points no Qdrant (carregando ospoint_id=chunks.id). A propagação ao Qdrant é obrigatória — delete que não propaga deixa vetor recuperável. - O Qdrant nunca guarda conteúdo rico (só ponteiro + filtros, §4.2): a erasure foca no Postgres + delete do point, sem caçar texto espalhado.
- TTL: linhas de
outboxcomstatus = 'processed'expiram por job de retenção após N dias (N é política operacional).failedficam para inspeção (§5.5).
4. Collections do Qdrant
4.1. Nomenclatura e identidade
Segue UNISCALE §26.1 — <domain>__<modality>__<content>__<model>:
ai_sales__text__chunks__bge-m3
ai_secretary__text__chunks__bge-m3
point_id=chunks.id(UUIDv7), UNISCALE §26.3. Já é determinístico e idempotente — não se usa hash de coordenadas; oiddo chunk basta para upsert idempotente, inclusive em blue-green (collection nova é outra collection, sem colisão).- Distance metric:
Cosine(texto moderno, BGE-M3) — registrar na criação (UNISCALE §26.5). - Dimensão:
1024(BGE-M3).
4.2. Payload (mínimo — UNISCALE §26.4)
Só o necessário para filtrar e referenciar. Conteúdo rico não vai no payload (desatualiza contra o Postgres); após o match, busca-se o content pelo id no Postgres.
| Campo | Tipo | Função |
|---|---|---|
tenant_id |
UUID (string) | Filtro de tenant. Índice de payload obrigatório (§26.2). |
document_id |
UUID (string) | Referência ao documento-pai. |
status |
string | Filtro de estado, quando a busca exclui inativos. |
source_id(§26.4) é omitido por redundância: opoint_idjá é oiddo chunk (§3 leitor competente — não repetir o que a chave já carrega).embedding_modeltambém não entra no payload — a collection já o trava no nome (§26.1).
4.3. Configuração via ambiente (UNISCALE §26.7)
Collection, modelo e dimensão são config, não hard-code, prefixados por domínio:
AI_SALES_VECTOR_COLLECTION=ai_sales__text__chunks__bge-m3
AI_SALES_EMBEDDING_MODEL=bge-m3
AI_SALES_EMBEDDING_DIMENSION=1024
4.4. Denso agora, híbrido depois
BGE-M3 suporta denso + esparso/multi-vetor. Hoje só denso. Híbrido (named vectors no Qdrant) fica adiado conscientemente (§7) — entra quando o recall justificar, sem quebrar o nome da collection (mesmo modelo).
5. Ingestão e consistência (outbox)
5.1. O problema e a solução
Dual write Postgres↔Qdrant sem transação distribuída diverge silenciosamente (busca volta incompleta/stale, sem erro). A solução é o outbox transacional: o chunk e a linha de outbox commitam na mesma transação Postgres (atômico). Um worker separado faz polling do outbox, age no Qdrant com retry, e marca como processado.
5.2. Fluxo do worker (<domain>-ingestion-worker)
- Polling de
outbox WHERE status = 'pending', em ordem (ix_outbox__pending). - Relê o chunk atual no Postgres pelo
chunk_id:- chunk ativo (
deleted_at IS NULL): pede o vetor aoembedding-service(content + collection-alvo) e faz upsert no Qdrant empoint_id = chunks.id. - chunk tombstoned (
deleted_atpreenchido): delete do point no Qdrant.
- chunk ativo (
- Marca a linha
processed(processed_at = now()). Em erro:retry_count++com backoff; estourado o limite →failed(DLQ lógica, §5.5).
A operação (upsert vs delete) é derivada do estado relido, não carregada no evento — por isso reprocessar ou processar fora de ordem sempre converge.
5.3. Update e delete
- Update: re-chunk → diff por
content_hash→ upsert só do que mudou + delete dos chunks que sumiram (tombstone → outbox). Sem o delete, sobra vetor órfão. - Delete: entra no mesmo molde — o worker não acha o chunk ativo → apaga o point.
5.4. Troca de modelo (blue-green)
- Cria collection nova (novo nome §26.1, novo modelo).
- Backfill relendo os chunks do Postgres (a fonte da verdade) → embeddar no modelo novo.
- Troca o alias da collection.
- Dropa a velha.
Zero conversão de vetor: tudo reembedda do texto (princípio 1).
5.5. Rede de segurança
- Job de reconciliação: compara contagens Postgres × Qdrant por
(tenant_id, collection)e re-enfileira o drift no outbox. - Métrica de lag do outbox: idade da linha
pendingmais antiga (alerta de worker parado). - DLQ: linhas
failedretidas para inspeção/reprocesso manual (espírito da UNISCALE §29.3).
5.6. Evolução
Começar com polling / LISTEN-NOTIFY, não CDC. Outbox + worker por domínio. CDC entra só se a escala exigir (adiado, §7).
6. Dependência externa lenta — boleto (ai-secretary)
A consulta de boleto passa pelo gateway legado de mensalidades (jacad, ~5s, fora do nosso controle). O alvo é tirar os 5s do caminho crítico, não cachear.
6.1. Por que não cachear
Volume ínfimo, resultado único por usuário e risco de compliance (dado financeiro pessoal). Cache aqui é custo e risco sem ganho.
6.2. Fluxo assíncrono + single-flight
- Usuário pede o boleto (WhatsApp, assíncrono).
- A IA resolve de qual boleto se trata (intenção) → calcula a chave single-flight =
(user_id, boleto_resolvido)— depois de resolver, não no texto cru. - Ack imediato ("já te trago, um instante") via
messaging-service. Reduz o spam na origem. - Single-flight: registra a chave "em voo" em store compartilhado (Redis,
REDIS_URL§12) com TTL. Se a chave já está em voo → ignora a duplicata; o usuário recebe a entrega do primeiro. - Busca em background no legado (passando idempotency key se o legado suportar).
- Resultado chega → push via
messaging-service→ libera a chave.
6.3. Garantias
- TTL obrigatório na entrada "em voo": legado travado / worker caído não pode travar a chave para sempre.
- Multi-instância: o conjunto "em voo" vive no store compartilhado (Redis), nunca em memória local — senão duas instâncias disparam a mesma busca.
7. Fronteiras adiadas conscientemente
- Busca híbrida (denso + esparso/multi-vetor do BGE-M3) — só denso hoje; named vectors entram quando o recall justificar (§4.4).
- Collection de histórico de conversa (busca semântica sobre o diálogo) — não existe hoje. Conversa é alimentada na LLM de forma relacional (últimas N + resumo recomputável). Só se um dia precisar de recall semântico sobre histórico longo.
- Schemas das tabelas de diálogo — o transcript completo (orquestradora) e a fatia + estado de negócio (especializada) substituem a antiga
messages; o desenho desses schemas fica adiado, fora deste modelo de conhecimento. - CDC no lugar de polling/LISTEN-NOTIFY — só se a escala do outbox exigir (§5.6).
- Tipo canônico de hash na UNISCALE (§22) —
content_hashhoje échar(64)por convenção local.
CHANGELOG
| Versão | Data | Autor | Resumo |
|---|---|---|---|
| 1.1.1 | 2026-06-18 | Plataforma | Editorial (sem mudança de regra): nota de status no plano de entrega — o messaging-service é recomendado, não adotado (ECOSYSTEM §4); nos fluxos deste doc, "messaging-service" = camada de entrega (connector de canal por decisão atual). O modelo de três planos do diálogo não muda. PATCH. |
| 1.1.0 | 2026-06-04 | Plataforma | Aposenta a tabela messages (nome colidia com messaging-service; dono errado). Reescreve §1.1 para o modelo de três planos do diálogo: messaging-service = gerência de entrega (indiferente ao conteúdo); ai-orchestrator = dono do diálogo completo (transcript em orchestrator_db, sem banco vetorial); IA especializada = dona da sua fatia + estado de negócio, além do RAG (Postgres de conhecimento + Qdrant). §3.3 deixa de definir schema de conversa (movido para fora do modelo de conhecimento, adiado §7); LGPD §3.5 e topologia §2 atualizadas; §7 troca "collection de messages" por "histórico de conversa" e registra os schemas de diálogo como adiados. Coerente com ECOSYSTEM (especializada devolve à orquestradora, não ao canal). Schemas a desenhar depois. MINOR. |
| 1.0.1 | 2026-06-02 | Plataforma | Editorial (sem mudança de regra): adiciona front-matter YAML conforme o contrato da fonte (cortex-docs/meta/DOC_SOURCE_CONTRACT.md). Documento é design narrativo — não recebe marcadores @rule. PATCH. |
| 1.0.0 | 2026-06-02 | Plataforma | Desenho inicial do subsistema de RAG e ingestão vetorial: princípios travados (texto canônico = verdade, Postgres vence, embedding-service casca pura BGE-M3 enforcer do invariante, embedding desacoplado da geração GLM), topologia (Postgres+Qdrant por domínio, embedding-service compartilhado sem banco, worker de ingestão por domínio), modelo de dados de referência (documents, chunks, messages, outbox) com índices, retenção e LGPD, configuração das collections Qdrant (nomenclatura, point_id = chunks.id, payload mínimo, denso agora/híbrido depois), fluxo de consistência por outbox transacional (worker idempotente, diff por content_hash, blue-green de modelo, reconciliação/lag/DLQ) e fluxo assíncrono do boleto com single-flight. Rascunho pendente de revisão. |