Herramientas de usuario

Herramientas del sitio


inteligencia_artificial:langchain:documentos

Preguntas y respuestas a documentos

Vamos a ver el modo en que los LLM gestionan la información de documentos, de modo que podamos preguntar a un modelo acerca de la información contenida en los mismos.

Para ello vamos a dividir este proceso en varias partes que vamos a ver de forma independiente:

  • Carga de documentos (información).
  • “Troceado” de documentos, para hacer manejable la información.
  • Guardado de información en base de datos vectorial con embeddings.
  • Retrieval: A partir de una consulta query se extrae de la base de datos vectorial la información requerida. Esto se hace a través de una búsqueda semántica.
  • Salida: A través de un prompt, el LLM utiliza la información proporcionada para tener un contexto enriquecido y que permita obtener mejores y más precisas respuestas.

 Procesado de documentos y preguntas y respuestas

RAG

Hay un concepto muy importante en el campo de la inteligencia artificial generativa, que vamos a ver en este apartado: RAG Retrieval Augmented Generation.
RAG es una técnica que combina modelos generativos de lenguaje (como GPT o Llama) con sistemas de recuperación de información para mejorar la precisión y relevancia de las respuestas generadas.

RAG tiene dos componentes principales, que se corresponden conlas dos últimas fases vistas anteriormente:

  • Retrieval: Este componente busca información relevante en una base de datos, un conjunto de documentos o cualquier fuente de conocimiento. Utiliza técnicas de búsqueda, a menudo basadas en embeddings (representaciones vectoriales), para encontrar los fragmentos de texto más relevantes en respuesta a una consulta.
  • Modelo generativo (Generator): Una vez que el módulo de recuperación obtiene información relevante, se alimenta al modelo generativo. El modelo utiliza esta información como contexto para generar respuestas más precisas y específicas.

De este modo, la técnica RAG permite buscar recuperar información de fuentes de datos concretas, de odo que obtenemos respuestas más “controladas” y precisas, disminuyendo el riesgo de alucinación, y pudiendo desarrollar aplicaciones que requieran conocimientos más especializados.

Carga de información

En primer lugar debemos recopilar información de las fuentes que necesitemos.
Estas fuentes pueden ser bases de datos, documentos PDF, documentos JSON, CSV, videos de youtube (en LLMs multimodales), etc.
Para cargar la información, langchain proporciona Loaders para cada tipo de archivo o formato. Langchain cuenta actualmente con más de 80 loaders, que pueden consultarse en su página web.

Estos loaders pueden cargar tanto información no estructurada (entradas de wikipedia, tweets de X, archivos ePub, documentos de Notion, etc), estructurados (documentos JSON, CSV, datasets de Hugging Face, hojas de cálculo Excel, etc.), si disponemos de un LLM con capacidad speech to text (por ejemplo API de whisper de OpenAI) podemos incluso obtener transcripciones de videos de youtube, .

Cuando usamos un loader, éste devuelve una lista de objetos tipo “document” (por eso se dice “carga de documentos”, aunque realmente sea “información” y no un determinado tipo de documento). Este tipo “document” contiene el contenido (texto) y metadatos.

A continuación vemos un ejemplo en el que cargaremos PDFs con PyPDFLoader:

# Vamos a cargar información a través de Loaders para PDF
 
# Dependencias
from langchain_ollama import ChatOllama
from langchain_community.document_loaders import PyPDFLoader
 
# Cargamos el modelo LLM
llm = ChatOllama(
    model = "llama3.2",
    temperature = 0.0,
    verbose = True
)
 
# Cargamos un documento PDF
#loader = PyPDFLoader("sentencia.pdf")
loader = PyPDFLoader("ADM.pdf")
 
# Cargamos las páginas, de modo que tenemos una lista de páginas, cada una con su contenido y metadatos
paginas = loader.load()
print(len(paginas))
 
