# RAG do Zero




Pipeline de **RAG (Retrieval-Augmented Generation)** implementado do zero, em
Python, para entender o que cada etapa realmente faz. Segmentação, BM25,
embeddings, busca híbrida e montagem do prompt — cada peça escrita de forma
explícita, sem framework no meio.
A única dependência obrigatória é o NumPy. Não precisa de chave de API, não
baixa modelo e roda offline: o backend de embedding padrão é determinístico e
funciona em qualquer máquina, inclusive na CI.
## Por que existe
Usar LangChain ou LlamaIndex resolve o problema — e esconde as decisões. Este
projeto nasceu do caminho contrário: implementar cada etapa para conseguir
responder *por que* a recuperação falhou quando ela falha.
## Instalação
```bash
git clone https://github.com/annalimonta/rag-do-zero.git
cd rag-do-zero
pip install -e ".[dev]"
```
Para usar embeddings de verdade (modelo multilíngue treinado):
```bash
pip install -e ".[st]"
```
## Uso como biblioteca
```python
from ragzero import Document, RAGPipeline
pipeline = RAGPipeline(chunk_size=500, overlap=80)
pipeline.add_documents([
Document(id="telemetria.md", text=texto_do_arquivo),
])
for trecho in pipeline.search("o que alimenta o controlador PID?", k=2):
print(trecho.doc_id, round(trecho.score, 4))
print(trecho.text)
# Prompt pronto, com as fontes numeradas para o modelo citar
print(pipeline.build_prompt("o que alimenta o controlador PID?", k=2))
```
Plugar um LLM é passar um callable — o pacote não depende de nenhum SDK:
```python
resposta = pipeline.answer("o que alimenta o controlador PID?", meu_llm)
```
## Uso pela linha de comando
```bash
python -m ragzero indexar exemplos/documentos --saida indice.json
python -m ragzero buscar indice.json "o que o RRF resolve?" -k 2
python -m ragzero prompt indice.json "o que o RRF resolve?"
```
Saída real do primeiro comando:
```
3 documento(s) -> 3 trecho(s) indexado(s)
índice gravado em indice.json
[1] busca-hibrida.md#0 (rrf=0.0328)
# Busca híbrida
A busca híbrida combina um índice léxico com um índice denso. O índice léxico,
como o BM25, pontua documentos pela frequência dos termos da consulta...
```
Demonstração completa, com quatro perguntas (uma delas sem resposta nos
documentos):
```bash
python exemplos/demo.py
```
## Como funciona
```mermaid
flowchart LR
A[Documentos] --> B[Segmentação
frases + sobreposição]
B --> C[Índice BM25
léxico]
B --> D[Índice denso
cosseno]
E[Pergunta] --> C
E --> D
C --> F[RRF
fusão dos rankings]
D --> F
F --> G[Prompt com
fontes citadas]
```
| Módulo | Responsabilidade |
| --- | --- |
| [`chunking.py`](ragzero/chunking.py) | Quebra o texto em frases e agrupa em trechos com sobreposição |
| [`bm25.py`](ragzero/bm25.py) | Índice léxico BM25 Okapi com tokenizador e stopwords em português |
| [`embeddings.py`](ragzero/embeddings.py) | Backend offline por hashing e adaptador para `sentence-transformers` |
| [`store.py`](ragzero/store.py) | Matriz de vetores com busca exata por cosseno |
| [`hybrid.py`](ragzero/hybrid.py) | Reciprocal Rank Fusion dos dois rankings |
| [`pipeline.py`](ragzero/pipeline.py) | Orquestra tudo e monta o prompt final |
## Decisões de projeto
**Por que híbrido, e não só embeddings.** Os dois índices erram de formas
diferentes: o léxico não reconhece sinônimos, o denso dilui termos raros
(siglas, códigos de peça, nomes próprios). Rodar os dois e fundir os rankings
cobre os dois buracos.
**Por que RRF, e não soma de scores.** Score BM25 é ilimitado; similaridade de
cosseno vive entre -1 e 1. Somar os dois exige normalizar grandezas que não são
comparáveis. O RRF só olha a posição de cada item em cada lista, então o
problema simplesmente não aparece.
**Por que `blake2b` e não `hash()`.** O hash de strings do CPython é
aleatorizado a cada processo (`PYTHONHASHSEED`). Com ele, um índice gravado em
disco viraria lixo na execução seguinte — o teste
`test_deterministico_entre_processos` trava esse comportamento.
**Por que a busca pode devolver nada.** Quando nenhum dos índices encontra
evidência, o resultado é uma lista vazia em vez do trecho "menos ruim".
Contexto irrelevante no prompt é combustível para alucinação: é melhor o
gerador dizer que não sabe. O corte é configurável em `min_similaridade`.
**Por que o saldo do limite é duro e a sobreposição é mole.** Quando repetir a
cauda do trecho anterior estouraria `max_chars`, o trecho sai sem sobreposição:
o limite de tamanho amarra a janela do modelo, a sobreposição é só heurística.
## Testes
```bash
pytest --cov=ragzero --cov-report=term-missing
ruff check .
```
68 testes, 96% de cobertura. A CI roda a suíte em Python 3.10, 3.11 e 3.12.
## Limitações conhecidas
- O `HashingEmbedder` é um espaço lexical: sinônimos não se aproximam, e
colisões de hash dão similaridade pequena a textos sem relação. Para
recuperação semântica de verdade, use o `SentenceTransformerEmbedder`.
- A busca vetorial é exata (`O(n)` por consulta). É a escolha certa até a casa
dos milhares de trechos; acima disso, um índice aproximado passa a valer.
- O índice fica todo em memória e é serializado em JSON.
## Próximos passos
- [ ] Re-ranking com cross-encoder sobre o topo da fusão
- [ ] Leitura de PDF na indexação
- [ ] Avaliação com métricas de recuperação (recall@k, MRR)
- [ ] Persistência em Postgres com `pgvector`
## Licença
MIT — veja [LICENSE](LICENSE).