Rag Agent Ollama

Rag agent ollama example.

RAG (Retrieval Augmented Generation) Agent using Ollama.

Run it

python examples/ai/rag_agent_ollama.py
"""
RAG (Retrieval Augmented Generation) Agent using Ollama.

This example demonstrates a fully local RAG implementation using:
- Ollama for both embeddings (nomic-embed-text) and LLM (llama3)
- FAISS for fast vector similarity search
- Markdown documents as knowledge base

RAG enhances LLM responses by retrieving relevant context from a document collection,
making it ideal for:
- Question answering over documentation
- Knowledge base chat
- Context-aware assistants
- Domain-specific applications

Prerequisites:
    1. Install Ollama: https://ollama.ai
    2. Pull required models:
       ollama pull llama3
       ollama pull nomic-embed-text
    3. Start Ollama service: ollama serve

Usage:
    # Index documents from a directory
    flux workflow run rag_agent_ollama '{
        "mode": "index",
        "docs_path": "./examples/ai/docs",
        "chunk_size": 500
    }'

    # Query with the indexed knowledge base (resume with execution_id from index step)
    flux workflow resume rag_agent_ollama <execution_id> '{
        "mode": "query",
        "query": "What are Flux workflows?"
    }'

    # Use different models
    flux workflow resume rag_agent_ollama <execution_id> '{
        "mode": "query",
        "query": "How does scheduling work?",
        "llm_model": "qwen2.5:0.5b",
        "embedding_model": "nomic-embed-text"
    }'
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

import faiss
import numpy as np
from ollama import AsyncClient

from flux import ExecutionContext, task, workflow


@task
async def load_markdown_documents(docs_path: str) -> list[dict[str, str]]:
    """
    Load markdown documents from a directory.

    Args:
        docs_path: Path to directory containing markdown files

    Returns:
        List of documents with content and metadata
    """
    docs_dir = Path(docs_path)

    if not docs_dir.exists():
        raise ValueError(f"Directory not found: {docs_path}")

    if not docs_dir.is_dir():
        raise ValueError(f"Path is not a directory: {docs_path}")

    documents = []
    md_files = list(docs_dir.glob("**/*.md"))

    if not md_files:
        raise ValueError(f"No markdown files found in: {docs_path}")

    for md_file in md_files:
        try:
            content = md_file.read_text(encoding="utf-8")
            documents.append(
                {
                    "content": content,
                    "filename": md_file.name,
                    "path": str(md_file.relative_to(docs_dir)),
                },
            )
        except Exception as e:
            # Log but continue if a single file fails
            print(f"Warning: Failed to read {md_file}: {e}")

    return documents


@task
async def chunk_documents(
    documents: list[dict[str, str]],
    chunk_size: int = 500,
    overlap: int = 50,
) -> list[dict[str, Any]]:
    """
    Split documents into smaller chunks with overlap.

    Args:
        documents: List of documents with content and metadata
        chunk_size: Maximum characters per chunk
        overlap: Character overlap between chunks

    Returns:
        List of chunks with content and metadata
    """
    chunks = []

    for doc in documents:
        content = doc["content"]
        filename = doc["filename"]
        path = doc["path"]

        # Split content into chunks
        start = 0
        chunk_idx = 0

        while start < len(content):
            end = start + chunk_size
            chunk_text = content[start:end]

            # Only add non-empty chunks
            if chunk_text.strip():
                chunks.append(
                    {
                        "content": chunk_text,
                        "filename": filename,
                        "path": path,
                        "chunk_index": chunk_idx,
                        "start_char": start,
                        "end_char": min(end, len(content)),
                    },
                )
                chunk_idx += 1

            start = end - overlap

    return chunks


@task.with_options(retry_max_attempts=3, retry_delay=1, retry_backoff=2, timeout=120)
async def generate_embeddings(
    texts: list[str],
    model: str = "nomic-embed-text",
    ollama_url: str = "http://localhost:11434",
) -> np.ndarray:
    """
    Generate embeddings for text chunks using Ollama.

    Args:
        texts: List of text strings to embed
        model: Ollama embedding model to use
        ollama_url: Ollama server URL

    Returns:
        NumPy array of embeddings (shape: [num_texts, embedding_dim])
    """
    try:
        client = AsyncClient(host=ollama_url)
        embeddings = []

        # Generate embeddings for each text
        for text in texts:
            response = await client.embeddings(model=model, prompt=text)
            embeddings.append(response["embedding"])

        return np.array(embeddings, dtype=np.float32)

    except Exception as e:
        raise RuntimeError(
            f"Failed to generate embeddings: {str(e)}. "
            f"Make sure Ollama is running and model '{model}' is available. "
            f"Run: ollama pull {model}",
        ) from e


@task
async def build_faiss_index(embeddings: np.ndarray) -> bytes:
    """
    Build FAISS index from embeddings.

    Args:
        embeddings: NumPy array of embeddings

    Returns:
        Serialized FAISS index (as bytes for Flux state management)
    """
    dimension = embeddings.shape[1]

    # Create a simple flat (brute-force) index for exact search
    index = faiss.IndexFlatL2(dimension)

    # Add embeddings to index
    index.add(embeddings)

    # Serialize index to bytes
    index_bytes = faiss.serialize_index(index).tobytes()

    return index_bytes


@task.with_options(retry_max_attempts=3, retry_delay=1, retry_backoff=2, timeout=120)
async def retrieve_relevant_chunks(
    query: str,
    index_bytes: bytes,
    chunks: list[dict[str, Any]],
    embedding_model: str,
    ollama_url: str,
    top_k: int = 3,
) -> list[dict[str, Any]]:
    """
    Retrieve most relevant chunks for a query using semantic search.

    Args:
        query: User's question
        index_bytes: Serialized FAISS index
        chunks: List of document chunks
        embedding_model: Ollama embedding model
        ollama_url: Ollama server URL
        top_k: Number of chunks to retrieve

    Returns:
        List of most relevant chunks with similarity scores
    """
    # Deserialize FAISS index
    index = faiss.deserialize_index(np.frombuffer(index_bytes, dtype=np.uint8))

    # Generate query embedding
    query_embedding = await generate_embeddings([query], embedding_model, ollama_url)

    # Search for similar chunks
    distances, indices = index.search(query_embedding, top_k)

    # Build results with metadata
    results = []
    for idx, distance in zip(indices[0], distances[0]):
        chunk = chunks[idx]
        results.append(
            {
                "content": chunk["content"],
                "filename": chunk["filename"],
                "path": chunk["path"],
                "chunk_index": chunk["chunk_index"],
                "similarity_score": float(distance),
            },
        )

    return results


@task.with_options(retry_max_attempts=3, retry_delay=1, retry_backoff=2, timeout=60)
async def generate_rag_response(
    query: str,
    context_chunks: list[dict[str, Any]],
    model: str,
    ollama_url: str,
) -> dict[str, Any]:
    """
    Generate response using LLM with retrieved context.

    Args:
        query: User's question
        context_chunks: Retrieved relevant chunks
        model: Ollama LLM model to use
        ollama_url: Ollama server URL

    Returns:
        Dictionary with response and sources
    """
    try:
        client = AsyncClient(host=ollama_url)

        # Format context from retrieved chunks
        context_parts = []
        for i, chunk in enumerate(context_chunks, 1):
            context_parts.append(f"[Source {i}: {chunk['filename']}]\n{chunk['content'].strip()}")

        context = "\n\n".join(context_parts)

        # Create prompt with context
        prompt = f"""Context from documentation:

