DESBLOQUEA TODO EL POTENCIAL DE TU FLUJO DE TRABAJO DE RAG
tEl número máximo de tokens que un modelo de lenguaje grande puede procesar en una sola solicitud se conoce como longitud de contexto (o ventana de contexto). La siguiente tabla muestra la longitud del contexto para todas las versiones de GPT-4 (a septiembre de 2024). Si bien la duración del contexto ha aumentado con cada iteración y con cada modelo más nuevo, sigue existiendo un límite en la información que podemos proporcionar al modelo. Además, existe una correlación inversa entre el tamaño de los aportes y la relevancia del contexto de las respuestas generadas por el LLM; los aportes breves y enfocados producen mejores resultados que los contextos largos que contienen una gran cantidad de información. Esto enfatiza la importancia de dividir nuestros datos en fragmentos más pequeños y relevantes para garantizar respuestas más apropiadas de los LLM, al menos hasta que los LLM puedan manejar enormes cantidades de datos sin volver a capacitarse.
La ventana de contexto representada en la imagen es Incluyendo tokens de entrada y salida.
Aunque los contextos más largos brindan una imagen más holística del modelo y lo ayudan a comprender las relaciones y hacer mejores inferencias, los contextos más cortos, por otro lado, reducen la cantidad de datos que el modelo necesita comprender y, por lo tanto, disminuyen la latencia, lo que hace que el modelo sea más receptivo. También ayuda a minimizar las alucinaciones del LLM ya que solo se proporcionan al modelo los datos relevantes. Por lo tanto, es un equilibrio entre el rendimiento, la eficiencia y la complejidad de nuestros datos, y necesitamos realizar experimentos sobre cuántos datos son la cantidad correcta que produce mejores resultados con recursos razonables.
Los 128.000 tokens del modelo GPT-4 pueden parecer muchos, así que convirtámoslos en palabras reales y pongámoslos en perspectiva. Desde Tokenizador OpenAI:
Una regla general útil es que un token generalmente corresponde a ~4 caracteres de texto para texto común en inglés. Esto se traduce en aproximadamente ¾ de palabra (es decir, 100 fichas ~= 75 palabras)
consideremos El sabueso de los Baskerville de Arthur Conan Doyle (Licencia del Proyecto Gutenberg) como nuestro ejemplo a lo largo de este artículo. Este libro tiene 7734 líneas y 62303 palabras, lo que equivale aproximadamente a 83 700 tokens.
Si está interesado en calcular tokens exactamente y no solo en una aproximación, puede usar OpenAI tik token:
import request.
from tiktoken import encoding_for_modelurl = "https://www.gutenberg.org/cache/epub/3070/pg3070.txt"
response = requests.get(url)
if response.status_code == 200:
book_full_text = response.text
encoder = encoding_for_model("gpt-4o")
tokens = encoder.encode(book_full_text)
print(f"Number of tokens: {len(tokens)}")
Lo que da el número de tokens que serán Number of tokens: 82069
me gusta el definición de wiki de fragmentación, ya que se aplica tanto a RAG como a la psicología cognitiva.
La fragmentación es un proceso mediante el cual se unen pequeñas piezas individuales de un conjunto de información. Los fragmentos están destinados a mejorar la retención del material a corto plazo, evitando así la capacidad limitada de la memoria de trabajo y permitiendo que la memoria de trabajo sea más eficiente.
El proceso de dividir grandes conjuntos de datos en fragmentos de información más pequeños y significativos para que la memoria no paramétrica del LLM pueda usarse de manera más efectiva se llama fragmentación. Hay muchas formas diferentes de dividir los datos para mejorar la recuperación de fragmentos para RAG, y debemos elegir según el tipo de datos que se consumen.
La fragmentación es un paso previo a la recuperación crucial en el proceso de RAG que influye directamente en el proceso de recuperación y afecta significativamente el resultado final. En este artículo, veremos las estrategias más comunes de fragmentación y las evaluaremos para determinar las métricas de recuperación en el contexto de nuestros datos.
En lugar de repasar de inmediato las estrategias de fragmentación/divisores disponibles en diferentes bibliotecas, comencemos a construir un divisor simple y exploremos los aspectos importantes que deben considerarse para desarrollar la intuición de escribir un nuevo divisor. Empecemos con un divisor básico y mejorémoslo progresivamente solucionando sus inconvenientes/limitaciones.
1. Fragmentación ingenua
Cuando hablamos de dividir datos, lo primero que nos viene a la mente es dividirlos con un carácter de nueva línea. Sigamos adelante con la implementación. Pero como puedes ver, deja muchos caracteres de carro de retorno. Además, simplemente asumimos \norte y \r ya que solo estamos tratando con el idioma inglés, pero ¿y si queremos analizar otros idiomas? Agreguemos también la flexibilidad de pasar los personajes para dividirlos.
def naive_splitter_v2(text: str, separators: List[str] = ["\n", "\r"]) -> List[str]:
"""Splits text at every separator"""
splits = [text]
for sep in separators:
splits = [segment for part in result for segment in part.split(sep) if segment]return splits
Es posible que ya hayas adivinado por el resultado por qué llamamos a este método Naive. La idea tiene muchos inconvenientes:
- Sin límites de fragmentos. Siempre que una línea tenga uno de los delimitadores, se romperá, pero ¿qué pasa si un fragmento no tiene esos delimitadores? Podría tener cualquier longitud.
- De manera similar, como puede ver claramente en el resultado, ¡hay fragmentos que son demasiado pequeños! una sola palabra fragmentada no tiene ningún sentido sin el contexto circundante.
- Interrupciones entre líneas: se recupera un fragmento en función de la pregunta que se hace, pero una oración/línea está totalmente incompleta o incluso tiene un significado diferente si la truncamos a mitad de la oración.
Intentemos solucionar estos problemas uno por uno.
2. Fragmentación de ventana fija
Primero abordemos el primer problema de los tamaños de trozos demasiado largos o demasiado cortos. Esta vez tomamos un límite para el tamaño e intentamos dividir el texto exactamente cuando alcanzamos el tamaño.
def fixed_window_splitter(text: str, chunk_size: int = 1000) -> List[str]:
"""Splits text at given chunk_size"""
splits = []
for i in range(0, len(text), chunk_size):
splits.append(text[i:i + chunk_size])
return splits
Resolvimos los límites mínimo y máximo del fragmento, ya que siempre será tamaño_fragmento. Pero las pausas entre palabras siguen siendo las mismas. Como podemos ver en el resultado, estamos perdiendo el significado de un fragmento ya que está dividido a mitad de la oración.
3. Ventana fija con fragmentación superpuesta
La forma más fácil de asegurarnos de no dividirnos entre palabras es asegurarnos de repasar hasta el final de la palabra y luego detenernos. Aunque esto hará que el contexto no sea demasiado largo y esté dentro del rango de tamaño de fragmento esperado, un mejor enfoque sería comenzar el siguiente fragmento un poco más tarde. x caracteres/palabras/fichas detrás de la posición inicial real, de modo que el contexto siempre se conserve y sea continuo.
def fixed_window_with_overlap_splitter(text: str, chunk_size: int = 1000, chunk_overlap: int = 10) -> List[str]:
"""Splits text at given chunk_size, and starts next chunk from start - chunk_overlap position"""
chunks = []
start = 0while start <= len(text):
end = start + chunk_size
chunks.append(text[start:end])
start = end - chunk_overlap
return chunks
4. Fragmentación recursiva de caracteres
Con Chunk Size y Chunk Overlap solucionado, ahora podemos resolver el problema de la división a mitad de palabra o a mitad de oración. Esto se puede resolver con una pequeña modificación de nuestro divisor Naive inicial. Tomamos una lista de separadores y elegimos un buen separador a medida que crecemos hasta alcanzar el tamaño del trozo. Mientras tanto, seguiremos usando la superposición de fragmentos de la misma manera. Este es uno de los divisores más populares disponibles en el paquete LangChain llamado Divisor de texto de carácter recursivo. Esto funciona de la misma manera que lo abordamos:
- Comienza con el separador de mayor prioridad, que comienza desde el principio \n\n y pasa al siguiente en el separadores lista.
- Si una división excede el tamaño del fragmento, aplica el siguiente separador hasta que la división actual caiga por debajo del tamaño correcto.
- La siguiente división comienza con caracteres chunk_overlap detrás del final de la división actual, manteniendo así la continuidad del contexto.
4. Fragmentación semántica
Hasta ahora, sólo hemos considerado dónde dividir nuestros datos, ya sea al final de un párrafo, en una nueva línea, en una tabulación u otros separadores. Pero no hemos pensado en cuándo dividir, es decir, cómo capturar mejor una porción significativa en lugar de solo una porción de cierta longitud. Este enfoque se conoce como fragmentación semántica. usemos Instinto para detectar límites de oraciones o entidades específicas y crear fragmentos significativos. El texto se divide en oraciones usando SegtokSentenceSplitter, lo que garantiza que esté dividido en límites significativos. Mantenemos la lógica de tallas igual, para agrupar hasta llegar tamaño_del_trozo y superposición de trozo_superposición para garantizar que se mantenga el contexto.
def semantic_splitter(text: str, chunk_size: int = 1000, chunk_overlap: int = 10) -> List[str]:
from flair.models import SequenceTagger
from flair.data import Sentence
from flair.splitter import SegtokSentenceSplittersplitter = SegtokSentenceSplitter()
# Split text into sentences
sentences = splitter.split(text)
chunks = []
current_chunk = ""
for sentence in sentences:
# Add sentence to the current chunk
if len(current_chunk) + len(sentence.to_plain_string()) <= chunk_size:
current_chunk += " " + sentence.to_plain_string()
else:
# If adding the next sentence exceeds max size, start a new chunk
chunks.append(current_chunk.strip())
current_chunk = sentence.to_plain_string()
# Add the last chunk if it exists
if current_chunk:
chunks.append(current_chunk.strip())
return chunks
LangChain tiene dos de estos divisores, utilizando el NLTK y espacio bibliotecas, así que échales un vistazo.
Entonces, generalmente, en los métodos de fragmentación estática, Chunk Size y Chunk Overlap Son dos factores principales a considerar al determinar la estrategia de fragmentación. El tamaño del fragmento es la cantidad de caracteres/palabras/tokens de cada fragmento y la superposición del fragmento es la cantidad del fragmento anterior que se incluirá en el fragmento actual para que el contexto sea continuo. La superposición de fragmentos también se puede expresar como número de caracteres/palabras/tokens o como porcentaje del tamaño del fragmento.
Puedes usar el fresco ChunkViz herramienta para visualizar cómo se comportan las diferentes estrategias de fragmentación con diferentes tamaños de fragmentación y parámetros de superposición:
5. Incrustación de fragmentación
Aunque la fragmentación semántica hace el trabajo, NLTK, spaCy o Flair usan sus propios modelos/incrustaciones para comprender los datos proporcionados e intentan indicarnos cuándo es mejor dividir los datos semánticamente. Cuando pasamos a nuestra implementación RAG real, nuestras incrustaciones pueden ser diferentes de aquellas con las que se fusionan nuestros fragmentos y, por lo tanto, pueden entenderse de una manera completamente diferente. Entonces, en este enfoque comenzamos dividiéndolos en oraciones y formamos los fragmentos según el mismo modelo de incrustación que luego usaremos para nuestra recuperación de RAG. Para hacer las cosas de manera diferente, usaremos NLTK para dividir en oraciones y usaremos OpenAIEmbeddings para fusionarlas y formar oraciones.
def embedding_splitter(text_data, chunk_size=400):
import os
import nltk
from langchain_openai.embeddings import AzureOpenAIEmbeddings
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
from dotenv import load_dotenv, find_dotenv
from tqdm import tqdm
from flair.splitter import SegtokSentenceSplitterload_dotenv(find_dotenv())
# Set Azure OpenAI API environment variables (ensure these are set in your environment)
# You can also set these in your environment directly
# os.environ["OPENAI_API_KEY"] = "your-azure-openai-api-key"
# os.environ["OPENAI_API_BASE"] = "your-azure-openai-api-endpoint"
os.environ["OPENAI_API_VERSION"] = "2023-05-15"
# Initialize OpenAIEmbeddings using LangChain's Azure support
embedding_model = AzureOpenAIEmbeddings(deployment="text-embedding-ada-002-01") # Use your Azure model name
# Step 1: Split the text into sentences
def split_into_sentences(text):
splitter = SegtokSentenceSplitter()
# Split text into sentences
sentences = splitter.split(text)
sentence_str = []
for sentence in sentences:
sentence_str.append(sentence.to_plain_string())
return sentence_str[:100]
# Step 2: Get embeddings for each sentence using the same Azure embedding model
def get_embeddings(sentences):
embeddings = []
for sentence in tqdm(sentences, desc="Generating embeddings"):
embedding = embedding_model.embed_documents([sentence]) # Embeds a single sentence
embeddings.append(embedding[0]) # embed_documents returns a list, so take the first element
return embeddings
# Step 3: Form chunks based on sentence embeddings, a similarity threshold, and a max chunk character size
def form_chunks(sentences, embeddings, similarity_threshold=0.7, chunk_size=500):
chunks = []
current_chunk = []
current_chunk_emb = []
current_chunk_length = 0 # Track the character length of the current chunk
for i, (sentence, emb) in enumerate(zip(sentences, embeddings)):
emb = np.array(emb) # Ensure the embedding is a numpy array
sentence_length = len(sentence) # Calculate the length of the sentence
if current_chunk:
# Calculate similarity with the current chunk's embedding (mean of embeddings in the chunk)
chunk_emb = np.mean(np.array(current_chunk_emb), axis=0).reshape(1, -1) # Average embedding of the chunk
similarity = cosine_similarity(emb.reshape(1, -1), chunk_emb)[0][0]
if similarity < similarity_threshold or current_chunk_length + sentence_length > chunk_size:
# If similarity is below threshold or adding this sentence exceeds max chunk size, create a new chunk
chunks.append(current_chunk)
current_chunk = [sentence]
current_chunk_emb = [emb]
current_chunk_length = sentence_length # Reset chunk length
else:
# Else, add sentence to the current chunk
current_chunk.append(sentence)
current_chunk_emb.append(emb)
current_chunk_length += sentence_length # Update chunk length
else:
current_chunk.append(sentence)
current_chunk_emb = [emb]
current_chunk_length = sentence_length # Set initial chunk length
# Add the last chunk
if current_chunk:
chunks.append(current_chunk)
return chunks
# Apply the sentence splitting
sentences = split_into_sentences(text_data)
# Get sentence embeddings
embeddings = get_embeddings(sentences)
# Form chunks based on embeddings
chunks = form_chunks(sentences, embeddings, chunk_size=chunk_size)
return chunks
6. Fragmentación agente
Nuestro Embedding Chunking debería acercarse a dividir los datos con la similitud coseno de las incrustaciones creadas. Aunque esto funciona bien, tenemos un gran inconveniente: no comprende la semántica del texto. “Me gustas” versus “yo Como Tú” con sarcasmo sobre “me gusta”, ambas oraciones tendrán las mismas incrustaciones y, por lo tanto, corresponderán a la misma distancia del coseno cuando se calculen. Aquí es donde la fragmentación Agentic (o basada en LLM) resulta útil. Analiza el contenido para identificar puntos de ruptura lógica en función de la independencia y la coherencia semántica.
def agentic_chunking(text_data):
from langchain_openai import AzureChatOpenAI
from langchain.prompts import PromptTemplate
from langchain
llm = AzureChatOpenAI(model="gpt-4o",
api_version="2023-03-15-preview",
verbose=True,
temperature=1)
prompt = """I am providing a document below.
Please split the document into chunks that maintain semantic coherence and ensure that each chunk represents a complete and meaningful unit of information.
Each chunk should stand alone, preserving the context and meaning without splitting key ideas across chunks.
Use your understanding of the content’s structure, topics, and flow to identify natural breakpoints in the text.
Ensure that no chunk exceeds 1000 characters length, and prioritize keeping related concepts or sections together.Do not modify the document, just split to chunks and return them as an array of strings, where each string is one chunk of the document.
Return the entire book not dont stop in betweek some sentences.
Document:
{document}
"""
prompt_template = PromptTemplate.from_template(prompt)
chain = prompt_template | llm
result = chain.invoke({"document": text_data})
return result
Cubriremos las técnicas de evaluación de RAG en una próxima publicación; En este post veremos dos métricas definidas por RAGAS, context_precision y context_relevanceque determinan el rendimiento de nuestras estrategias de fragmentación.
Precisión del contexto es una métrica que evalúa si todos los elementos relevantes de la verdad fundamental presentes en los contextos tienen una clasificación más alta o no. Lo ideal es que todos los fragmentos relevantes aparezcan en los primeros puestos. Esta métrica se calcula utilizando la pregunta, ground_truth y los contextos, con valores que oscilan entre 0 y 1, donde las puntuaciones más altas indican una mejor precisión.
Relevancia del contexto Mide la relevancia del contexto recuperado, calculado en base tanto a la pregunta como a los contextos. Los valores se encuentran dentro del rango de (0, 1), y los valores más altos indican una mayor relevancia.
En el próximo artículo repasaremos la recuperación de propuestas, uno de los métodos de división agente, y calcularemos las métricas de RAGAS para todas las estrategias.
En este artículo cubrimos por qué necesitamos fragmentación y hemos desarrollado una intuición para construir algunas de las estrategias y su implementación, así como su código correspondiente en algunas de las bibliotecas más conocidas. Estas son solo estrategias básicas de fragmentación, aunque cada día se inventan estrategias cada vez más nuevas para mejorar aún más la recuperación.