Pular para conteúdo

Knowledge Retrieval com pgvector

Visao Geral

O Ciba utiliza pgvector para busca semantica na base de conhecimento dos agentes. O sistema implementa chunking inteligente via LLM para reduzir consumo de tokens:

  1. Cada bloco de conhecimento e dividido em chunks menores por um LLM (OpenAI gpt-4.1)
  2. Gera um embedding (vetor de 1536 dimensoes) para cada chunk
  3. Quando uma mensagem chega, gera um embedding da pergunta do usuario
  4. Usa similaridade por cosseno para encontrar os chunks mais relevantes
  5. Inclui apenas esses chunks no contexto do LLM
  6. Tambem busca FAQs com embeddings e combina os resultados

Resultado: Reducao de ~60-80% no consumo de tokens mantendo (ou melhorando) a qualidade das respostas.

Arquitetura

┌─────────────────────────────────────────────────────────────────┐
│                     Knowledge Block (pai)                        │
│                                                                  │
│  ┌──────────┐    ┌─────────────────────────────────────────────┐│
│  │  Title   │    │              Content (texto completo)       ││
│  └──────────┘    └─────────────────────────────────────────────┘│
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
                              ↓ ChunkingService (LLM)
┌─────────────────────────────────────────────────────────────────┐
│                     Knowledge Chunks (filhos)                    │
│                                                                  │
│  ┌────────────────────┐  ┌────────────────────┐  ┌───────────┐ │
│  │ Chunk 0            │  │ Chunk 1            │  │ Chunk N   │ │
│  │ content + embedding│  │ content + embedding│  │ ...       │ │
│  └────────────────────┘  └────────────────────┘  └───────────┘ │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                     PostgreSQL + pgvector                        │
│                                                                  │
│  • Tabela: agent_knowledge_chunks                               │
│  • Coluna: embedding vector(1536)                               │
│  • Indice: HNSW (Hierarchical Navigable Small World)            │
│  • Operador: vector_cosine_ops (similaridade por cosseno)       │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                    Busca Semantica                               │
│                                                                  │
│  1. Usuario envia mensagem                                       │
│  2. QueryRewriteService reformula em query standalone (LLM)      │
│     Ex: "E o primeiro" → "Qual e o primeiro melhor refrigerante?"│
│  3. Gera embedding da query reescrita                            │
│  4. EF Core LINQ busca chunks + FAQs por distancia cosseno      │
│     - AgentKnowledgeChunks com Embedding.CosineDistance()        │
│     - AgentFaqs com Embedding.CosineDistance()                   │
│     - Filtra por threshold, ordena por distancia                 │
│     - Take(TopK=3) em cada, combina e retorna top 3 total       │
│  5. Retorna chunks mais similares formatados como "## Title\n..."│
│     e FAQs como "**Pergunta:** ...\n**Resposta:** ..."           │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Modelo de Dados

AgentKnowledge (bloco pai)

public class AgentKnowledge
{
    public Guid Id { get; private set; }
    public Guid AgentId { get; private set; }
    public Guid TenantId { get; private set; }
    public string Title { get; private set; } = string.Empty;
    public string Content { get; private set; } = string.Empty;
    public int DisplayOrder { get; private set; }
    public bool IsActive { get; private set; } = true;
    public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
    public DateTime UpdatedAt { get; private set; } = DateTime.UtcNow;

    // Navigation
    public Agent Agent { get; private set; } = null!;

    private readonly List<AgentKnowledgeChunk> _chunks = [];
    public IReadOnlyCollection<AgentKnowledgeChunk> Chunks => _chunks.AsReadOnly();

    private AgentKnowledge() { }

    public static AgentKnowledge Create(Guid tenantId, Guid agentId, string title, string content, int displayOrder = 0);

    public void Update(string title, string content, int displayOrder);
    public void Activate();
    public void Deactivate();
}

Padrao: Construtor privado + factory method Create(), setters privados, IReadOnlyCollection para navegacao de filhos.

AgentKnowledgeChunk (chunk filho)

public class AgentKnowledgeChunk
{
    public Guid Id { get; private set; }
    public Guid KnowledgeBlockId { get; private set; }
    public int ChunkIndex { get; private set; }           // Ordem do chunk
    public string Content { get; private set; } = string.Empty;  // Fragmento do texto
    public Vector? Embedding { get; private set; }        // Vetor 1536 dimensoes
    public DateTime? EmbeddingUpdatedAt { get; private set; }
    public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;