{context}

Question: {query}

Answer the question based on the context provided above. If the context doesn't contain relevant information, say so clearly. Be concise and accurate."""

        # Generate response
        response = await client.chat(
            model=model,
            messages=[
                {
                    "role": "system",
                    "content": "You are a helpful assistant that answers questions based on provided documentation context. Always cite your sources.",
                },
                {"role": "user", "content": prompt},
            ],
        )

        answer = response["message"]["content"]

        # Include sources in response
        sources = [
            {"filename": chunk["filename"], "path": chunk["path"]} for chunk in context_chunks
        ]

        return {
            "answer": answer,
            "sources": sources,
            "context_chunks": context_chunks,
        }

    except Exception as e:
        raise RuntimeError(
            f"Failed to generate response: {str(e)}. "
            f"Make sure Ollama is running and model '{model}' is available. "
            f"Run: ollama pull {model}",
        ) from e


@workflow
async def rag_index_documents(ctx: ExecutionContext[dict[str, Any]]):
    """
    Index documents and save to disk for later querying.

    This workflow builds a vector index from markdown documents and saves it for reuse.
    Use this once to index your documents, then use rag_query_documents for queries.

    Input format:
    {
        "docs_path": "./path/to/docs",           # Required: path to markdown docs
        "index_name": "my_docs",                 # Required: unique name for this index
        "chunk_size": 500,                       # Optional: chars per chunk
        "overlap": 50,                           # Optional: chunk overlap
        "embedding_model": "nomic-embed-text",   # Optional: embedding model
        "ollama_url": "http://localhost:11434"   # Optional: Ollama server URL
    }

    Returns:
        Dictionary with indexing results and index location
    """
    input_data = ctx.input or {}

    # Required parameters
    docs_path = input_data.get("docs_path")
    index_name = input_data.get("index_name")

    if not docs_path:
        return {
            "error": "Missing required parameter 'docs_path'",
            "execution_id": ctx.execution_id,
        }

    if not index_name:
        return {
            "error": "Missing required parameter 'index_name'",
            "execution_id": ctx.execution_id,
        }

    # Configuration
    chunk_size = input_data.get("chunk_size", 500)
    overlap = input_data.get("overlap", 50)
    embedding_model = input_data.get("embedding_model", "nomic-embed-text")
    ollama_url = input_data.get("ollama_url", "http://localhost:11434")

    # Step 1: Load markdown documents
    documents = await load_markdown_documents(docs_path)

    # Step 2: Chunk documents
    chunks = await chunk_documents(documents, chunk_size, overlap)

    if not chunks:
        return {
            "error": "No chunks created from documents",
            "execution_id": ctx.execution_id,
        }

    # Step 3: Generate embeddings
    chunk_texts = [chunk["content"] for chunk in chunks]
    embeddings = await generate_embeddings(chunk_texts, embedding_model, ollama_url)

    # Step 4: Build FAISS index
    index_bytes = await build_faiss_index(embeddings)

    # Step 5: Save index and chunks to disk
    import pickle
    from pathlib import Path

    # Save to .flux directory
    index_dir = Path.home() / ".flux" / "rag_indexes"
    index_dir.mkdir(parents=True, exist_ok=True)

    index_file = index_dir / f"{index_name}_index.faiss"
    chunks_file = index_dir / f"{index_name}_chunks.pkl"
    metadata_file = index_dir / f"{index_name}_metadata.json"

    # Save FAISS index
    index_file.write_bytes(index_bytes)

    # Save chunks
    with open(chunks_file, "wb") as f:
        pickle.dump(chunks, f)

    # Save metadata
    import json

    metadata = {
        "index_name": index_name,
        "docs_path": str(docs_path),
        "num_documents": len(documents),
        "num_chunks": len(chunks),
        "chunk_size": chunk_size,
        "overlap": overlap,
        "embedding_model": embedding_model,
        "created_at": str(documents[0] if documents else ""),
    }

    with open(metadata_file, "w") as f:
        json.dump(metadata, f, indent=2)

    return {
        "status": "indexed",
        "index_name": index_name,
        "index_file": str(index_file),
        "num_documents": len(documents),
        "num_chunks": len(chunks),
        "embedding_model": embedding_model,
        "execution_id": ctx.execution_id,
    }


