# RAG do Zero ![Python](https://img.shields.io/badge/python-3.10%2B-3670A0?style=for-the-badge&logo=python&logoColor=ffdd54) ![NumPy](https://img.shields.io/badge/numpy-013243?style=for-the-badge&logo=numpy&logoColor=white) ![Testes](https://img.shields.io/badge/testes-68%20passando-2ea44f?style=for-the-badge) ![Licença](https://img.shields.io/badge/licen%C3%A7a-MIT-blue?style=for-the-badge) 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).