    // Navigation
    public AgentKnowledge KnowledgeBlock { get; private set; } = null!;

    private AgentKnowledgeChunk() { }

    public static AgentKnowledgeChunk Create(Guid knowledgeBlockId, int chunkIndex, string content);

    public void SetEmbedding(Vector embedding);
}

Padrao: Construtor privado + factory method Create(), setters privados.

Componentes

1. Modelo de Embedding

Modelo: text-embedding-3-small (OpenAI) - Dimensoes: 1536 - Limite de tokens: 8.191 (~25.000 caracteres) - Custo: $0.02 / 1M tokens

2. Servicos

Servico Responsabilidade
IEmbeddingService Gera embeddings via OpenAI API
IChunkingService Divide blocos em chunks usando LLM
IKnowledgeRetrievalService Busca chunks e FAQs relevantes por similaridade
IQueryRewriteService Reescreve mensagens anaforicas em queries standalone via LLM

3. ChunkingService (LLM)

O ChunkingService usa um LLM (OpenAI gpt-4.1) para dividir e otimizar o texto:

  1. Carrega prompt de um arquivo externo (Prompts/chunking-prompt.md)
  2. Substitui placeholder {{MAX_SIZE}} com valor da configuracao
  3. Cria um ILlmClient via ILlmClientFactory internamente
  4. Envia o texto completo para o LLM com limite de tokens configuravel (MaxOutputTokens)
  5. LLM analisa, otimiza e divide o texto em chunks atomicos
  6. Retorna ChunkingResult com lista de chunks

O que o LLM faz: - Remove ruido: redundancias, introducoes genericas, transicoes desnecessarias - Compacta: reescreve de forma concisa mantendo 100% do significado - Atomiza: cada chunk responde UMA pergunta especifica - Normaliza: padroniza termos e formatos (datas, valores, etc.) - Lista: converte paragrafos densos em listas quando possivel

Vantagens: - Chunks menores e mais precisos para busca semantica - Melhor match entre pergunta do usuario e chunk relevante - Reducao de tokens consumidos no contexto do LLM - Fallback automatico para chunk unico em caso de falha

Custo: ~$2.00/1M tokens de entrada + ~$8.00/1M tokens de saida (OpenAI gpt-4.1)

public record ChunkingResult(List<AgentKnowledgeChunk> Chunks);

public interface IChunkingService
{
    Task<ChunkingResult> CreateChunksAsync(
        Guid agentId,
        AgentKnowledge block,
        CancellationToken ct = default);
}

Nota: O ChunkingService cria seu proprio ILlmClient via ILlmClientFactory.CreateClient(ChunkingConfig.Provider) internamente. Nao recebe ILlmClient como parametro.

Tracking: Tokens consumidos sao rastreados via LlmUsageTracker.Track() com tipo LlmOperationType.Chunking e metadata ChunkingUsageMetadata(block.Id).

4. Prompt Externo

O prompt de chunking e armazenado como embedded resource em:

src/Ciba.Infrastructure/Prompts/chunking-prompt.md

Isso permite facil manutencao do prompt sem recompilar o codigo.

Placeholder disponivel: - {{MAX_SIZE}} - Limite maximo de caracteres por chunk

Regras principais do prompt:

Regra Descricao
Remover ruido Redundancias, introducoes, transicoes
Compactar Reescrever de forma concisa
Atomizar Um chunk = uma pergunta
Normalizar Padronizar termos e formatos
Listar Converter paragrafos em listas

Exemplo de transformacao:

Entrada:
"Bem-vindo! Aceitamos Pix com 10% de desconto, cartao em ate 3x..."

Saida:
["Pagamento:\n- Pix: 10% desconto\n- Cartao: ate 3x sem juros", ...]

Configuracao

Classes Estaticas com Constantes

A configuracao de Knowledge Retrieval e definida via classes estaticas com campos const (hardcoded em tempo de compilacao), e nao via appsettings.json:

EmbeddingConfig (Ciba.Infrastructure/Configuration/EmbeddingConfig.cs)

