DEV Community

Daniel
Daniel

Posted on

RAG desde cero con Ruby

La idea

¿Alguna vez deseaste preguntarle algo a un LLM sobre tus propios documentos?, pues bien Esto es exactamente lo que hace esta app.

Subes un PDF o TXT, la aplicacion lo "digiere" y despus puedes hacerle preguntas como si fuera un experto en ese documento.

Ubicacion del proyecto

https://github.com/Daniel-Penaloza/sinatra_rag

Ejemplo real:

  • Subes la documentacion de uso de endpoints.
  • Preguntas: ¿Que necesito para poder hacer una peticion a X endpoint?
  • La IA te responde basandose SOLO en esa documentacion.

🧠 Componentes Rag

  • Documentos: En este caso utilizaremos PDF o archivos TXT.

  • Document Loaders: Como aqui no utilizaremos langchain como wrapper, usaremos la gema pdf-reader para leer el texto de un pdf y posteriormente almacenar ese texto.

  • Chunkers: Tambien llamados dividores de texto, para tener la informacion dividida y que de esta manera sea mas manejable.

  • Embeddings: Con esto codificamos el texto en representaciones numericas (creacion de vectores).

  • Base de datos vectoriales: Utilizada para guardar nuestros vectores.

Tech Stack

Qué hace Tecnología
Backend Ruby + Sinatra
Base de datos PostgreSQL + pgvector
Procesamiento async Sidekiq + Redis
LLM OpenAI (embeddings + chat)

¿Por que este Tech Stack?

Ruby es la tecnologia que mas utilizo para programar y en este caso solo busco entender como funciona RAG sin utilizar frameworks como LangChain que son un tipo de wrapper para la mayoria de los LLM's disponibles en el mercado.

Construirlo de esta manera me ayudo a entender cada pieza del rompecabezas y creo que de esta manera tu que lo estas leyendo tambien puedes entenderlo.


Como funciona

1. Subes un documento

Tu PDF ──> La app lo guarda ──> Entra a la cola de procesamiento
Enter fullscreen mode Exit fullscreen mode

2. La app lo procesa (en segundo plano)

Documento
    │
    ▼
Extrae el texto
    │
    ▼
Lo parte en pedazos pequeños (chunks)
    │
    ▼
Cada pedazo se convierte en vectores (embeddings)
    │
    ▼
Se guarda en la base de datos
Enter fullscreen mode Exit fullscreen mode

¿Por qué en pedazos? Porque es más fácil buscar en fragmentos pequeños que en un documento de 100 páginas.

¿Por qué números? Porque las computadoras comparan números más rápido que texto. Y lo cool es que estos números capturan el significado, no solo las palabras.

3. Haces una pregunta

Tu pregunta
    │
    ▼
Se convierte en vectores (igual que los documentos)
    │
    ▼
Busca los pedazos más parecidos en la base de datos
    │
    ▼
Le pasa esos pedazos a la IA como contexto
    │
    ▼
La IA te responde basándose en TU información
Enter fullscreen mode Exit fullscreen mode

Estructura del proyecto

RAG/
├── app.rb                 # El cerebro de la app (rutas)
├── config/
│   └── sidekiq.rb         # Config de jobs en background
├── db/
│   └── migrations/        # Estructura de la base de datos
├── lib/
│   ├── jobs/
│   │   └── document_processor_job.rb  # Procesa docs en segundo plano
│   ├── models/            # Tablas de la BD
│   ├── services/
│   │   ├── chat_agent.rb      # El que arma las respuestas
│   │   ├── chunker.rb         # Parte el texto en pedazos
│   │   ├── embeddings.rb      # Convierte texto a números
│   │   └── rag_retriever.rb   # Busca los pedazos relevantes
│   └── text_extractors/   # Saca texto de PDFs y TXTs
├── views/                 # Las pantallas de la app
└── docker-compose.yml     # PostgreSQL + Redis
Enter fullscreen mode Exit fullscreen mode

Basado en lo anterior podemos dividir lo que hace nuestra aplicacion en dos partes:

  • Carga de documentos.
  • Respuesta a nuestras preguntas (Chat Agent).

‼️ Warning.

