Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/geremyjampiersalasgarcia-eng/Caso_Practico_Semillero_IA/llms.txt

Use this file to discover all available pages before exploring further.

The RAG (Retrieval-Augmented Generation) pipeline in Mesa de Ayuda IA is divided into two distinct phases with different timing characteristics. Phase 1 — offline ingestion runs once (or whenever the knowledge base is updated) via python scripts/ingest.py; it reads the raw text files, chunks them, generates vector embeddings, and persists everything to ChromaDB on disk. Phase 2 — online retrieval runs on every user query at inference time; it embeds the question with the same model, performs a cosine similarity search against the appropriate ChromaDB collection, and injects the top-4 matching chunks into the agent’s system prompt before calling Gemini. Separating these two phases means the expensive embedding work happens once offline, and every live query enjoys sub-second retrieval from the local vector database without any external calls beyond the Gemini LLM itself.

Phase 1: Document Ingestion

The ingestion pipeline is a four-stage sequential process that transforms raw .txt files into searchable vector collections.
1

Load — loader.py

load_documents() scans a directory for .txt and .pdf files using LangChain’s TextLoader (UTF-8 encoding) and PyPDFLoader respectively. Each file is read into a LangChain Document object and enriched with metadata — specifically a filename field that is later surfaced in AgentResult.sources so users know which document each answer came from.
if file_path.suffix.lower() == ".txt":
    loader = TextLoader(str(file_path), encoding="utf-8")
    docs = loader.load()
elif file_path.suffix.lower() == ".pdf":
    loader = PyPDFLoader(str(file_path))
    docs = loader.load()

# Agregar nombre de archivo como metadato
for doc in docs:
    doc.metadata["filename"] = file_path.name
2

Split — splitter.py

split_documents() passes the raw Document list through RecursiveCharacterTextSplitter configured with chunk_size=1000, chunk_overlap=200, and separators ["\n\n", "\n", ".", " ", ""]. The splitter tries each separator in order, so paragraph breaks (\n\n) are preferred over sentence breaks (.) and word breaks ( ), preserving semantic coherence within each chunk.
def split_documents(documents: List[Document]) -> List[Document]:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=settings.CHUNK_SIZE,
        chunk_overlap=settings.CHUNK_OVERLAP,
        separators=["\n\n", "\n", ".", " ", ""]
    )
    chunks = splitter.split_documents(documents)
    return chunks
3

Embed — embeddings.py

Each chunk’s text is converted into a high-dimensional numerical vector using GoogleGenerativeAIEmbeddings with model="models/gemini-embedding-001". The embedding function is shared between the ingestion script and the query-time retriever — the same model that creates the vectors is used to embed user questions, guaranteeing that similarity comparisons are meaningful.
from app.core.llm import get_embeddings

def get_rag_embeddings():
    """Retorna el modelo de embeddings para operaciones RAG."""
    return get_embeddings()
4

Store — vectorstore.py

index_documents() calls get_vectorstore() to initialize (or open) the appropriate ChromaDB collection, then calls vectorstore.add_documents(documents) to persist the embedded chunks. Each collection is stored under settings.CHROMA_PERSIST_DIR (default: data/chroma_db/), meaning the vectors survive backend restarts without re-ingestion.
def get_vectorstore(collection_name: str) -> Chroma:
    embeddings = get_rag_embeddings()
    os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
    vectorstore = Chroma(
        collection_name=collection_name,
        embedding_function=embeddings,
        persist_directory=settings.CHROMA_PERSIST_DIR
    )
    return vectorstore

def index_documents(documents, collection_name: str):
    vectorstore = get_vectorstore(collection_name)
    vectorstore.add_documents(documents)
    logger.info("Documentos indexados exitosamente",
                collection=collection_name,
                doc_count=len(documents))

Document-to-Collection Mapping

Each source document is ingested into its own dedicated ChromaDB collection, providing clean knowledge isolation between agents:
Source DocumentChromaDB CollectionAgent OwnerApprox. Chunks
01_Catalogo_Productos_Precios.txt (~1,200 chars)col_catalogoagente_catalogo, agente_multimodal2
02_Politicas_Comerciales_Descuentos_Credito.txt (~1,000 chars)col_politicasagente_politicas1–2
03_Proceso_Ventas_CRM.txt (~1,400 chars)col_proceso_ventasagente_proceso_ventas2
Chunk counts are approximate and depend on the exact content boundaries. Because the source documents are short (~1,000–1,500 characters), the splitter generates very few chunks per file — which is intentional. Fewer, larger chunks mean each retrieval result carries more complete context about a topic, reducing the chance of a partial answer.

Phase 2: Query-time Retrieval

At inference time, retrieve_context() in retriever.py performs the following steps for every agent that is invoked:
def retrieve_context(
    query: str,
    collection_name: str,
    top_k: Optional[int] = None
) -> List[Dict[str, Any]]:
    if top_k is None:
        top_k = settings.RETRIEVAL_TOP_K   # default: 4

    vectorstore = get_vectorstore(collection_name)
    docs = vectorstore.similarity_search(query, k=top_k)

    results = []
    for doc in docs:
        results.append({
            "content": doc.page_content,
            "metadata": doc.metadata
        })
    return results
The retrieval flow step-by-step:
1

Embed the question

