CORTEX · doc

Arquitetura de RAG e Ingestão Vetorial

narrativo · 28 seções · v1.1.1 · rascunho · owner: plataforma · 21 KB · rag · sumário em JSON

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.md Documentos 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

  1. Embedding não se converte entre modelos. Trocar de modelo = reembeddar a partir do texto. Por isso o texto canônico é o que importa.
  2. Postgres é a fonte da verdade (texto canônico do chunk + metadados). Qdrant é reconstruível (vetor + ponteiro + campos filtráveis). Em divergência, Postgres vence.
  3. embedding-service é casca única sobre o modelo (hoje BGE-M3, 1024d). Recebe texto + collection-alvo, resolve collection → modelo → dim e 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.
  4. 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.
  5. 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.
  6. Quem busca é a IA especializada; quem escreve no Qdrant é o worker de ingestão do domínio. O embedding-service só faz texto → 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: o messaging-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 mapa collection → 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 (com ix_chunks__document_id, §24.4).
  • ux_chunks__document_id__seq — unique parcial WHERE deleted_at IS NULL (§24.4).
  • ix_chunks__tenant_id; ix_chunks__content_hash (diff de reindexação).

content_hash usa 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 (com ix_outbox__chunks).
  • ix_outbox__pending — (created_at) parcial WHERE 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/documents quando aplicável — no Postgres e dispara o delete dos points no Qdrant (carregando os point_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 outbox com status = 'processed' expiram por job de retenção após N dias (N é política operacional). failed ficam 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; o id do 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: o point_id já é o id do chunk (§3 leitor competente — não repetir o que a chave já carrega). embedding_model també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)

  1. Polling de outbox WHERE status = 'pending', em ordem (ix_outbox__pending).
  2. Relê o chunk atual no Postgres pelo chunk_id:
    • chunk ativo (deleted_at IS NULL): pede o vetor ao embedding-service (content + collection-alvo) e faz upsert no Qdrant em point_id = chunks.id.
    • chunk tombstoned (deleted_at preenchido): delete do point no Qdrant.
  3. 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)

  1. Cria collection nova (novo nome §26.1, novo modelo).
  2. Backfill relendo os chunks do Postgres (a fonte da verdade) → embeddar no modelo novo.
  3. Troca o alias da collection.
  4. 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 pending mais antiga (alerta de worker parado).
  • DLQ: linhas failed retidas 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

  1. Usuário pede o boleto (WhatsApp, assíncrono).
  2. 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.
  3. Ack imediato ("já te trago, um instante") via messaging-service. Reduz o spam na origem.
  4. 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.
  5. Busca em background no legado (passando idempotency key se o legado suportar).
  6. 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_hash hoje é 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.