# Prompt de Implementación

## Contexto del sistema

Implementa un módulo de Retrieval-Augmented Generation (RAG) en Python como paquete independiente. Expone una API pública mínima y cualquier consumidor (el chatbot, un script, una API REST) la invoca a voluntad.

## Principios de diseño

El contrato público se reduce a tres funciones:
`index_documents()`, `query()` y `reset()`

El pipeline de chunking, embedding y búsqueda se implementa con las librerías sentence-transformers, chromadb. No usar LangChain

ChromaDB guarda los vectores en disco (`./rag_db/`).

## Stack tecnológica

| Bloque          | Herramienta                   | Razón                                                  |
| --------------- | ----------------------------- | ------------------------------------------------------ |
| Chunking        | Código Python puro            | RecursiveCharacterSplitter propio, sin dependencias    |
| Embeddings      | `sentence-transformers`       | paraphrase-multilingual-MiniLM-L12-v2, 384-dim, gratis |
| Vector store    | `ChromaDB`                    | Open source, cero config, soporta filtros por metadata |
| Lectura de docs | `pathlib` + `pypdf2` opcional | .txt nativo, .pdf con pypdf2 si está instalado         |

## Estructura de archivos a generar

```
rag/
├── __init__.py Exporta la API pública del módulo
├── config.py Constantes del módulo RAG
├── chunker.py Partición de texto en chunks
├── embedder.py Carga del modelo y generación de embeddings
├── store.py Abstracción sobre ChromaDB (escritura y lectura)
├── loader.py Lectura de archivos
├── retriever.py Función pública principal: query()
├── indexer.py  Función pública principal: index_documents()
├── requirements.txt Dependencias del módulo RAG
└── README.md Instrucciones y documentación
```

## API pública del módulo

```python
# rag/__init__.py — exportaciones públicas
```

### `index_documents(source: str | Path, collection: str = "default") -> IndexResult`

Lee uno o varios documentos, los parte en chunks, los vectoriza y los
guarda en ChromaDB. Idempotente: si un documento ya fue indexado con el
mismo `doc_id`, se omite sin error (a menos que se pase `force=True`).

```python
@dataclass
class IndexResult:
    collection: str
    docs_processed: int
    chunks_added: int
    chunks_skipped: int
    errors: list[str]
```

### `query(text: str, collection: str = "default", top_k: int = 3) -> QueryResult`

Vectoriza el texto de consulta y recupera los `top_k` chunks más similares
de la colección indicada.

```python
@dataclass
class QueryResult:
    chunks: list[Chunk]
    collection: str
    top_k: int

@dataclass
class Chunk:
    text: str
    score: float # similitud coseno (0.0–1.0)
    source: str # nombre del archivo de origen
    chunk_index: int # posición del chunk dentro del documento fuente
    metadata: dict # cualquier metadata adicional del documento
```

### `reset(collection: str = "default") -> None`

Borra todos los vectores de una colección, para reindexar desde cero.

### `rag/config.py`

```python
from pathlib import Path

EMBEDDING_MODEL = "paraphrase-multilingual-MiniLM-L12-v2"

CHROMA_PERSIST_DIR = Path("./rag_db")

DEFAULT_COLLECTION = "default"
DEFAULT_TOP_K = 3
MIN_SCORE = 0.30
SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf"}
```

### `rag/chunker.py`

Partición de texto en fragmentos con solapamiento. Sin dependencias externas.

```python
"""
divide texto en fragmentos con solapamiento.

Estrategia:
  1. Intentar partir por párrafos dobles (\n\n).
  2. Si un párrafo excede CHUNK_SIZE, partir por salto de línea simple (\n).
  3. Si sigue siendo grande, partir por punto seguido de espacio (". ").

El solapamiento se agrega tomando los últimos CHUNK_OVERLAP caracteres del
chunk anterior como prefijo del siguiente. Esto evita que una oración
importante quede partida sin contexto en ninguno de los dos chunks.

Función pública:
  split(text: str, chunk_size: int = CHUNK_SIZE,
        overlap: int = CHUNK_OVERLAP) -> list[str]

  Args:
    text:       Texto completo del documento.
    chunk_size: Tamaño máximo de cada chunk en caracteres.
    overlap:    Caracteres de solapamiento entre chunks contiguos.

  Returns:
    Lista de strings. Cada string tiene entre MIN_CHUNK_LENGTH y
    chunk_size caracteres (salvo el último, que puede ser más corto).
    Chunks menores a MIN_CHUNK_LENGTH se descartan.
"""
```