public static class EmbeddingConfig
{
    public const string Provider = "openai";
    public const string Model = "text-embedding-3-small";
    public const int Dimensions = 1536;
    public const int MaxTokens = 8191;
}

ChunkingConfig (Ciba.Infrastructure/Configuration/ChunkingConfig.cs)

public static class ChunkingConfig
{
    public const string Provider = "openai";
    public const string Model = "gpt-4.1";
    public const int MaxSize = 4000;
    public const int MaxOutputTokens = 8192;
}

SearchConfig (Ciba.Infrastructure/Configuration/SearchConfig.cs)

public static class SearchConfig
{
    public const int TopK = 3;
    public const double SimilarityThreshold = 0.3;
}

QueryRewriteConfig (Ciba.Infrastructure/Configuration/QueryRewriteConfig.cs)

public static class QueryRewriteConfig
{
    public const bool Enabled = true;
    public const string Provider = "openai";
    public const string Model = "gpt-4o-mini";
    public const int MaxOutputTokens = 256;
    public const int MaxHistoryMessages = 6;
}

Nota: As API keys sao obtidas da secao Llm do appsettings.json (Llm:AnthropicApiKey e Llm:OpenAiApiKey). O ILlmClientFactory resolve o client correto pelo nome do provider (ex: "openai").

Resumo dos Valores

Config Campo Valor
EmbeddingConfig Provider openai
EmbeddingConfig Model text-embedding-3-small
EmbeddingConfig Dimensions 1536
EmbeddingConfig MaxTokens 8191
ChunkingConfig Provider openai
ChunkingConfig Model gpt-4.1
ChunkingConfig MaxSize 4000
ChunkingConfig MaxOutputTokens 8192
SearchConfig TopK 3
SearchConfig SimilarityThreshold 0.3
QueryRewriteConfig Enabled true
QueryRewriteConfig Provider openai
QueryRewriteConfig Model gpt-4o-mini
QueryRewriteConfig MaxOutputTokens 256
QueryRewriteConfig MaxHistoryMessages 6

Nota: O LLM decide semanticamente o tamanho ideal de cada chunk. O MaxSize e apenas um limite de seguranca para evitar chunks gigantes. O MaxOutputTokens deve ser aumentado para blocos de conhecimento muito grandes.

Cache de Embeddings

O KnowledgeRetrievalService utiliza ICacheService para evitar queries desnecessarias ao banco:

var embeddingsInfo = await _cache.GetOrSetAsync(
    CacheKeys.AgentHasEmbeddings(agentId),
    async () => new CachedEmbeddingsInfo(
        HasKnowledge: /* verifica se existem chunks com embedding */,
        HasFaq: /* verifica se existem FAQs com embedding */),
    TimeSpan.FromSeconds(CacheKeys.AgentHasEmbeddingsTtlSeconds), // 30 min
    cancellationToken);

Chave: agent:{agentId}:has-embeddings (definida em CacheKeys.AgentHasEmbeddingsPrefix) TTL: 30 minutos (CacheKeys.AgentHasEmbeddingsTtlSeconds = 1800)

Invalidacao: O cache e removido via _cache.RemoveAsync(CacheKeys.AgentHasEmbeddings(agentId)) sempre que: - Um bloco de conhecimento e criado, atualizado ou deletado - Um FAQ e criado, atualizado ou deletado

Se o cache indicar que nao ha embeddings (nem knowledge nem FAQ), a busca semantica e pulada completamente.

Nota: A verificacao de existencia de embeddings tambem e usada pelo RewriteKnowledgeQueryStep para pular o query rewrite quando o agente nao possui knowledge base. Isso evita chamadas desnecessarias ao LLM de reescrita via HasEmbeddingsAsync(agentId).

Busca Combinada: Knowledge + FAQ

O KnowledgeRetrievalService busca em duas fontes usando EF Core LINQ com pgvector:

1. AgentKnowledgeChunks

var relevantChunks = await _db.AgentKnowledgeChunks
    .AsNoTracking()
    .Where(c =>
        c.KnowledgeBlock.AgentId == agentId &&
        c.KnowledgeBlock.IsActive &&
        c.Embedding != null)
    .Select(c => new
    {
        c.KnowledgeBlock.Title,
        c.Content,
        c.ChunkIndex,
        Distance = c.Embedding!.CosineDistance(queryEmbedding)
    })
    .Where(c => c.Distance <= maxDistance)
    .OrderBy(c => c.Distance)
    .Take(topK)
    .ToListAsync(cancellationToken);