Durante esta explicacion voy a evitar hablar de boiler plate code, como es el caso de las sesiones del chat o el docker file, es decir codigo que no vea tan relevante de como funciona a RAG, por lo tanto deberas de tener un poco de conocimientos en Ruby, Docker, etcetera, sin embargo creo que esta muy straightforward de seguir este documento.

📄 Carga de documentos.

Para poder hacer la carga de documentos ocupamos dos rutas en nuestro app.rb las cuales son:

  • get "/upload"
  • post "/documents"

Upload (GET):
Contiene el siguiente codigo:

get "/upload" do
  @documents = db[:documents].reverse_order(:id).all
  erb :upload
end
Enter fullscreen mode Exit fullscreen mode

Practicamente lo que nos indica es que deberemos de tener una view (vista) con el nombre de upload.erb, la cual tendra variable de instancia @documents para poder iterar sobre los archivos en esta vista.

Esta vista en codigo practicamente lo que hace es poner a nuestra disposicion un formulario que al ser llenado y dar click en el boton de procesar e indexar mandara a llamar a nuestro ruta post "/documents", como se puede apreciar enseguida:

<h2>Subir documento</h2>

<form class="card" action="/documents" method="post" enctype="multipart/form-data">
  <label>Título</label>
  <input type="text" name="title" placeholder="Ej. Políticas de seguridad" />
.....
Enter fullscreen mode Exit fullscreen mode

Documents (POST):

Aqui es en donde sucede la magia de nuestra aplicacion, por lo que empezaremos a explicar paso a paso que sucede:

1. Carga y registro de documento.

file = params[:file]
halt 400, "Falta archivo" if file.nil?

filename = file[:filename]
tempfile = file[:tempfile]
mime = file[:type]
size = (tempfile.size.to_f / 1024 / 1024).round(2)

halt 400, "Solo PDF o TXT" unless allowed_file?(filename, mime, size)

storage_name = "#{SecureRandom.uuid}#{File.extname(filename).downcase}"
storage_path = File.join(UPLOAD_DIR, storage_name)
FileUtils.cp(tempfile.path, storage_path)

title = params[:title].to_s.strip
title = filename if title.empty?

doc_id = db[:documents].insert(
  title: title,
  filename: filename,
  content_type: mime.to_s,
  storage_path: storage_path
)
Enter fullscreen mode Exit fullscreen mode

Durante esta etapa lo que estamos haciendo se divide en dos partes:

  • Extraer informacion relevante del archivo que hemos subido en donde hemos agregado algunas validaciones para comprobar si en efecto un archivo fue cargado y si cumple con la validacion de que sea unicamente un PDF o un TXT.

  • Guardar en nuestra tabla documents el archivo que acabamos de cargar desde nuestro formulario.

2. Chunking y embedding de forma asincrona del documento.

Jobs::DocumentProcessorJob.perform_async(doc_id, storage_path, filename)

redirect "/upload"
Enter fullscreen mode Exit fullscreen mode

Practicamente lo que estaremos haciendo aqui es pedirle a nuestra aplicacion que procese por medio de sidekiq nuestro archivo en segundo plano y sus responsabilidades seran:

  • Extraer el texto.
  • Partir el documento en partes (chunks).
  • Crear embeddings (vectores) para cada parte del documento.

Todo esto lo podemos ver dentro de DocumentProcessorJob el cual explicare a continuacion:

module Jobs
  class DocumentProcessorJob
    include Sidekiq::Worker
    sidekiq_options queue: :default, retry: 3

    def perform(doc_id, storage_path, filename)

      # Extraer texto del documento
      text = 
        if File.extname(filename).downcase == ".pdf"
          TextExtractors::PdfExtractor.extract(storage_path)
        else
          TextExtractors::TxtExtractor.extract(storage_path)
        end

        return if text.strip.empty?

        # Chunking - Embeddings
        chunks = Services::Chunker.split(text, size: 1100, overlap: 200)
        embedder = Services::Embeddings.new

        chunks.each_with_index do |chunk, idx|
          emb = embedder.embed(chunk)
          next if emb.nil?

          DocumentChunk.create(
            document_id: doc_id,
            chunk_index: idx,
            content: chunk,
            embedding: emb,
            char_count: chunk.length
          )
        end

        DB.run("ANALYZE document_chunks")
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Extraccion de texto