pagina = paginas[7]
print(pagina.page_content[0:500])
print(pagina.metadata)

Troceado de documentos

Los modelos de lenguaje, o LLM, sólo pueden examinar unos pocos miles de palabras de una vez, por lo que tenemos que necesitamos una solución para documentos que sean largos.

Podemos “trocear” la información almacenada para poder trabajar con ella sin problemas de tener ventanas de contexto demasiado grandes, que reduzcan el desempeño del LLM.
Puede resultar sencillo conceptualmente, pero la forma de trocear la información tendrá un alto impacto en que el modelo sea capaz de encontrar la información deseada y realice su función adecuadamente.

Es importante que cada “trozo” de información contenga ideas o datos completos, de forma que se pueda acceder a la información de forma adecuada. Si quedase parte de una información concreta dividida en dos partes diferentes, el acceso a la misma no sería adecuado y el LLM no daría una respuesta correcta.

Cuando especificamos el text splitter no sólo indicamos el tamaño del “trozo” de información, si no un tamaño de “overlap”, es decir, solapamiento. Hay una parte solapada de información, es decir, el trozo siguiente tiene una cantidad determinada de caracteres (o tokens, lo que corresponda) del trozo anterior, con el objetivo de no perder la integridad semántica de la información.

El tamaño de las partes puede ser definidas a partir de tokens o de caracteres. Es importante que mantengan una cohesión de metadatos, de forma que partes específicas tengan metadatos específicos, lo que ayudará en la búsqueda de información.

