API de RAG (Retrieval-Augmented Generation) para conhecimento agronômico: fertilidade do solo, culturas, pragas, doenças e manejo. Os documentos são indexados no PostgreSQL com pgvector, a busca é semântica com embeddings locais do Hugging Face e as respostas são geradas por um LLM rodando no Ollama, tanto em uma cadeia RAG simples quanto em um agente com LangGraph.
Tudo roda localmente com docker compose up, sem chave de API externa.
- API: Python 3.12, FastAPI, Pydantic v2, uv
- Banco vetorial: PostgreSQL 17 + pgvector (índice HNSW, distância de cosseno), SQLAlchemy 2 async + asyncpg, Alembic
- Cache: Redis 7 (cache-aside com TTL, degradação graciosa), redis-py asyncio
- Mensageria: AWS SNS + SQS (com DLQ) e DynamoDB via aioboto3, emulados localmente pelo floci
- Embeddings:
intfloat/multilingual-e5-smallvia sentence-transformers (CPU) - LLM / orquestração: LangChain (LCEL, retriever próprio), LangGraph (agente com grading e ferramenta), Ollama com
qwen2.5:3b - Avaliação: recall@k, MRR e nDCG com numpy, pandas e scikit-learn; experimentos registrados no MLflow
- Observabilidade: OpenTelemetry (FastAPI + SQLAlchemy) exportando para Jaeger, logs JSON com structlog, métricas Prometheus
- Qualidade: pytest, pytest-asyncio, httpx, cobertura mínima de 85%, ruff, mypy
--strict, GitHub Actions - Carga: Locust
flowchart LR
subgraph ingest[Ingestão]
MD[corpus/*.md] --> CH[chunking<br/>RecursiveCharacterTextSplitter]
CH --> EMB[e5-small<br/>embeddings]
EMB --> PG[(Postgres + pgvector<br/>HNSW)]
end
C[Cliente] -->|POST /search| API[FastAPI]
C -->|POST /ask| API
C -->|POST /agent| API
API --> RET[PgVectorRetriever]
RET -.cache-aside.-> RD[(Redis)]
RET --> PG
API --> RAG[Cadeia RAG<br/>LCEL]
API --> AG[Agente LangGraph]
RAG --> LLM[Ollama<br/>qwen2.5:3b]
AG --> LLM
API -.traces.-> J[Jaeger]
API -.métricas.-> P[Prometheus]
EV[agro-rag evaluate] --> PG
EV -.runs.-> ML[MLflow]
O agente segue o fluxo abaixo. O grader descarta trechos irrelevantes; se nenhum sobrar, a pergunta é reescrita e a busca é repetida uma vez. Na geração, o modelo pode chamar a ferramenta liming_calculator, que calcula a necessidade de calagem pelo método da saturação por bases: NC = (V2 − V1) × CTC / PRNT.
flowchart LR
S((início)) --> R[retrieve] --> G[grade]
G -->|há documentos relevantes| A[answer]
G -->|nenhum relevante| W[rewrite] --> R
A -->|tool call| T[tools] --> A
A --> F[finish] --> E((fim))
Pré-requisitos: Docker e Docker Compose.
docker compose up -d --wait # db, redis, floci, aws-init, migrate, worker, ollama (+ pull do modelo), api, mlflow, jaeger, prometheus
docker compose run --rm api agro-rag ingest # indexa os 36 documentos do corpusNa primeira execução o compose baixa o qwen2.5:3b (~1,9 GB) e o modelo de embeddings (~470 MB, guardado no volume hf-cache).
| Serviço | URL |
|---|---|
| API (Swagger) | http://localhost:8000/docs |
| MLflow | http://localhost:5000 |
| Jaeger | http://localhost:16686 |
| Prometheus | http://localhost:9090 |
Variáveis úteis: OLLAMA_MODEL (modelo do Ollama), API_WORKERS (workers do uvicorn, padrão 4), API_TORCH_THREADS (threads do torch por worker, padrão 2). As configurações da aplicação usam o prefixo AGRO_ (veja src/agro_rag/config.py).
# Busca semântica (somente retrieval)
curl -s localhost:8000/search -H 'content-type: application/json' \
-d '{"query": "como controlar a cigarrinha no milho safrinha", "k": 3}'
# RAG: retrieve + geração com citação das fontes
curl -s localhost:8000/ask -H 'content-type: application/json' \
-d '{"question": "Quando devo aplicar nitrogênio em cobertura no milho?"}'
# Agente LangGraph com grading e ferramenta de calagem
curl -s localhost:8000/agent -H 'content-type: application/json' \
-d '{"question": "Calcule a necessidade de calagem para um solo com V1 de 35%, V2 de 60%, CTC de 8 cmolc/dm³ e calcário com PRNT de 80%."}'
# steps: ["retrieve:4", "grade:2", "answer", "tools", "answer"]
# answer: "A necessidade de calagem para esse solo é de **2.50 t/ha**."Também existem GET /health (verifica o banco), GET /livez, GET /readyz (veja Resiliência) e GET /metrics (Prometheus).
O /search, o /ask e o retriever do /agent usam cache-aside no Redis:
| Cache | Chave | TTL padrão |
|---|---|---|
search |
coleção, categoria, k e pergunta |
300 s (AGRO_CACHE_TTL_SECONDS) |
embedding |
modelo e pergunta (vetor da consulta) | 3600 s (AGRO_CACHE_EMBEDDING_TTL_SECONDS) |
answer |
modelo, coleção, k e pergunta (resposta do /ask) |
300 s |
- O cache é opcional: sem
AGRO_REDIS_URLa API roda sem Redis. O compose já configuraredis://redis:6379/0, commaxmemory 128mbe políticaallkeys-lru. - Degradação graciosa: qualquer falha do Redis (recusa de conexão, timeout de
AGRO_REDIS_TIMEOUT_SECONDS= 0,25 s, valor corrompido) é tratada como cache miss, registrada em log e contada; a requisição segue para o banco e nunca vira 5xx. - Invalidação: a ingestão que cria, atualiza ou remove documentos apaga (via
SCAN) as chavessearcheanswerda coleção. - Métricas:
agro_rag_cache_requests_total{cache,result}comresultemhit,missouerror.
As chamadas ao Ollama e ao Redis passam por src/agro_rag/resilience.py:
- Timeouts: Ollama com conexão de 3 s e leitura de 120 s (
AGRO_OLLAMA_CONNECT_TIMEOUT_SECONDS,AGRO_OLLAMA_TIMEOUT_SECONDS); Redis com 0,25 s por operação. - Retry com backoff exponencial e full jitter: espera sorteada em
U(0, min(máx, base × 2^n)). O Ollama tenta até 3 vezes (base 0,3 s) em erro de conexão,RemoteProtocolError,ReadErrore HTTP 502/503/504; um timeout de leitura não é repetido, pois a chamada já gastou o orçamento de tempo. O Redis tenta 2 vezes (base 20 ms). - Circuit breaker: abre após falhas consecutivas (5 no Ollama, 3 no Redis) e rejeita as chamadas sem tocar a dependência. Passado o tempo de recuperação (30 s e 10 s) deixa passar uma chamada de teste (half-open): se ela funciona o circuito fecha, senão reabre. Cada tentativa passa pelo breaker, então os retries param assim que ele abre. O breaker é por processo (cada worker do uvicorn tem o seu).
- Ollama fora do ar:
/aske/agentrespondem503comRetry-After: 30em vez de 500;/searchcontinua funcionando. - Redis fora do ar: vira cache miss, sem erro para o cliente (veja Cache).
- Métricas:
agro_rag_dependency_retries_total,agro_rag_circuit_breaker_state(0 fechado, 1 half-open, 2 aberto) eagro_rag_circuit_breaker_rejections_total, por dependência.
Probes: GET /livez só diz que o processo responde (não consulta nenhuma dependência, para uma queda do banco não reiniciar o pod). GET /readyz devolve 503 apenas quando o banco não responde; o estado do cache (ok, unavailable ou disabled) e do LLM (ok, unavailable quando o circuito do Ollama está aberto, ou fake) é informativo, porque sem eles a API degrada, mas continua atendendo.
Tudo roda no floci, um emulador local da AWS (floci/floci:2.1.0 no compose, credenciais falsas, sem conta na AWS). O serviço one-shot aws-init (agro-rag aws-init) cria a infraestrutura de forma idempotente.
flowchart LR
C[Cliente] -->|POST /ingest/documents| API[FastAPI]
API -->|job queued| DDB[(DynamoDB<br/>agro-ingestion-jobs)]
API -->|publish| SNS{{SNS<br/>agro-ingestion}}
SNS --> Q[SQS<br/>agro-ingestion-jobs]
SNS --> A[SQS<br/>agro-ingestion-audit]
Q -->|long polling| W[worker<br/>agro-rag worker]
W -->|status| DDB
W --> PG[(pgvector)]
Q -.após 3 recebimentos.-> D[DLQ<br/>agro-ingestion-dlq]
curl -s localhost:8000/ingest/documents -H 'content-type: application/json' -H 'Idempotency-Key: meu-upload-1' \
-d '{"source": "solo/palhada.md", "title": "Palhada e nitrogênio", "category": "solo", "content": "..."}'
# {"job_id": "6b01c3...", "status": "queued"} (202)
curl -s localhost:8000/ingest/jobs/6b01c3... # queued -> processing -> done | failed- Idempotência: o
Idempotency-Keygera umjob_idestável e o registro do job é criado com escrita condicional no DynamoDB (attribute_not_exists), então repetir o POST não enfileira duas vezes. No consumo, um job jádoneé confirmado sem reprocessar (a mensagem é entregue pelo menos uma vez), e o pipeline ainda ignora documentos com o mesmo hash de conteúdo. - Visibility timeout e DLQ: a fila tem
VisibilityTimeoutde 60 s eRedrivePolicycommaxReceiveCount3. O worker só apaga a mensagem depois do job concluído; se falhar, ela volta à fila após o timeout e, na terceira falha, vai para a DLQ. Mensagens inválidas (JSON quebrado) seguem o mesmo caminho. - Fan-out: o tópico SNS entrega (raw) para duas filas, a de ingestão e uma de auditoria, que nada consome.
- Falhas da AWS: se o SNS ou o DynamoDB estiverem fora do ar, o
POSTresponde503comRetry-Aftere o registro do job é removido; a API continua servindo as demais rotas. SemAGRO_AWS_ENDPOINT_URLa ingestão assíncrona fica desligada (503). - Testes: os testes de integração rodam contra o floci no compose e na CI (provisionamento, fan-out, redrive, idempotência, retry, DLQ e o loop real do worker).
Manifestos em k8s/ (kustomize): base/ tem namespace, ConfigMap, Secret (credenciais de desenvolvimento), Postgres com pgvector (StatefulSet com PVC), Redis, floci, os Jobs migrate e aws-init, o Deployment api (2 réplicas, Service, probes e HPA) e o Deployment worker. O overlay overlays/kind troca os embeddings por hashing e o LLM por fake, para o cluster local não precisar baixar modelos nem rodar o Ollama (o Ollama não está nos manifestos).
- Probes da API:
startupProbeelivenessProbeem/livez(só o processo),readinessProbeem/readyz(falha só se o banco cair). - HPA:
autoscaling/v2, CPU a 70% do request (250m), de 2 a 6 réplicas. - Os pods usam
enableServiceLinks: false: sem isso o Kubernetes injetaFLOCI_PORT=tcp://..., que o floci tenta ler como número de porta e não sobe (CrashLoopBackOff).
Como validar localmente (kind v0.34.0 e kubectl v1.37.1, cluster Kubernetes v1.37.0):
kind create cluster --name agro-rag
docker compose build api
for i in agro-rag-api:latest pgvector/pgvector:pg17 redis:7.4-alpine floci/floci:2.1.0; do
kind load docker-image $i --name agro-rag
done
kubectl apply -k k8s/overlays/kind
kubectl -n agro-rag wait --for=condition=complete job/migrate job/aws-init --timeout=300s
kubectl -n agro-rag rollout status deploy/api deploy/worker deploy/floci deploy/redis
kubectl -n agro-rag exec deploy/api -- agro-rag ingest
kubectl -n agro-rag port-forward svc/api 18000:80
curl -s localhost:18000/readyzO que foi verificado nesse cluster: os Jobs migrate e aws-init completaram; os Deployments api (2/2), worker, floci e redis e o StatefulSet postgres (PVC de 2 Gi Bound) ficaram Ready; /livez e /readyz responderam 200 ({"status":"ready","database":"ok","cache":"ok","llm":"fake"}); /search e /ask responderam via Service; um POST /ingest/documents foi consumido pelo worker no cluster e o job terminou done; a chave de cache apareceu no Redis do cluster.
HPA: o kind não traz metrics-server, então ele foi instalado à parte (manifesto oficial com --kubelet-insecure-tls). Com as métricas disponíveis o HPA passou a mostrar cpu: 1%/70%. Para forçar o escalonamento, o cache foi desligado (kubectl -n agro-rag set env deploy/api AGRO_REDIS_URL=) e o Locust (imagem dev) rodou dentro do cluster com 150 usuários por 4 min: a CPU chegou a 334% do request e o HPA subiu a api de 2 para 6 réplicas (evento SuccessfulRescale), todas Ready. Foram 116.642 requisições, 0 falhas, 486,6 req/s e p95 de 9 ms, mas esse throughput vem de um cluster de um único nó com o gerador de carga na mesma máquina e embeddings hashing; serve para mostrar o escalonamento, não como benchmark.
Não validado: cluster multi-nó, PVC em storage class de nuvem, Ollama e embeddings reais dentro do cluster, Ingress, escalonamento para baixo do HPA, e o uso de credenciais reais da AWS no lugar do floci.
corpus/: 36 documentos curtos, escritos para este projeto, em quatro categorias (solo,culturas,fitossanidade,manejo).eval/questions.jsonl: 72 perguntas (duas por documento), escritas com vocabulário diferente do texto e rotuladas com os documentos relevantes.
agro-rag evaluate indexa o corpus em uma coleção por configuração, roda todas as perguntas, deduplica os chunks por documento e calcula recall@k, hit@k, nDCG@k, MRR e a latência do retrieval. Cada configuração vira um run no MLflow, com parâmetros, métricas e o CSV por pergunta como artefato.
Os testes rodam sem rede: usam HashingEmbeddings (feature hashing determinístico) e DeterministicChatModel, um chat model do LangChain que responde a partir do contexto, simula o grader e emite a chamada de ferramenta. O banco é um Postgres com pgvector de verdade.
docker compose --profile test build
docker compose --profile test run --rm tests # pytest + cobertura (mínimo 85%)
# ou no host, com o banco do compose em localhost:5433
uv sync
uv run ruff check . && uv run ruff format --check . && uv run mypy src tests
uv run pytestA CI (GitHub Actions) roda lint e tipos, os testes com um serviço Postgres/pgvector, e o build das imagens Docker, executando os testes dentro da imagem e um smoke test de /health, /search e /ask via compose.
Medições feitas localmente em 27/09/2026, com a stack rodando no Docker Compose.
Máquina: Intel Core i7-10700 @ 2,90 GHz (8 núcleos / 16 threads), 31 GiB de RAM, Linux 7.1. Tudo em CPU (a GPU não foi usada pelos containers).
72 perguntas, overlap de 50 caracteres, métricas por documento.
docker compose run --rm -e AGRO_EMBEDDING_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 \
api agro-rag evaluate --chunk-sizes 300,500,800 --overlaps 50
docker compose run --rm api agro-rag evaluate --chunk-sizes 300,500,800 --overlaps 50| Modelo de embeddings | Chunk | Chunks | Recall@1 | Recall@3 | Recall@5 | Recall@10 | nDCG@5 | MRR |
|---|---|---|---|---|---|---|---|---|
| paraphrase-multilingual-MiniLM-L12-v2 | 300 | 173 | 0,660 | 0,875 | 0,910 | 0,951 | 0,817 | 0,805 |
| paraphrase-multilingual-MiniLM-L12-v2 | 500 | 99 | 0,618 | 0,764 | 0,861 | 0,951 | 0,759 | 0,752 |
| paraphrase-multilingual-MiniLM-L12-v2 | 800 | 71 | 0,528 | 0,729 | 0,778 | 0,903 | 0,677 | 0,672 |
| multilingual-e5-small | 300 | 173 | 0,924 | 0,979 | 0,979 | 1,000 | 0,973 | 0,974 |
| multilingual-e5-small | 500 | 99 | 0,931 | 0,979 | 0,979 | 0,993 | 0,976 | 0,981 |
| multilingual-e5-small | 800 | 71 | 0,889 | 0,965 | 0,979 | 1,000 | 0,958 | 0,956 |
A troca de modelo foi o que mais pesou: com chunks de 500, o MRR subiu de 0,752 para 0,981 e o recall@1 de 0,618 para 0,931. Chunks de 500 caracteres com o e5-small viraram o padrão.
API com 4 workers do uvicorn e OMP_NUM_THREADS=2; Locust com espera de 0,1 a 0,5 s entre requisições, rodando na mesma máquina.
docker compose --profile load build
docker compose --profile load run --rm locust # 50 usuários, 2 min
LOCUST_USERS=100 LOCUST_SPAWN_RATE=20 docker compose --profile load run --rm locust # 100 usuários, 2 min| Usuários | Requisições | Falhas | Throughput | p50 | p95 | p99 |
|---|---|---|---|---|---|---|
| 50 | 13.165 | 0 | 109,97 req/s | 110 ms | 380 ms | 650 ms |
| 100 | 11.479 | 0 | 95,86 req/s | 730 ms | 1.100 ms | 1.400 ms |
Com 100 usuários a API já está saturada (~100 req/s): o throughput não sobe e a latência cresce por fila. O gargalo é o encoding da pergunta na CPU. Antes de habilitar múltiplos workers, com um único processo, o mesmo teste de 50 usuários ficava em 41,62 req/s com mediana de 860 ms.
Mesmo cenário do Locust acima (4 workers, 2 min, mesma máquina), com o Redis vazio no início de cada execução. O cenário sorteia entre as 72 perguntas de eval/questions.jsonl, então o cache aquece em poucos segundos: a taxa de acerto foi de 99,2% (19.240 hits e 148 misses no search). É um teto favorável, não a taxa esperada em produção. A partir de certo ponto o throughput passa a ser limitado pelo próprio Locust (espera de 0,1 a 0,5 s por usuário), não pela API.
| Usuários | Cache | Requisições | Falhas | Throughput | p50 | p95 | p99 |
|---|---|---|---|---|---|---|---|
| 50 | sem | 13.683 | 0 | 114,28 req/s | 90 ms | 360 ms | 550 ms |
| 50 | com | 19.385 | 0 | 161,93 req/s | 2 ms | 4 ms | 13 ms |
| 100 | sem | 13.313 | 0 | 111,17 req/s | 580 ms | 900 ms | 1.100 ms |
| 100 | com | 38.864 | 0 | 324,53 req/s | 2 ms | 5 ms | 22 ms |
Queda do Redis sob carga: com 50 usuários por 60 s, o container do Redis foi parado (docker compose stop redis) por cerca de 20 s no meio da execução e religado em seguida. Resultado: 8.019 requisições, 0 falhas; a latência média subiu para 60 ms e o p99 para 690 ms enquanto a API caía para o banco.
/ask com cache de resposta (Ollama em CPU, mesma pergunta repetida 3 vezes): 19,2 s na primeira chamada (miss) e 2 ms e 50 ms nas seguintes (hit). Amostra de uma pergunta, só para ordem de grandeza.
Redis travado (não parado): com docker compose pause redis a conexão é aceita, mas nada responde, o pior caso para um cache. Mesmo teste de 50 usuários por 60 s, com o Redis pausado durante toda a execução:
| Versão | Requisições | Falhas | Throughput | p50 | p95 | p99 |
|---|---|---|---|---|---|---|
| Só timeout de 0,25 s (sem breaker) | 2.142 | 0 | 35,88 req/s | 1.000 ms | 1.100 ms | 1.300 ms |
| Retry + circuit breaker | 6.502 | 0 | 108,85 req/s | 98 ms | 400 ms | 610 ms |
Sem o breaker, cada /search pagava quatro timeouts de 0,25 s (leitura e escrita dos dois caches) e ficava em cerca de 1 s. Com o breaker aberto a API volta a ~110 req/s, equivalente à execução sem cache (114 req/s). O breaker abriu nas primeiras requisições e rejeitou 26.047 chamadas ao Redis durante o teste; ao despausar, fechou sozinho na primeira chamada de teste depois de 10 s.
Ollama parado (docker compose stop ollama, /ask com perguntas diferentes): as duas primeiras requisições levaram 0,59 s e 0,55 s (3 tentativas com backoff, todas recusadas), o circuito abriu e as 12 seguintes responderam 503 em cerca de 21 ms cada (a busca ainda roda antes de o breaker rejeitar), com /search respondendo 200 normalmente em 19 ms. /readyz passou a mostrar "llm": "unavailable". Depois de docker compose start ollama e 30 s, a chamada de teste passou, o circuito fechou e o /ask voltou a responder 200.
Amostra pequena, apenas para ordem de grandeza: 5 perguntas no /ask e 4 no /agent, sequenciais.
| Endpoint | Mediana | Faixa |
|---|---|---|
/ask |
18,6 s | 14,3 a 24,1 s |
/agent |
32,9 s | 24,5 a 37,1 s |
O agente faz uma chamada ao LLM para cada trecho recuperado (grading) antes de responder, por isso custa quase o dobro. Com modelos de 3B em CPU o grader também é conservador: em uma das quatro perguntas ele descartou todos os trechos e o agente respondeu que não encontrou a informação.
126 testes, cobertura de 97% (docker compose --profile test run --rm tests).
src/agro_rag/
api/ rotas, schemas e dependências do FastAPI
db/ modelos SQLAlchemy, sessão async e migrações Alembic
ingestion/ loader de Markdown, chunking, pipeline, jobs (DynamoDB), fila (SNS) e worker (SQS)
evaluation/ métricas de ranking e runner com MLflow
aws.py clientes aioboto3 e provisionamento de SNS, SQS e DynamoDB
cache.py cache-aside no Redis com degradação graciosa
resilience.py retry com backoff e jitter, circuit breaker e transporte HTTP resiliente
embeddings.py sentence-transformers e embeddings determinísticos para testes
retrieval.py busca no pgvector e retriever do LangChain
rag.py cadeia RAG (LCEL)
agent.py agente LangGraph
tools.py ferramenta de cálculo de calagem
llm.py Ollama e chat model determinístico
observability.py
corpus/ base de conhecimento
eval/ perguntas rotuladas
loadtest/ cenário do Locust
MIT. O conteúdo de corpus/ é original e tem finalidade didática; não substitui recomendação de um engenheiro agrônomo.