### `rag/embedder.py`

Carga del modelo y generación de vectores. Singleton para no recargar el
modelo en cada llamada.

```python
"""
genera vectores de texto con sentence-transformers.

Patrón singleton:
  El modelo se carga una sola vez en memoria cuando se llama a get_embedder()
  por primera vez. Las llamadas posteriores reutilizan la instancia.

Funciones públicas:

  get_embedder() -> SentenceTransformer
    Retorna la instancia singleton del modelo. Carga en la primera llamada.

  embed(texts: list[str]) -> list[list[float]]
    Genera embeddings para una lista de textos.
    Usa batch encoding interno para eficiencia.

    Args:
      texts: Lista de strings a vectorizar. Puede ser un solo elemento.

    Returns:
      Lista de vectores. Cada vector es una lista de 384 floats.
      El orden de salida corresponde al orden de entrada.

    Nota: sentence-transformers normaliza los vectores a norma unitaria
    por defecto. Esto hace que la distancia coseno sea equivalente al
    producto punto, lo que ChromaDB aprovecha internamente.

  embed_one(text: str) -> list[float]
    Atajo para vectorizar un solo string. Equivalente a embed([text])[0].
"""
```

### `rag/loader.py`

Lectura de archivos desde disco. Retorna texto plano independientemente
del formato de origen.

```python
"""
lee documentos y retorna su contenido como texto plano.

Función pública:
  load(source: str | Path) -> list[Document]

  Comportamiento según el argumento:
    - Si `source` es un archivo .txt o .md: carga ese archivo.
    - Si `source` es un archivo .pdf: extrae el texto de todas las páginas.
    - Si `source` es un directorio: carga recursivamente todos los archivos
      cuya extensión esté en SUPPORTED_EXTENSIONS.
    - Si `source` no existe o no tiene extensión soportada: loguea un warning
      y retorna lista vacía (no lanza excepción).

  Returns:
    list[Document]

@dataclass
class Document:
    text:     str       # contenido completo del archivo como texto plano
    source:   str       # nombre del archivo (sin path completo)
    path:     Path      # path absoluto del archivo
    metadata: dict      # {"source": ..., "extension": ..., "size_bytes": ...}

Manejo de PDF:
  Si pypdf2 está instalado: extrae texto página a página, las concatena
  con "\n--- página N ---\n" como separador.
  Si pypdf2 no está instalado: loguea un warning descriptivo y omite el
  archivo sin fallar. El resto de documentos se procesan normalmente.

Manejo de encoding:
  Intenta UTF-8 primero. Si falla, intenta latin-1.
  Si ambos fallan, loguea el error y omite el archivo.
"""
```

### `rag/store.py`

Abstracción sobre ChromaDB. Toda interacción con la base de datos vectorial
pasa por este módulo.