text = 
  if File.extname(filename).downcase == ".pdf"
    TextExtractors::PdfExtractor.extract(storage_path)
  else
    TextExtractors::TxtExtractor.extract(storage_path)
  end
Enter fullscreen mode Exit fullscreen mode

Contamos con dos Extractores de texto uno llamado PdfExtractor y otro TxtExtractor (ya se que pudimos utilizar herencia para evitar usar esos IF's, pero a mi me interesa aprender a hacer un RAG), en donde el codigo de cada una de estas dos clases la verdad es muy sencillo, como se muestra enseguida:

module TextExtractors
  class PdfExtractor
    def self.extract(path)
      reader = PDF::Reader.new(path)
      text = reader.pages.map(&:text).join("\n")
      text.strip
    end
  end
end

module TextExtractors
  class TxtExtractor
    def self.extract(path)
      File.read(path).to_s.strip
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Chunking

chunks = Services::Chunker.split(text, size: 1100, overlap: 200)
Enter fullscreen mode Exit fullscreen mode

Para esto contamos con un servicio llamado Chunker, el cual posiblemente no sea el mas refinado pero hace su trabajo por el momento.

module Services
  class Chunker
    def self.split(text, size: 1000, overlap: 150)
      # limpiamos texto
      t = text.gsub("\u0000", "").strip

      # Si esta vacio retornarmo un arreglo vacio
      return [] if t.empty?

      chunks = []
      i = 0

      # Agregamos los pedazos de texto a el arreglo chunks
      while i < t.length
        chunk = t[i, size]
        chunks << chunk.strip if chunk&.strip&.length&.positive?
        i += (size - overlap)
        break if size <= overlap
      end
      chunks
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Ojo aqui con overlap, lo unico que significa overlap: 150 es que cada chunk repite los ultimos 150 caracteres del anterior chunk para no perder contexto.

Embedding

Enseguida debemos de hacer nuestros embeddings (convertir nuestro texto en vectores) sobre cada pedazo de texto que posteriormente "chunkeamos" por medio del servicio Embeddings como se muestra enseguida:

require "openai"
require_relative "../config"

module Services
  class Embeddings
    def initialize
      @client = OpenAI::Client.new(api_key: ENV.fetch("OPENAI_API_KEY"))
    end

    def embed(input_text)
      response = @client.embeddings.create(
        model: Config::OPENAI_EMBEDDING_MODEL,
        input: input_text,
        encoding_format: 'float'
      )

      response.to_h[:data][0][:embedding]
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Como vemos aqui inicializamos el servicio de OpenAI con nuestras llaves y posteriormente creamos un metodo llamado embed el cual se encargara unicamente de hacer lo que comentamos anteriormente.

Una vez que hayamos logrado esto unicamente veremos en nuestro index como es que somos redireccionados a el root de nuestra pequeña app y apreciaremos como es que se subio el archivo.

💬 Nuestro Chat.

Ya hicimos lo mas importante que es cargar nuestra base de conocimiento a nuestra base de datos, posteriormente lo que necesitaremos es obtener informacion basado en un input (el chat) y para poder hacerlo deberemos de hacer lo siguiente.

👀 Nuestra vista

get "/chat" do
  session = find_or_create_chat_session
  @messages = db[:chat_messages].where(chat_session_id: session[:id]).order(:id).all
  erb :chat
end
Enter fullscreen mode Exit fullscreen mode

Nuestra vista para el chat es llamada asi chat el cual tiene un formulario para hacer la pregunta como se muestra enseguida:

<h2>Chat RAG</h2>

<form class="card" action="/chat" method="post">
  <label>Pregunta</label>
  <textarea name="message" placeholder="Pregunta algo sobre los documentos..."></textarea>
  <br/>
  <button type="submit">Enviar</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Y tambien cuenta con la variable de instancia @messages para iterar sobre los mensajes o preguntas que le he hecho a la aplicacion para obtener resultados.

🤖 Obtener informacion

Para esto contamos con nuestro metodo POST '/chat' con la finalidad de poder procesar nuestra respuesta con el siguiente codigo:

post "/chat" do
  session = find_or_create_chat_session
  question = params[:message].to_s.strip
  halt 400, "Mensaje vacío" if question.empty?

  db[:chat_messages].insert(chat_session_id: session[:id], role: "user", content: question, sources: Sequel.pg_jsonb([]))

  agent = Services::ChatAgent.new
  result = agent.answer(question)

  db[:chat_messages].insert(
    chat_session_id: session[:id],
    role: "assistant",
    content: result[:answer],
    sources: Sequel.pg_jsonb(result[:sources])
  )

  redirect "/chat"
end
Enter fullscreen mode Exit fullscreen mode

Lo que hace esto es insertar la pregunta que estamos haciendo en la tabla chat_messages para posteriormente pasar esta pregunta a el servicio de ChatAgent el cual tiene lo siguiente:

Este es el contrato del agente, practicamente le estamos indicando como se debe de comportar, de esta manera matamos las alucinaciones.

SYSTEM = <<~SYS
  Eres un asistente que responde usando SOLO el CONTEXTO proporcionado.
  - Si el contexto no contiene la respuesta, di claramente: "No tengo suficiente información en los documentos."
  - Devuelve una sección "Fuentes" listando doc_id y chunk_id usados.
  - Sé conciso y directo.
SYS
Enter fullscreen mode Exit fullscreen mode

Inicializamos el agente

def initialize(retriever: Services::RagRetriever.new)
  @retriever = retriever
  @client = OpenAI::Client.new(api_key: ENV.fetch("OPENAI_API_KEY"))
end
Enter fullscreen mode Exit fullscreen mode

Esto lo hacemos inicializando el RagRetriever el cual su funcion principal es obtener una respuesta en base a la pregunta que le hicimos mediante la comparacion de vectores.

class RagRetriever
  def initialize(embedder: Services::Embeddings.new)
    @embedder = embedder
  end

  def retrieve(question, top_k: 6)
    q_emb = @embedder.embed(question)
    raise "No se pudo generar embedding" if q_emb.nil?

    DocumentChunk
      .nearest_neighbors(:embedding, q_emb, distance: "cosine")
      .select(
        Sequel[:document_chunks][:id].as(:chunk_id),
        Sequel[:document_chunks][:document_id],
        Sequel[:document_chunks][:chunk_index],
        Sequel[:document_chunks][:content]
      )
      .limit(top_k)
      .all
  end
end
Enter fullscreen mode Exit fullscreen mode

top_k es practicamente decirle a la IA dame los resultados mas relevantes y olvida el resto, en donde K es un numero que nosotros eligimos; en este caso le estamos indicando que busque los 6 chunks mas parecidos a nuestra pregunta.

Posteriormente buscamos los chunks por medio del metodo retrieve que es parte de nuestra instancia inicializada de RagRetriever.

  hits = @retriever.retrieve(question, top_k: 6)
Enter fullscreen mode Exit fullscreen mode

Despues creamos el texto de la siguiente manera:

context = hits.map.with_index(1) do |h, idx|
  "[S#{idx}] doc_id=#{h[:document_id]} chunk_id=#{h[:chunk_id]} chunk_index=#{h[:chunk_index]}\n#{h[:content]}"
end.join("\n\n")
Enter fullscreen mode Exit fullscreen mode

Que es practicamente lo siguiente:

[S1] doc_id=10 chunk_id=44
Texto real del documento...

[S2] doc_id=12 chunk_id=03
Otro texto real...
Enter fullscreen mode Exit fullscreen mode

Enseguida creamos nuestro user prompt:

user_prompt = <<~USR
  CONTEXTO:
  #{context}

  PREGUNTA:
  #{question}
USR
Enter fullscreen mode Exit fullscreen mode

Por ultimo para no entrar en mas detalles lo que hacemos es mandar a llamar al modelo de OpenAI y su metodo de crear un chat de la siguiente manera:

resp = @client.chat.completions.create(
  model: Config::OPENAI_CHAT_MODEL,
  messages: [
    { 
      role: "system",
      content: SYSTEM
    },
    { 
      role: "user", 
      content: user_prompt 
    }
  ]
)
Enter fullscreen mode Exit fullscreen mode

Todo esto lo podemos ver en el siguiente link https://platform.openai.com/docs/api-reference/chat.


¿Dudas o comentarios?

Me pueden buscar en mi linkedin https://www.linkedin.com/in/daniel-pe%C3%B1aloza-82aa9a100/ o bien mandarme un mensaje por aqui y sin tema intercambiamos ideas.

Top comments (0)