A continuación algunos tipos de text splitters proporcionados por Langchain en langchain.text_splitter.:

  • CharacterTextSplitter() Pensado para textos estructurados con separadores claros, como párrafos o líneas. Divide el texto en chunks basándose en un separador fijo (separator por defecto, \n). Intenta cumplir con un tamaño máximo de chunk (chunk_size) y solapamiento (chunk_overlap). Si no encuentra el separador, el texto puede no dividirse.
  • MarkdownHeaderTextSplitter Pensado para documentos en formato Markdown con encabezados jerárquicos (manuales técnicos, blogs, etc.). Divide los textos en función de los encabezados Markdown (como #, ##, ###, etc.), creando una estructura jerárquica de secciones y subsecciones.
  • TokenTextSplitter() Divide el texto en función del número de tokens. Los parámetros principales son chunk_size y chunk_overlap, refiriéndose al número de tokens. Pensado para manejar textos en función de limitaciones en el número de tokens.
  • SentenceTransformersTokenTextSplitter() Similar a TokenTextSplitter, pero está diseñado para trabajar específicamente con modelos de Sentence Transformers, que funcionan mejor cuando se les alimenta con oraciones completas. Divide el texto en chunks asegurándose de que las oraciones no se corten arbitrariamente.
  • RecursiveCharacterTextSplitter() Pensado para textos largos y desestructurados, donde es importante dividirlos de forma lógica (por párrafos, oraciones, palabras). Intenta dividir el texto de forma jerárquica. Usa una lista de separadores definidos (de más amplio a más específico) para dividir el texto. Por ejemplo: separators = [“\n\n”, “\n”, “ ”, “”]: Si no puede dividir el texto con un separador más amplio (como párrafos \n\n), intentará con uno más pequeño (como palabras ) y finalmente cortará arbitrariamente (“”).
  • Language() Usa el paquete langchain.text_splitter.Language para dividir texto basado en las características específicas de un idioma. Es una herramienta auxiliar, no un splitter completo. Se utiliza como base para otros splitters (por ejemplo, para configurar idiomas específicos en SpacyTextSplitter o NLTKTextSplitter). El parámetro principal es language para especificar el idioma del texto (“en”, “es”, etc).
  • NLTKTextSplitter() Pensado para textos donde es importante preservar oraciones completas, como artículos o transcripciones. Usa el módulo NLTK para dividir el texto en oraciones (tokenización basada en oraciones). Es ideal para textos donde el corte en oraciones es importante. Los parámetros principales son language, chunk_size y chunk_overlap.
  • SpacyTextSplitter() Pensado para textos donde la gramática y las oraciones completas son importantes, como documentos legales o textos científicos. Usa la biblioteca spaCy para dividir el texto basándose en la estructura lingüística. Puede identificar oraciones, párrafos o incluso dependencias gramaticales para realizar cortes naturales. Para usar el parámetro language para definir el idioma del texto, requiere que tenga instalado el modelo correspondiente, como en_core_web_sm para inglés.

En el siguiente ejemplo vamos a usar 2 text splitters comunes, recursive character text splitter y character text splitter. Para usar expresiones regulares con recursive character text splitter expresiones regulares, con el parámetro is_separator_regex=True.

Lo vemos con unos ejemplos:

# Dependencias
from langchain.text_splitter import RecursiveCharacterTextSplitter, CharacterTextSplitter
 
# Configuración de los "chunks" trozos de texto
chunk_size = 26
chunk_overlap = 4
 
# Instanciamos los splitters
r_splitter = RecursiveCharacterTextSplitter(
    chunk_size = chunk_size,
    chunk_overlap = chunk_overlap
)
 
c_splitter = CharacterTextSplitter(
    chunk_size = chunk_size,
    chunk_overlap = chunk_overlap
)
 
# Textos de prueba
text1 = 'abcdefghijklmnopqrstuvwxyz'
text2 = 'abcdefghijklmnopqrstuvwxyzabcdefg'
text3 = "a b c d e f g h i j k l m n o p q r s t u v w x y z"
 
# Ejemplo en el que comprobamos el RecursiveCharacterTextSplitter
print(r_splitter.split_text(text1)) # Si el chunk_size es de mismo tamaño del texto, no se divide
print(r_splitter.split_text(text2)) # Si se divide, se añaden los caracteres de chunk_overlap de la sección anterior
 
# Ejemplo en el que vemos la diferencia entre RecursiveCharacterTextSplitter y CharacterTextSplitter
print(r_splitter.split_text(text3)) # En este caso, cuenta los espacios como caracteres
print(c_splitter.split_text(text3)) # El CharacterTextSplitter puede "cortar" los trozos cuando encuentra un carácter en concreto, por defecto el carácter nueva línea '\n'. Como no encuentra este carácter no divide el texto, aún sobrepasando el chunk_size
 
# Ejemplo en el que especificamos como carácter separador del CharacterTextSplitter el espacio ' '
print(c_splitter.split_text(text3)) # En este caso, como hemos especificado el espacio como separador, puede dividir el texto en donde se encuentre ' ', por lo que respeta el chunksize, puesto que hay un ' ' para poder "cortar". El carácter por el que separa el texto, se pierde

Para textos normales, funciona mejor el splitter recursivo. Vamos a ver otro ejemplo con texto normal para entender mejor su funcionamiento:

# Dependencias
from langchain.text_splitter import RecursiveCharacterTextSplitter, CharacterTextSplitter
 
some_text = """Al escribir documentos, los escritores utilizan la estructura del documento para agrupar el contenido. \
Esto puede transmitir al lector qué ideas están relacionadas. Por ejemplo, las ideas estrechamente relacionadas \
están en oraciones. Las ideas similares están en párrafos. Los párrafos forman un documento. \n\n \
Los párrafos suelen estar delimitados por uno o dos retornos de carro. \
Los retornos de carro son la "barra invertida n" que se ve incrustada en esta cadena. \
Las oraciones tienen un punto al final, pero también tienen un espacio. \
Y las palabras están separadas por espacios."""
 
print(len(some_text))   # Tamaño del texto
 
# Vamos a ver como se comportan los splitters en este texto
c_splitter = CharacterTextSplitter(
    chunk_size=450,
    chunk_overlap=0,
    separator = ' '
)
 
r_splitter = RecursiveCharacterTextSplitter(
    chunk_size=450,
    chunk_overlap=0, 
    separators=["\n\n", "\n", " ", ""]  # En este caso, añadimos los separadores que queremos que se tengan en cuenta por orden de prioridad
)
 
print(c_splitter.split_text(some_text)) # En este caso, el texto se divide en chunks de 450 caracteres, y se corta en un espacio ' ' si lo encuentra
 
print(r_splitter.split_text(some_text)) # En este caso, el texto se divide en chunks de 450 caracteres, y se corta en los separadores que hemos especificado, de modo que el chunk es más pequeño que el especificado por encontrarse un separador válido
 
# Vamos a probar a cambiar el tamaño de los chunks
r_splitter = RecursiveCharacterTextSplitter(
    chunk_size=150,
    chunk_overlap=0,
    separators=["\n\n", "\n", "\. ", " ", ""]
)
 
print(r_splitter.split_text(some_text)) # En este caso, el texto se divide en chunks de 150 caracteres, y ocurre un problema: el punto '.' no se tiene en cuenta como separador, por lo que no se divide el texto en oraciones, sino que se divide en chunks de 150 caracteres
 
# Vamos a usar una expresión regular para que se tenga en cuenta el punto '.'
r_splitter = RecursiveCharacterTextSplitter(
    chunk_size=150,
    chunk_overlap=0,
    is_separator_regex=True,
    separators=["\n\n", "\n", "(?<=\. )", " ", ""]
)
 
print(r_splitter.split_text(some_text))  # Ahora las secciones del texto terminan con el punto final de una oración, antes de llegar al límite del chunk_size.

Guardado de información

Retrieval

Salida

Embeddings

Una vez “troceada” la información en partes significativas, hay que guardar éstas para poder trabajar con esta información. Para ello se recurre a embeddings.

Los embeddings generan representaciones numéricas de las partes de texto. Estas representaciones son vectores cuyo valor depende del significado de estas partes de texto, de forma que textos semánticamente similares, tendrán vectores similares.

En el ejemplo de la imagen, los dos primeros casos hablan de mascotas, mientras que el tercero habla de un coche, por lo que los vectores entre los dos primeros son similares (cerca espacialmente, dado que son vectores), mientras que el tercero es muy diferente.

Los embeddings dependen de un modelo de IA. OpenAI tiene sus embeddings, Llama los suyos, etc. De modo que podemos usar sus APIs para generar estos vectores, aunque no se usen sus LLM.

Base de datos vectorial

Los vectores generados a través de embeddings los guardamos en una base de datos vectorial, desde la que trabajaremos para hacer búsquedas semánticas y recuperar información.

Para pasar al modelo las partes más importantes del texto que requiera la consulta, necesitaremos crear un índice index que busque entre los vectores de las diferentes porciones de información.

Para realizar una búsqueda semántica en la base de datos, se pasa la consulta a vector a través de embeddings, y se compara con los diferentes vectores de cada porción de información. Se devuelven al modelo los más parecidos para poder devolver la respuesta definitiva.

En langchain existen más de 30 tipos de base de datos vectoriales diferentes que pueden consultarse en su web.

En el siguiente ejemplo cargamos el texto de varios PDFs, dividimos la información en partes, usando los embeddings de Llama 3.1 pasamos las partes a vectores, y finalmente los guardamos en una base de datos vectorial de tipo Chroma por su simplicidad:

from langchain_community.document_loaders import PyPDFLoader
 
# Cargamos los documentos
loaders = [
    PyPDFLoader("seres_01_Hada.pdf"),
    PyPDFLoader("seres_01_Hada.pdf"),   # Repetimos el mismo documento para ver como se comporta el sistema
    PyPDFLoader("seres_02_Gnomo.pdf"),
    PyPDFLoader("seres_03_Trasgo.pdf"),
    PyPDFLoader("seres_04_Mago.pdf"),
]
 
docs = []
for loader in loaders:
    docs.extend(loader.load())
 
# Troceamos los documentos
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size = 500,
    chunk_overlap = 50
)
 