2. AgentFaqs

var relevantFaqs = await _db.AgentFaqs
    .AsNoTracking()
    .Where(f =>
        f.AgentId == agentId &&
        f.Embedding != null)
    .Select(f => new
    {
        f.Question,
        f.Answer,
        Distance = f.Embedding!.CosineDistance(queryEmbedding)
    })
    .Where(f => f.Distance <= maxDistance)
    .OrderBy(f => f.Distance)
    .Take(topK)
    .ToListAsync(cancellationToken);

3. Combinacao

Os resultados sao combinados, ordenados por distancia, e o top K total e retornado:

var combined = relevantChunks
    .Select(c => (Content: $"## {c.Title}\n{c.Content}", c.Distance))
    .Concat(relevantFaqs.Select(f => (Content: $"**Pergunta:** {f.Question}\n**Resposta:** {f.Answer}", f.Distance)))
    .OrderBy(x => x.Distance)
    .Take(topK)
    .Select(x => x.Content)
    .ToList();

Nota: A busca usa EF Core LINQ com pgvector (CosineDistance()), nao SQL raw.

Query Rewriting

Problema

Quando o usuario envia mensagens anaforicas como "E o primeiro", "E no domingo?", "Quanto custa esse?", a busca vetorial nao encontra resultados relevantes porque a query nao contem contexto suficiente para gerar um embedding significativo.

Solucao

O QueryRewriteService usa um LLM leve (gpt-4o-mini) para reformular a mensagem do usuario em uma query standalone antes da busca vetorial:

Usuario: "Segundo melhor refrigerante do mundo"
IA: "Coca-Cola"
Usuario: "E o primeiro"
                ↓ QueryRewriteService
Query reescrita: "Qual e o primeiro melhor refrigerante do mundo?"
                ↓ EmbeddingService + pgvector
Chunks relevantes encontrados

Fluxo

  1. RewriteKnowledgeQueryStep (sub-step do pipeline de agregacao) verifica se o agente possui embeddings via HasEmbeddingsAsync() — se nao, pula o rewrite
  2. Extrai o conteudo consolidado e historico recente
  3. Converte historico para List<ChatMessage> usando MessageContentFormatter (combina Content + ExtractedText)
  4. Chama QueryRewriteService.RewriteAsync() com mensagem atual + historico
  5. O servico monta prompt com historico formatado e envia para o LLM
  6. LLM retorna query reformulada (uma linha, sem explicacoes)
  7. Query reescrita e armazenada em context.RewrittenKnowledgeQuery
  8. InvokeAiServiceStep passa a query para AiResponseService que a usa na busca vetorial

Prompt

O prompt de query rewriting esta em src/Ciba.Infrastructure/Prompts/query-rewrite-prompt.md (embedded resource).

Regras do prompt: - Resolver referencias anaforicas usando historico - Output deve ser standalone (compreensivel sem contexto) - Preservar idioma original - Nao inventar informacao - Se ja for standalone, retornar como esta - Retornar apenas a query (uma linha, sem explicacoes)

Fallback

Situacao Comportamento
Query rewrite desabilitado Usa mensagem original
Agente sem embeddings Pula query rewrite (usa mensagem original)
Mensagem vazia Usa mensagem original
LLM retorna resposta vazia Usa mensagem original
Erro na chamada LLM Usa mensagem original (log error)

Tracking

Tokens consumidos sao rastreados via LlmUsageTracker.Track() com tipo LlmOperationType.QueryRewrite.

Fluxo de Criacao/Atualizacao

Criacao de Bloco

  1. Admin cria bloco via CRUD
  2. Handler salva o bloco no banco
  3. ChunkingService.CreateChunksAsync() usa LLM para dividir o texto
  4. IEmbeddingService.GenerateEmbeddingsAsync() gera embeddings em batch (1 chamada HTTP)
  5. Chunks salvos com seus embeddings
  6. Se falhar, bloco e criado sem chunks (log warning)

