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:
- Cada bloco de conhecimento e dividido em chunks menores por um LLM (OpenAI gpt-4.1)
- Gera um embedding (vetor de 1536 dimensoes) para cada chunk
- Quando uma mensagem chega, gera um embedding da pergunta do usuario
- Usa similaridade por cosseno para encontrar os chunks mais relevantes
- Inclui apenas esses chunks no contexto do LLM
- 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:
- Carrega prompt de um arquivo externo (
Prompts/chunking-prompt.md) - Substitui placeholder
{{MAX_SIZE}}com valor da configuracao - Cria um
ILlmClientviaILlmClientFactoryinternamente - Envia o texto completo para o LLM com limite de tokens configuravel (
MaxOutputTokens) - LLM analisa, otimiza e divide o texto em chunks atomicos
- Retorna
ChunkingResultcom 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:
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¶
RewriteKnowledgeQueryStep(sub-step do pipeline de agregacao) verifica se o agente possui embeddings viaHasEmbeddingsAsync()— se nao, pula o rewrite- Extrai o conteudo consolidado e historico recente
- Converte historico para
List<ChatMessage>usandoMessageContentFormatter(combina Content + ExtractedText) - Chama
QueryRewriteService.RewriteAsync()com mensagem atual + historico - O servico monta prompt com historico formatado e envia para o LLM
- LLM retorna query reformulada (uma linha, sem explicacoes)
- Query reescrita e armazenada em
context.RewrittenKnowledgeQuery InvokeAiServiceSteppassa a query paraAiResponseServiceque 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¶
- Admin cria bloco via CRUD
- Handler salva o bloco no banco
ChunkingService.CreateChunksAsync()usa LLM para dividir o textoIEmbeddingService.GenerateEmbeddingsAsync()gera embeddings em batch (1 chamada HTTP)- Chunks salvos com seus embeddings
- Se falhar, bloco e criado sem chunks (log warning)
Atualizacao de Bloco¶
- Admin atualiza titulo ou conteudo
- Handler detecta mudanca no conteudo (ou bloco sem chunks -> backfill)
- Deleta todos os chunks antigos
- Cria novos chunks via LLM
- Gera embeddings em batch (1 chamada HTTP)
- Salva novos chunks
Delecao de Bloco¶
- Admin deleta bloco
- 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¶
- Verificar se chunks existem:
SELECT COUNT(*) FROM agent_knowledge_chunks WHERE knowledge_block_id = ... - Verificar se embeddings foram gerados:
SELECT embedding IS NOT NULL FROM agent_knowledge_chunks WHERE ... - Verificar threshold: Se muito alto (ex: 0.7), pode filtrar chunks validos
- Verificar se indice existe:
\d agent_knowledge_chunksno psql
Chunks nao estao sendo gerados¶
- Verificar se a chave OpenAI esta configurada (
Llm:OpenAiApiKeynoappsettings.json) - Verificar logs por erros de API
- Verificar se o bloco foi criado (chunks sao criados apos o bloco)
Performance lenta¶
- Verificar se indice HNSW existe na tabela
agent_knowledge_chunks - Considerar ajustar
TopKpara valor menor (atualmente 3) - Verificar latencia com OpenAI API
- Considerar aumentar
MaxSizese chunks forem muito pequenos
Migration¶
A migration AddKnowledgeChunks faz:
- Remove colunas
embeddingeembedding_updated_atda tabelaagent_knowledge - Remove indice HNSW da tabela
agent_knowledge - Cria tabela
agent_knowledge_chunkscom: - FK para
agent_knowledgecom CASCADE delete - Indice HNSW para busca semantica
- Indices para ordenacao e FK
Nota: Blocos existentes precisarao ter seus chunks gerados manualmente ou via backfill job.