splits = text_splitter.split_documents(docs)
 
# Definimos el modelo de embeddings
from langchain_ollama import OllamaEmbeddings
 
embedding = OllamaEmbeddings(model="llama3.1")
 
# Guardamos los datos en una base de datos vectorial
from langchain_community.vectorstores import Chroma
 
# Definimos el directorio donde se guardará la base de datos vectorial
persist_directory = 'docs/chroma'
 
# Eliminamos el directorio si ya existe, es decir, borramos la base de datos anterior antes de volver a lanzar el sript
import shutil
 
path = "docs/chroma"
 
try:
    shutil.rmtree(path)
    print(f"Directorio {path} eliminado")
 
except FileNotFoundError:
    print(f"Directorio {path} no encontrado")
 
except Exception as e:
    print(f"Error al eliminar el directorio: {e}")
 
# Creamos la base de datos vectorial
vectordb = Chroma.from_documents(
    documents = splits,
    embedding = embedding,
    persist_directory = persist_directory
)
 
# Vamos a realizar una consulta
question = "¿En qué parte de la península hay seres fantásticos como hadas y gnomos?"
docus = vectordb.similarity_search(question, k=3) # En langchain el texto devuelto de una base de datos vectorial al realizar una búsqueda semántica es de tipo "document", por eso se le suele llamar "docs" o documentos.
print(f"Número de documentos encontrados: {len(docus)}")
 