```python
"""
abstracción sobre ChromaDB para operaciones de escritura y lectura.

Patrón de inicialización:
  ChromaDB se inicializa con PersistentClient apuntando a CHROMA_PERSIST_DIR.
  Si el directorio no existe, ChromaDB lo crea automáticamente.
  La colección se crea si no existe; si ya existe, se reutiliza.

Funciones públicas:

  get_collection(name: str) -> chromadb.Collection
    Retorna (o crea) la colección con el nombre indicado.

  upsert(collection_name: str,
         ids:        list[str],
         embeddings: list[list[float]],
         documents:  list[str],
         metadatas:  list[dict]) -> None
    Inserta o actualiza chunks en la colección.
    ChromaDB usa `ids` para deduplicar: si un id ya existe, actualiza.
    Esto hace que index_documents() sea idempotente por defecto.

  similarity_search(collection_name: str,
                    query_embedding: list[float],
                    top_k: int) -> list[dict]
    Busca los `top_k` chunks más similares al vector de consulta.

    Returns:
      Lista de dicts, cada uno con:
        {
          "text":        str,
          "score":       float,   # distancia coseno convertida a similitud: 1 - distance
          "source":      str,
          "chunk_index": int,
          "metadata":    dict,
        }
      Ordenados por score descendente. Chunks con score < MIN_SCORE excluidos.

  delete_collection(name: str) -> None

  collection_exists(name: str) -> bool

Generación de IDs:
  Cada chunk recibe un ID determinístico:
    id = sha256(f"{source}::{chunk_index}::{text[:50]}")[:16]
"""
```

### `rag/indexer.py`

Función pública `index_documents()`. Orquesta loader → chunker → embedder → store.

```python
"""
orquesta el pipeline completo de indexación.

Flujo interno de index_documents():

  source (str | Path)
        │
        ▼
   [1] loader.load(source)
       Retorna list[Document]. Si vacía entonces retornar IndexResult con 0 procesados.
        │
        ▼
   [2] Para cada Document:
       chunker.split(doc.text)
       Retorna list[str] de chunks limpios.
        │
        ▼
   [3] embedder.embed(chunks)
       Retorna list[list[float]] un vector por chunk.
        │
        ▼
   [4] Construir ids, metadatas
       id        = sha256(source + "::" + chunk_index)[:16]
       metadata  = {source, chunk_index, **doc.metadata}
        │
        ▼
   [5] store.upsert(collection, ids, embeddings, chunks, metadatas)
        │
        ▼
   IndexResult(docs_processed, chunks_added, chunks_skipped, errors)

Función pública:

  index_documents(
      source:     str | Path,
      collection: str  = DEFAULT_COLLECTION,
      force:      bool = False,
  ) -> IndexResult

  Args:
    source:     Archivo o directorio a indexar.
    collection: Nombre de la colección en ChromaDB.
    force:      Si True, elimina la colección antes de indexar (reindexación
                completa). Si False (default), es idempotente: chunks ya
                existentes se omiten.

  Returns:
    IndexResult con el resumen de la operación.

  Raises:
    No lanza excepciones al caller. Errores por archivo se acumulan en
    IndexResult.errors y se loguean con nivel WARNING.
"""
```

### `rag/retriever.py`

Función pública `query()`. Es el único punto de contacto que necesita el chatbot.

```python
"""
Retriever — función pública de consulta al índice vectorial.

Flujo interno de query():

  text (str)
      │
      ▼
  [1] Validación básica
      strip() · rechazar si vacío → retornar QueryResult vacío
      │
      ▼
  [2] embedder.embed_one(text)
      Genera el vector de consulta (384-dim).
      │
      ▼
  [3] store.similarity_search(collection, query_embedding, top_k)
      Busca los chunks más similares. Filtra por MIN_SCORE.
      │
      ▼
  [4] Construir list[Chunk] ordenada por score desc.
      │
      ▼
  QueryResult(chunks, collection, top_k)

Función pública:

  query(
      text:       str,
      collection: str = DEFAULT_COLLECTION,
      top_k:      int = DEFAULT_TOP_K,
  ) -> QueryResult

  Args:
    text:       Texto de la consulta (el mensaje del usuario, por ejemplo).
    collection: Colección a consultar.
    top_k:      Número máximo de chunks a retornar.

  Returns:
    QueryResult. Si no hay resultados relevantes (todos bajo MIN_SCORE o
    colección vacía), retorna QueryResult con chunks=[].

  Raises:
    No lanza excepciones al caller. Cualquier error interno retorna
    QueryResult vacío y loguea el error con nivel ERROR.
"""
```

### `rag/requirements.txt`

```
# Embeddings
sentence-transformers>=3.0.0

# Vector store persistente
chromadb>=0.5.0

# Lectura de PDFs
pypdf2>=3.0.0
```