The user’s question is passed to the same GoogleGenerativeAIEmbeddings instance used during ingestion. This produces a vector in the same embedding space as the stored chunks, making cosine distances meaningful.
2

Cosine similarity search

ChromaDB’s similarity_search(query, k=4) computes the cosine similarity between the question vector and every stored chunk vector in the target collection, returning the top_k=4 closest matches.
3

Build retrieval context

The base agent iterates over the returned documents, assembling a context_text string with labeled fragments and a sources list containing each document’s filename, a 200-character snippet, and its relevance score.
for i, ctx in enumerate(contexts):
    snippet = ctx["content"]
    metadata = ctx["metadata"]
    doc_name = metadata.get("filename", f"doc_{i}")

    context_text += f"--- Fragmento {i+1} de {doc_name} ---\n{snippet}\n\n"

    sources.append({
        "document_name": doc_name,
        "content_snippet": (snippet[:200] + "...") if len(snippet) > 200 else snippet,
        "relevance_score": metadata.get("score", 0.0)
    })
4

Inject into prompt

The retrieved context is appended to the agent’s system prompt before the Gemini LLM call: system_content = f"{base_prompt}\n\n### CONTEXTO RECUPERADO:\n{context_text}". The LLM never answers from its pre-trained weights alone — every response is grounded in the retrieved document fragments.

Why top-k = 4?

With source documents of only 1,000–1,500 characters that produce 1–2 chunks each, retrieving 4 chunks covers the entirety (or nearly all) of the relevant collection. Requesting more chunks would simply return lower-relevance content and bloat the prompt unnecessarily, while requesting fewer might miss complementary information that spans a chunk boundary.
The top_k value is configurable via settings.RETRIEVAL_TOP_K. For larger document corpora in future iterations, it can be raised independently of any code changes.

ChromaDB Persistence

ChromaDB is run in embedded local mode, storing the vector index on disk rather than requiring a separate database service. All three collections share a single persist directory:
# Default path (configurable via CHROMA_PERSIST_DIR in .env)
backend/data/chroma_db/
├── col_catalogo/
├── col_politicas/
└── col_proceso_ventas/
Key operational notes:
  • One collection per agent — knowledge isolation prevents cross-contamination between domains. An agente_politicas query never accidentally retrieves catalog text.
  • Persistence across restarts — the Chroma(persist_directory=...) argument means the index is automatically reloaded when get_vectorstore() is called after a backend restart, with no warm-up delay.
  • Re-indexing — running python scripts/ingest.py again clears the existing collections and rebuilds them from scratch. This is the recommended workflow whenever source documents are updated.
# Re-index all collections (run from backend/ directory)
python scripts/ingest.py
If the backend starts before ingest.py has been run, queries will return empty results and agents will respond with a “collection not found” error. Always run the ingestion script at least once before the first backend startup. See the quickstart guide for the correct service startup order.

Chunking Strategy

The RecursiveCharacterTextSplitter is configured with the following parameters:
ParameterValueRationale
chunk_size1000 charactersMatches the approximate length of each source document, so each chunk holds a coherent topic section
chunk_overlap200 charactersEnsures that rules or prices that fall exactly at a split boundary are fully captured in at least one chunk
separators["\n\n", "\n", ".", " ", ""]Prioritizes semantic splits (paragraphs, lines) over mechanical splits (mid-sentence)
Because the three source documents are short (1,000–1,500 characters each), the splitter generates only 1–2 chunks per file. This is an ideal scenario: each chunk holds a nearly complete section of the knowledge base, and the 200-character overlap acts as a safety net for boundary cases. Example partition for 01_Catalogo_Productos_Precios.txt (~1,200 chars):
ChunkApprox. Content
Chunk 1Smartphone product lines (Patito Lite, Patito Pro) — specs, prices, availability
Chunk 2Laptop product lines + pricing notes (the last 200 chars of Chunk 1 repeat here due to overlap)
The overlap means the LLM always has enough surrounding context to correctly interpret a price or policy that sits at a document section boundary. Without overlap, a chunk ending with “precio de lista: USD” and a chunk beginning with “1,299 — disponible en stock” would each be incomplete.

Embedding Model

Mesa de Ayuda IA uses models/gemini-embedding-001 from Google’s Generative AI platform for all vector operations:
# core/llm.py (called by embeddings.py via get_embeddings())
GoogleGenerativeAIEmbeddings(model="models/gemini-embedding-001")
This model was selected for three reasons:
  1. Spanish text qualitygemini-embedding-001 is trained on multilingual corpora including Spanish, producing embeddings that capture semantic similarity for Spanish sales terminology accurately. A pure English embedding model would underperform on queries like “descuento máximo sin autorización del gerente”.
  2. Gemini ecosystem consistency — using the same vendor for both the embedding model (gemini-embedding-001) and the generative LLM (gemini-flash-lite-latest) keeps the integration surface minimal: a single GOOGLE_API_KEY covers both, and both are accessed through the langchain-google-genai package.
  3. Semillero IA requirement — the Google Gemini stack was specified as the required AI platform for this project, and gemini-embedding-001 is the current production embedding model in that ecosystem.
The embedding model is initialized once through get_embeddings() in core/llm.py and reused by both the ingestion script and the query-time retriever. There is no separate initialization cost at query time — the model client is already warm when the first user message arrives.

Build docs developers (and LLMs) love