@workflow
async def rag_query_documents(ctx: ExecutionContext[dict[str, Any]]):
    """
    Query pre-indexed documents using RAG.

    This workflow loads a previously built index and answers questions.
    Run rag_index_documents first to create the index.

    Input format:
    {
        "index_name": "my_docs",                 # Required: name of pre-built index
        "query": "Your question here",           # Required: question to answer
        "llm_model": "llama3",                   # Optional: LLM model
        "top_k": 3,                             # Optional: chunks to retrieve
        "ollama_url": "http://localhost:11434"   # Optional: Ollama server URL
    }

    Returns:
        Dictionary with answer, sources, and metadata
    """
    input_data = ctx.input or {}

    # Required parameters
    index_name = input_data.get("index_name")
    query = input_data.get("query")

    if not index_name:
        return {
            "error": "Missing required parameter 'index_name'",
            "execution_id": ctx.execution_id,
        }

    if not query:
        return {
            "error": "Missing required parameter 'query'",
            "execution_id": ctx.execution_id,
        }

    # Configuration
    llm_model = input_data.get("llm_model", "llama3")
    top_k = input_data.get("top_k", 3)
    ollama_url = input_data.get("ollama_url", "http://localhost:11434")

    # Load index and chunks from disk
    import json
    import pickle
    from pathlib import Path

    index_dir = Path.home() / ".flux" / "rag_indexes"
    index_file = index_dir / f"{index_name}_index.faiss"
    chunks_file = index_dir / f"{index_name}_chunks.pkl"
    metadata_file = index_dir / f"{index_name}_metadata.json"

    # Check if index exists
    if not index_file.exists():
        return {
            "error": f"Index '{index_name}' not found. Run rag_index_documents first.",
            "execution_id": ctx.execution_id,
        }

    # Load FAISS index
    index_bytes = index_file.read_bytes()

    # Load chunks
    with open(chunks_file, "rb") as f:
        chunks = pickle.load(f)

    # Load metadata
    with open(metadata_file) as f:
        metadata = json.load(f)

    embedding_model = metadata.get("embedding_model", "nomic-embed-text")

    # Retrieve relevant chunks
    relevant_chunks = await retrieve_relevant_chunks(
        query,
        index_bytes,
        chunks,
        embedding_model,
        ollama_url,
        top_k,
    )

    # Generate response with context
    result = await generate_rag_response(query, relevant_chunks, llm_model, ollama_url)

    return {
        "query": query,
        "answer": result["answer"],
        "sources": result["sources"],
        "num_sources": len(result["sources"]),
        "index_name": index_name,
        "num_chunks_indexed": metadata.get("num_chunks", 0),
        "execution_id": ctx.execution_id,
    }