Atualizacao de Bloco

  1. Admin atualiza titulo ou conteudo
  2. Handler detecta mudanca no conteudo (ou bloco sem chunks -> backfill)
  3. Deleta todos os chunks antigos
  4. Cria novos chunks via LLM
  5. Gera embeddings em batch (1 chamada HTTP)
  6. Salva novos chunks

Delecao de Bloco

  1. Admin deleta bloco
  2. Chunks sao deletados automaticamente via CASCADE

Indice HNSW

Por que HNSW? - 15.5x mais rapido que IVFFlat em queries - Resiliente a atualizacoes frequentes - Pode ser criado em tabela vazia - Recall de 0.998

Configuracao na Migration:

builder.HasIndex(x => x.Embedding)
    .HasDatabaseName("idx_chunks_embedding")
    .HasMethod("hnsw")
    .HasOperators("vector_cosine_ops");

Fallback

O sistema nunca faz fallback para toda a base de conhecimento quando a busca semantica nao encontra resultados. Isso garante que o agente so responda com informacoes relevantes.

Situacao Comportamento
Query vazia Retorna vazio (sem conhecimento)
Nenhum chunk com embedding Retorna vazio (sem conhecimento)
Nenhum chunk passa do threshold Retorna vazio (sem conhecimento)
Erro na busca semantica Retorna vazio (sem conhecimento)
Erro na geracao de embedding Log warning, bloco criado sem chunks
Erro no LLM de chunking Cria chunk unico com conteudo completo

Validacoes

Para garantir que embeddings funcionem corretamente:

  • MaximumLength(25000) nos validators de titulo e conteudo
  • Texto truncado silenciosamente se ultrapassar 8k tokens
  • Warning logado quando truncamento ocorre

Monitoramento

Logs Importantes

// Busca semantica bem-sucedida
Semantic search for agent {AgentId}: found {ChunkCount} knowledge chunks and {FaqCount} FAQ items (threshold: {Threshold}, query: {QueryPreview})

// Nenhum resultado encontrado (sem fallback)
No results matched similarity threshold for agent {AgentId}. Returning empty knowledge (no fallback).

// Chunks criados
Knowledge block '{Title}' created with {ChunkCount} chunks for Agent {AgentId}

// Chunks atualizados
Knowledge block '{Title}' updated with {ChunkCount} chunks for Agent {AgentId}

// LLM chunking tokens
LLM chunking used {InputTokens} input tokens and {OutputTokens} output tokens

Metricas Recomendadas

  • Tempo de geracao de embedding (P50, P95, P99)
  • Taxa de hit do threshold de similaridade
  • Chunks retornados por busca (media)
  • Taxa de buscas sem resultado (zero matches)
  • Numero medio de chunks por bloco
  • Tokens consumidos no chunking LLM

Troubleshooting

Busca nao retorna chunks relevantes

  1. Verificar se chunks existem: SELECT COUNT(*) FROM agent_knowledge_chunks WHERE knowledge_block_id = ...
  2. Verificar se embeddings foram gerados: SELECT embedding IS NOT NULL FROM agent_knowledge_chunks WHERE ...
  3. Verificar threshold: Se muito alto (ex: 0.7), pode filtrar chunks validos
  4. Verificar se indice existe: \d agent_knowledge_chunks no psql

Chunks nao estao sendo gerados

  1. Verificar se a chave OpenAI esta configurada (Llm:OpenAiApiKey no appsettings.json)
  2. Verificar logs por erros de API
  3. Verificar se o bloco foi criado (chunks sao criados apos o bloco)

Performance lenta

  1. Verificar se indice HNSW existe na tabela agent_knowledge_chunks
  2. Considerar ajustar TopK para valor menor (atualmente 3)
  3. Verificar latencia com OpenAI API
  4. Considerar aumentar MaxSize se chunks forem muito pequenos

Migration

A migration AddKnowledgeChunks faz:

  1. Remove colunas embedding e embedding_updated_at da tabela agent_knowledge
  2. Remove indice HNSW da tabela agent_knowledge
  3. Cria tabela agent_knowledge_chunks com:
  4. FK para agent_knowledge com CASCADE delete
  5. Indice HNSW para busca semantica
  6. Indices para ordenacao e FK

Nota: Blocos existentes precisarao ter seus chunks gerados manualmente ou via backfill job.