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
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
¿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
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
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
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" />
.....
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
)
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"
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
Extraccion de texto
text =
if File.extname(filename).downcase == ".pdf"
TextExtractors::PdfExtractor.extract(storage_path)
else
TextExtractors::TxtExtractor.extract(storage_path)
end
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
Chunking
chunks = Services::Chunker.split(text, size: 1100, overlap: 200)
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
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
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
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>
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
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
Inicializamos el agente
def initialize(retriever: Services::RagRetriever.new)
@retriever = retriever
@client = OpenAI::Client.new(api_key: ENV.fetch("OPENAI_API_KEY"))
end
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
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)
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")
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...
Enseguida creamos nuestro user prompt:
user_prompt = <<~USR
CONTEXTO:
#{context}
PREGUNTA:
#{question}
USR
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
}
]
)
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)