if __name__ == "__main__":  # pragma: no cover
    import json

    # Get the docs path relative to this file
    current_dir = Path(__file__).parent
    docs_path = current_dir / "docs"

    if not docs_path.exists():
        print(f"Error: Sample docs not found at {docs_path}")
        print("Please create the docs directory with markdown files first.")
        exit(1)

    try:
        print("=" * 80)
        print("RAG Agent Demo - Two-Workflow Pattern (Production-Ready)")
        print("=" * 80 + "\n")

        # Step 1: Index documents once
        print("Step 1: Indexing documents...\n")
        result = rag_index_documents.run(
            {
                "docs_path": str(docs_path),
                "index_name": "flux_docs",
                "chunk_size": 500,
            },
        )

        if result.has_failed:
            raise Exception(f"Indexing failed: {result.output}")

        print(
            f"✓ Indexed {result.output.get('num_chunks')} chunks from {result.output.get('num_documents')} documents",
        )
        print(f"✓ Index saved as: {result.output.get('index_name')}\n")
        print("=" * 80 + "\n")

        # Step 2: Query 1
        print("Step 2: Query 1 - What are Flux workflows?\n")
        result = rag_query_documents.run(
            {
                "index_name": "flux_docs",
                "query": "What are Flux workflows?",
                "top_k": 3,
            },
        )

        if result.has_failed:
            raise Exception(f"Query failed: {result.output}")

        print(f"Answer: {result.output.get('answer')}\n")
        print(f"Sources: {json.dumps(result.output.get('sources'), indent=2)}\n")
        print("=" * 80 + "\n")

        # Step 3: Query 2 (reusing the same index - much faster!)
        print("Step 3: Query 2 - How does task caching work? (reusing index)\n")
        result = rag_query_documents.run(
            {
                "index_name": "flux_docs",
                "query": "How does task caching work in Flux?",
                "top_k": 3,
            },
        )

        if result.has_failed:
            raise Exception(f"Query failed: {result.output}")

        print(f"Answer: {result.output.get('answer')}\n")
        print(f"Sources: {json.dumps(result.output.get('sources'), indent=2)}\n")

        print("=" * 80)
        print("✓ RAG agent working successfully!")
        print("✓ Index reused for multiple queries (efficient!)")
        print("=" * 80)

    except Exception as e:
        print(f"Error: {e}")
        print("\nMake sure:")
        print("1. Ollama is running: ollama serve")
        print("2. Required models are pulled:")
        print("   ollama pull llama3")
        print("   ollama pull nomic-embed-text")

Last verified against Flux 0.56.0.