print(f"\nPrimer resultado: {docus[0].page_content}")
print(f"\nSegundo resultado: {docus[1].page_content}")
print(f"\nTercer resultado: {docus[2].page_content}")

Al final del ejemplo anterior realizamos una búsqueda en la base de datos. Al haber documentos duplicados, podemos obtener diferentes respuestas prácticamente iguales. También puede ocurrir que se obtenga la información de algunos documentos (vectores), cuando hay otros más idóneos para la consulta realizada. Veremos en el siguiente apartado retrieval como obtener información de la base de datos vectorial adecuadamente.

Métodos de extracción de información

Hay varios métodos a partir de los cuales se puede extraer la información más importante (la más similar vectorialmente) de las diferentes partes en que se dividió la información.

Stuff

Este método es el más sencillo y consiste en pasarle TODA la información a la consulta como contexto y de ahí al modelo.

  • Pros: Una única llamada al LLM. Se tiene acceso a todos los datos.
  • Contras: Si hay muchos datos, puedes salirte de la ventana de contexto.

Es el método más usado, junto con Map reduce.

Map reduce

Cada trozo de texto se pasa junto con la consulta a un LLM que da respuestas individuales, y cada una de estas pasa a otro LLM que resume todas las respuestas y da una respuesta final.
Es muy potente y permite la inspección de muchos “documentos” (porciones de información), pero e hacen muchas llamadas a LLM. Además, no siempre se puede tratar a los “documentos” de forma independiente.

Es el método más usado, después de Stuff. También viene bien para realizar resúmenes.

Refine

Va construyendo la respuesta en función del “documento” (porción de información) anterior.
Muy bueno para combinar información e ir construyendo una respuesta en el tiempo.
Da lugar a respuestas largas, pero es muy lento.

Map rerank

Se llama al LLM por cada documento, y éste asigna una puntuación por la relevancia del mismo para la consulta. Se selecciona e más adecuado y a partir de él se genera la respuesta final.
Es algo experimental, tiene que hacer muchas llamadas, pero es más rápido que Refine.

inteligencia_artificial/langchain/documentos.txt · Última modificación: por alberto

Donate Powered by PHP Valid HTML5 Valid CSS Driven by DokuWiki