В последние пару лет RAG (retrieval-augmented generation) стал одной из самых обсуждаемых технологий в области обработки текстов и поисковых систем. Его идея проста: объединить поиск (retrieval) и генерацию (generation), чтобы быстрее находить нужную информацию и создавать более точные тексты.
Рост объёмов данных и информационного шума привёл к тому, что классические методы поиска и генерации уже не всегда справляются с новыми задачами. Например, большие языковые модели без доступа к актуальной информации могут искажать факты, а традиционные поисковики при запросах на естественном языке дают слишком общий результат. RAG решает эти проблемы, добавляя дополнительный "слой знаний" за счёт внешних баз данных, что особенно полезно для чат-ботов, систем вопрос-ответ, рекомендательных сервисов и многих других приложений.
Целью данной статьи является погружение читателя в технологию RAG, а также ознакомление с основными критериями и методами его улучшения. В этой статье мы обсудим, как именно устроен RAG, как правильно оценивать его эффективность и какие существуют техники улучшения – от уже известных методов до совершенно новых решений.
1. Понятия и основные процессы в RAG
RAG представляет собой подход, объединяющий возможности генеративных моделей и поиска в обширных внешних хранилищах знаний [0]. Сама техника RAG -- это промпт-инжиниринг, где мы не пишем промпты вручную, а динамически подставляем необходимые для ответа на вопрос сведения в инструкцию модели.
Рассмотрим, как весь процесс работы RAG шаг за шагом. Перед процессом генерации языковой моделью происходит этап извлечения релевантного контекста из внешнего источника. Чаще всего для этого используют векторные поисковые механизмы, строящиеся на базе эмбеддингов (векторных представлений), или классические индексные структуры, если речь идёт о более простых текстовых операциях. Векторные базы данных являются хранилищем, где каждый кусочек (чанк) документа преобразуется в эмбеддинг с помощью какой-либо трансформерной модели (BERT, RoBERTa, и т.д.). Когда приходит пользовательский запрос, этот запрос также конвертируется в вектор, после чего по мере близости косинусного сходства или другой метрики из векторной базы извлекаются самые подходящие чанки текста. Полученные фрагменты подаются на вход генеративной модели вместе с исходным вопросом. По сути, генерация становится “подкреплённой” внешними знаниями: языковая модель не просто статистически “угадывает”, а обрабатывает полученные факты и формирует более точный, контекстуально подкрепленный ответ. RAG, таким образом, позволяет обновлять информацию, не переобучая саму языковую модель.
2. Кейсы использования RAG
Применение RAG достаточно широко. Во-первых, это любые чат-боты, предназначенные для обслуживания клиентов, консультирования и технической поддержки. Вместо того чтобы хранить всю документацию внутри громоздкой модели, можно динамически извлекать нужную информацию из актуального репозитория. Например, в сфере e-commerce при запросах пользователей о характеристиках продукта система могла бы “подтягивать” описания из базы товаров и выдавать сформированный на лету ответ. Во-вторых, RAG является инструментов для создания бизнес-аналитических ассистентов, которые позволяют руководителям отслеживать параметры продаж, производительности и другие сведения. Ещё одной важной областью применения являются научные и медицинские системы, где необходим высоконадежный доступ к доказательствам, статьям, клиническим рекомендациям. Языковая модель при этом реже “придумывает”, поскольку дополнена фактической информацией, извлечённой из проверенных источников, что делает подход ценным в кейсах, где цена ошибки слишком высока.
Кроме этого, поскольку RAG является по сути инструментом промпт-инженеринга, он может использоваться сугубо технически, например, для изменения поведения модели в процессе обработки сообщений: для динамической подстановки уточняющих промптов, или для выполнения поиска по истории чата, чтобы извлекать наиболее подходящие запросы пользователей и ответы моделей.
3. Оценка RAG
Обычно [1] при оценке RAG речь идёт о "триаде", состоящей компонентов: контекстная релеваность (здесь оценивается релевантность возвращенных для LLM фрагментов), фактологическая правильность (здесь проверяются насколько фактически точным является ответ), релевантность (измерение соответствия финального ответа запросу пользователя). Однако этот подход можно считать несколько поверхностным, так как в нём не учитывается логика использования диалоговых систем.
Здесь не рассматриваются такие вопросы, как:
1) учет контекста истории при генерации. Например, в векторной базе данных содержатся сведения про инцидент и про отпуска, когда мы спрашиваем в чате, “Что необходимо сделать для оформления отпуска?” и получаем ответ “Для получения отпуска необходимо …>”, а затем уточняем “Нужно ли кого-то уведомить?”, модель для эмбеддингов должна получить расширенное представление, в противном случае может возникнуть конфликт между знаниями, и генеративная модель может ответить про уведомление коллег в случае инцидентов.
2) предобработка документов для генеративной модели. Документы могут содержать противоречивые сведения, например, один документ, про
Фактически, данный подход более релевантен для наивного RAG, когда все что есть -- запрос, поиск, и сгенерированный ответ, но не для реального сценария использования RAG как диалоговой системы. При более детальном рассмотрении компонентами являются:
1) подсистема предварительной обработки данных – компонент, который берёт на себя функции структурирования информации и разделения документов на чанки.
2) энкодерная модель – отвечает за векторизацию как чанков, так и пользовательских запросов.
3) векторная база знаний – обеспечивает быстрый и эффективный поиск необходимой информации.
4) подсистема предобработки пользовательских запросов – может отвечать как за насыщение пользовательского запроса контекстом из истории чата, так и за разбиение запроса на подзадачи.
5) генеративная модель – осуществляет синтез окончательного ответа с учётом найденных данных.

Оценка RAG как диалоговой системы предполагает измерение корректности не только финального ответа, но и каждого из компонентов.
Оценка предобработки
Первый компонент оценивается по качеству и эффективности разбиения документов на логически обоснованные фрагменты при сохранении контекстной целостности исходной информации. Здесь нет конкретных метрик, кроме качественных оценок. Необходимо проверять, насколько корректно система структурирует данные для последующего извлечения и генерации ответов, а также проанализировать её производительность при работе с большими объемами информации. Задача состоит в том, чтобы обеспечивать баланс между размером фрагментов и сохранением смысловых элементов, позволяя свести к минимуму потери информации и повысить общую точность RAG-системы.
В самом простом варианте, мы можем сделать конвертацию docx, pdf-документов в markdown (так как большинство генеративных моделей генерируют текст именно в данном формате, он является наиболее предпочтительным), после чего разбивать их по заголовкам:
Пример кода
import os
import xml.etree.ElementTree as ET
from abc import ABC, abstractmethod
from contextlib import suppress
from dataclasses import dataclass
from io import BytesIO
from typing import List, Iterable, Callable, Tuple
import docx
from docx.oxml import CT_P
from docx.table import Table
from docx.text.paragraph import Paragraph
from lxml import etree
from pdf2docx import Converter
from src.utils import write
def parse_images(xml_string):
root = etree.fromstring(xml_string)
namespaces = {
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
}
embed_values = [element.get('{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed') for
element in root.xpath('//a:blip', namespaces=namespaces)]
return embed_values
def extract_level(xml_string):
root = ET.fromstring(xml_string)
namespaces = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
for elem in root.findall('.//w:pStyle', namespaces):
val = tuple(elem.attrib.values())[-1]
return int(val if val.isdigit() else 0)
return 0
def list_to_md(data: List[List]) -> str:
col_widths = [max(len(str(item)) for item in col) for col in zip(*data)]
def format_row(row):
return "| " + " | ".join(f"{str(item):<{w}}" for item, w in zip(row, col_widths)) + " |"
headers = format_row(data[0])
separator = "| " + " | ".join("-" * w for w in col_widths) + " |"
rows = "n".join(format_row(row) for row in data[1:])
return "n".join([headers, separator, rows])
class Markodwnable(ABC):
@property
@abstractmethod
def md(self):
pass
def split_into_chunks(
items: List[Markodwnable],
max_chunk_length: int = 4000,
length_function: Callable = len,
skip_header_in_chunks: bool = True
) -> Tuple[List[List[Markodwnable]], List[Markodwnable]]:
chunks = []
current_chunk = []
current_chunk_length = 0
header = ParagraphDTO(1, '')
headers = []
for item in items:
item_length = length_function(item.md)
is_header = item.md.startswith('#')
if is_header:
header = item
if not current_chunk or is_header:
if current_chunk:
chunks.append(current_chunk)
current_chunk = [ParagraphDTO(0, '')] if skip_header_in_chunks else [item]
current_chunk_length = 0 if skip_header_in_chunks else item_length
headers.append(header)
elif current_chunk_length + item_length <= max_chunk_length:
current_chunk.append(item)
current_chunk_length += item_length
else:
chunks.append(current_chunk)
current_chunk = [header, item]
current_chunk_length = item_length
headers.append(header)
if current_chunk:
chunks.append(current_chunk)
return chunks, headers
def container_to_md(doc_container: Iterable[Markodwnable]) -> str:
md = 'nn'.join(
[dto.md for dto in doc_container]
).strip()
return md
@dataclass
class ImageDTO(Markodwnable):
id: str
data: bytes
def __repr__(self):
return f"ImageDTO(id={self.id})"
@property
def md(self):
return f""
@dataclass
class ParagraphDTO(Markodwnable):
level: int
text: str
def __repr__(self):
return f'ParagraphDTO(level={self.level})'
@property
def md(self):
return '#' * self.level + ' ' + self.text
@dataclass
class TableDTO(Markodwnable):
values: List[List]
def __repr__(self):
return f"TableDTO()"
@property
def md(self):
return list_to_md(self.values)
def remove_duplicates(lst: List):
seen = {}
return [seen.setdefault(x, x) for x in lst if x not in seen]
def get_unique_cells(row):
"""Фильтрует дублирующиеся ячейки в строке из-за объединения"""
seen_cells = set()
unique_cells = []
for cell in row.cells:
cell_id = id(cell._tc)
if cell_id not in seen_cells:
seen_cells.add(cell_id)
unique_cells.append(cell)
return unique_cells
def parse_docx(data: BytesIO | str) -> List[Markodwnable]:
doc = docx.Document(data)
doc_container = []
for element in doc.element.body:
with suppress(Exception):
xml = element.xml
images_ids = parse_images(xml)
images = tuple(
map(lambda _id:
ImageDTO(_id, doc.part.rels[str(_id)]._target._blob),
remove_duplicates(images_ids))
)
doc_container.extend(images)
if isinstance(element, CT_P):
level = extract_level(xml)
text = Paragraph(element, doc).text.strip()
if len(text):
doc_container.append(
ParagraphDTO(level=level, text=text)
)
else:
table = TableDTO([])
_docx_table = Table(element, doc)
if len(tuple(_docx_table.columns)):
for row in _docx_table.rows:
unique_cells = get_unique_cells(row) # Отфильтровываем дубли
table.values.append(
[c.text.strip().replace('n', ' ') for c in unique_cells]
)
doc_container.append(table)
prev = None
ind = 0
for i, dto in enumerate(tuple(doc_container)):
if isinstance(dto, ImageDTO):
if prev is None:
prev = dto
else:
if prev.id == dto.id:
del doc_container[i + ind]
ind -= 1
prev = dto
return doc_container
def parse_pdf(data: str | BytesIO) -> List[Markodwnable]:
buff = BytesIO()
isio = isinstance(data, BytesIO)
con = Converter(**dict(
stream=data.read()
if isio
else None,
pdf_file=data if not isio else None
),
)
con.convert(buff)
con.close()
return parse_docx(buff)
def p_10_length(x):
return int(x * 0.1)
def p_50_length(x):
return int(x * 0.5)
def p_25_length(x):
return int(x * 0.25)
def tokens(x):
return int(len(x) / 3.5)
@dataclass
class DocumentDTO(Markodwnable):
container: List[Markodwnable]
length_function: Callable = tokens
length: int = 3000
length_rate_function: Callable = p_50_length
@property
def md(self) -> str:
return container_to_md(self.container)
@classmethod
def from_docx(cls, data: BytesIO | str) -> 'DocumentDTO':
return cls(parse_docx(data))
@classmethod
def from_pdf(cls, data: BytesIO | str) -> 'DocumentDTO':
return cls(parse_pdf(data))
@property
def struct(self) -> 'DocumentDTO':
return DocumentDTO(
list(filter(lambda x: isinstance(x, ParagraphDTO) and x.level > 0, self.container))
)
@property
def struct_md(self) -> str:
return self.struct.md.replace('nn', 'n')
@property
def applications(self):
return dict(map(lambda x: (x.id, x.data), filter(lambda x: isinstance(x, ImageDTO), self.container)))
def _chunks(self, skip_headers: bool):
chunks, headers = split_into_chunks(
self.container,
length_function=self.length_function,
max_chunk_length=self.length,
skip_header_in_chunks=skip_headers
)
return list(
map(
lambda x:
DocumentDTO(
x,
length_function=self.length_function,
length=p_10_length(self.length),
length_rate_function=self.length_rate_function
),
chunks
)
), headers
@property
def chunks(self):
return self._chunks(skip_headers=True)
@property
def chunks_md(self):
return list(map(lambda x: x.md, self._chunks(skip_headers=False)[0]))
Оценка энкодерной модели
При оценке энкодерной модели важно учитывать как качество векторизации текстовых чанков и пользовательских запросов, так и вычислительную эффективность самой модели. Для оценки семантического представления применяют метрики Precision@K, Recall@K, F1-score, mAP, позволяющие определить, насколько корректно модель ранжирует релевантные результаты. Дополнительно используют метрику NDCG, которая учитывает позиции результатов в выдаче и тем самым более точно отражает степень их релевантности, что особенно важно для правильной работы генеративных моделей [2]. Для справки дополню, что все метрики принимают значения от 0 до 1, где 0 и 1 -- худший и идеальный результат, соответственно. Для выбора метрик для оценки можно использовать таблицу ниже.
Метрика |
Что измеряет? |
Когда использовать? |
Недостатки |
Precision@K (Точность@K) |
Доля релевантных результатов среди первых K |
Когда важно, насколько качественные топ-K результаты |
Не учитывает позицию релевантных объектов, не отражает полноту |
Recall@K (Полнота@K) |
Доля найденных релевантных объектов среди всех существующих |
Когда важно найти все релевантные объекты |
Может быть высоким даже при низкой точности |
F1-score@K |
Баланс между точностью и полнотой |
Если важен баланс между Precision и Recall |
Теряет смысл, если Precision или Recall слишком низкие |
mAP (Mean Average Precision) |
Средняя точность на разных уровнях полноты |
Для оценки всего ранжирования поисковых систем и рекомендаций |
Требует вычисления для всех релевантных документов |
NDCG (Normalized Discounted Cumulative Gain) |
Оценка качества ранжирования с учетом позиций релевантных объектов |
Когда важен порядок результатов (поисковые системы, рекомендации) |
Сложнее вычислять, зависит от весов релевантности |
Реализация в программном коде может выглядеть так:
Пример кода
import numpy as np
from sklearn.metrics import ndcg_score
def precision_at_k(predicted, relevant, k):
predicted_at_k = predicted[:k]
relevant_at_k = set(predicted_at_k) & set(relevant)
return len(relevant_at_k) / k
def recall_at_k(predicted, relevant, k):
relevant_at_k = set(predicted[:k]) & set(relevant)
return len(relevant_at_k) / len(relevant) if relevant else 0
def f1_score_at_k(predicted, relevant, k):
p = precision_at_k(predicted, relevant, k)
r = recall_at_k(predicted, relevant, k)
return 2 * (p * r) / (p + r) if (p + r) > 0 else 0
def average_precision(predicted, relevant):
score = 0.0
num_hits = 0
for i, p in enumerate(predicted):
if p in relevant:
num_hits += 1
score += num_hits / (i + 1)
return score / len(relevant) if relevant else 0
def mean_average_precision(predictions, relevants):
return np.mean([average_precision(pred, rel) for pred, rel in zip(predictions, relevants)])
def reciprocal_rank(predicted, relevant):
for i, p in enumerate(predicted):
if p in relevant:
return 1 / (i + 1)
return 0
def compute_ndcg(predictions, relevants, k):
relevance_scores = np.array([[1 if doc in rel else 0 for doc in pred[:k]] for pred, rel in zip(predictions, relevants)])
return np.mean([ndcg_score([rel], [pred]) for rel, pred in zip(relevance_scores, relevance_scores)])
num_queries = 10
docs_per_query = 10
all_docs = [f"doc_{i}" for i in range(100)]
predictions = [np.random.choice(all_docs, docs_per_query, replace=False).tolist() for _ in range(num_queries)]
relevants = [np.random.choice(pred, size=np.random.randint(1, 5), replace=False).tolist() for pred in predictions]
k_values = [1, 3, 5, 10]
precision_scores = {k: np.mean([precision_at_k(pred, rel, k) for pred, rel in zip(predictions, relevants)]) for k in k_values}
recall_scores = {k: np.mean([recall_at_k(pred, rel, k) for pred, rel in zip(predictions, relevants)]) for k in k_values}
f1_scores = {k: np.mean([f1_score_at_k(pred, rel, k) for pred, rel in zip(predictions, relevants)]) for k in k_values}
map_score = mean_average_precision(predictions, relevants)
ndcg_score_value = compute_ndcg(predictions, relevants, k=10)
Но одним из самых простых вариантов оценки является выбор модели по бенчмарку encodechka [3]. Правда, стоит отметить, что датасет этого бенчмарк не фокусируются на конкретной предметной области, поэтому в случае адаптации RAG к специфическим данным (например, извлечение программного кода или JSON), необходимо самостоятельно готовить данные.
Оценка векторной базы данных
Оценка векторной базы данных подразумевает оценку, скорее, не качества поиска, а производительности, в том числе эффективность хранения, масштабируемость, потребление ресурсов и устойчивость к изменениям данных.Основными метриками являются время поиска, пропускная способность и потребление памяти в конкретной инфраструктуре. При оценке векторной базы можно, в целом, руководствоваться готовыми бенчмарками, например, от Zilliz Cloud [4].
Оценка предобработки запросов
В автоматическом режиме оценка предобработки запросов может осуществляться с помощью генеративных моделей, работающих по принципу “LLM-as-judge”, например, через Alpaca Eval [5] или другие аналогичные фреймворки, с использованием других библиотек. Суть подхода “LLM-as-a-judge” состоит в том, что большие языковые модели (LLM) сами выступают в роли “судей” и оценивают ответы, имитируя человеческую экспертизу без привлечения внешних специалистов. Благодаря этому снижаются трудозатраты на оценку моделей.
Пример кода
import json
import openai
import pandas as pd
import torch
from openai import OpenAI
from transformers import AutoModelForCausalLM, AutoTokenizer
import re
openai.api_key = "..."
model_name = "belyakoff/llama-3.2-3b-instruct-fine-tuned"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto")
def extract_json(md_text):
json_pattern = re.compile(r'``jsons*([sS]*?)s*``', re.MULTILINE)
matches = json_pattern.findall(md_text)
json_objects = []
for match in matches:
try:
json_objects.append(json.loads(match))
except json.JSONDecodeError as e:
print(f"Ошибка парсинга JSON: {e}")
return json_objects
def rephrase_question(question, chat_history):
prompt = f"Переформулируй вопрос, учитывая контекст:nИстория чата: {chat_history}nВопрос: {question}nПереформулированный вопрос:"
inputs = tokenizer.apply_chat_template({'role': 'user', 'content': prompt}, return_tensors="pt", tokenize=True).to(model.device)
outputs = model.generate(**inputs, max_length=200, do_sample=True, temperature=0.4)
return tokenizer.decode(outputs[0], skip_special_tokens=True).replace(prompt, "").strip()
def evaluate_question(original, rephrased):
messages = [
{"role": "system",
"content": "Ты эксперт по анализу текста. Твоя задача - оценить, насколько хорошо измененная формулировка передает смысл оригинального вопроса."},
{"role": "user", "content": f"""
Оригинальный вопрос: {original}
Переформулированный вопрос: {rephrased}
0. Порассуждай над ответом.
1. Дай оценку от 1 до 10, насколько смысл остался тем же.
2. Объясни свой выбор одним-двумя предложениями.
В конце ответа обзятаельно напиши JSON с оценкой в формате {{"value": "<твоя оценка>"}}.
"""}
]
response = OpenAI().chat.completions.create(model="gpt-4o", messages=messages, temperature=0.2)
return extract_json(response.choices[0].message.content)[0]
df = pd.DataFrame({
"question": ["Как погода сегодня?",],
"chat_history": ["Куда пойти в Иркутске?", ]
})
df["rephrased_question"] = df.apply(lambda row: rephrase_question(row["question"], row["chat_history"]), axis=1)
df["evaluation"] = df.apply(lambda row: evaluate_question(row["question"], row["rephrased_question"]), axis=1)
Оценка синтезированного ответа
Оценка последнего компонента, как правило, производится вручную экспертами, а может и с помощью заранее заготовленных ответов. Если автоматизировать данный процесс, можно использовать заранее заготовленные ответы в комбинации с подходом LLM-as-judge (как в предыдущем фрагменте), или специальной предобученной модели на задачу оценки соответствия, или с использованием метрик для оценки перекрытий.
В таблице ниже также оценка того, как и когда выбирать метрики.
Метрика |
Что измеряет? |
Когда использовать? |
Недостатки |
BLEU |
Перекрытие n-грамм между сгенерированным и эталонным текстом |
При оценке машинного перевода, кратких текстов |
Не учитывает смысл и порядок слов, чувствителен к длине |
ROUGE |
Совпадение n-грамм и последовательностей слов между текстами |
При оценке рефератов, суммаризации текста |
Чувствителен к длине текста, не учитывает синонимы |
METEOR |
Учитывает порядок слов, синонимы и морфологию |
Когда важны смысловые соответствия, а не только точные совпадения |
Трудозатратен в вычислении, требует сложной обработки |
В программном коде реализация оценки может выглядеть следующим образом:
Пример кода
import nltk
import numpy as np
import pandas as pd
from nltk.translate.bleu_score import sentence_bleu
from nltk.translate.meteor_score import meteor_score
from rouge import Rouge
nltk.download('wordnet')
def calculate_bleu(reference, candidate):
return sentence_bleu([reference], candidate)
def calculate_rouge(reference, candidate):
rouge = Rouge()
scores = rouge.get_scores(" ".join(candidate), " ".join(reference))
return scores[0]['rouge-l']['f']
def calculate_meteor(reference, candidate):
return meteor_score([reference], candidate)
def compute_text_metrics(predictions, references):
results = []
for pred, ref in zip(predictions, references):
pred_tokens = pred.split()
ref_tokens = ref.split()
bleu = calculate_bleu(ref_tokens, pred_tokens)
rougel = calculate_rouge(ref_tokens, pred_tokens)
meteor = calculate_meteor(ref_tokens, pred_tokens)
results.append({
'BLEU': bleu,
'ROUGE-L': rougel,
'METEOR': meteor
})
return results
predicted = [
"Живописный закат окрасил небо в багряные оттенки.",
"Робот-исследователь успешно приземлился на Марсе.",
"Искусственный интеллект научился писать стихи."
]
reference = [
"Солнце скрылось за горизонтом, оставив алые краски в небе.",
"Марсоход совершил мягкую посадку на поверхность Красной планеты.",
"Компьютерная программа теперь способна сочинять поэзию."
]
metrics = compute_text_metrics(predicted, reference)
Таким образом, оценка RAG является куда более многоступенчатой и включает в себя взаимодействие каждого из компонентов, а не только свойств LLM и эмбеддингов.
3. Как улучшить наивный RAG?
Семантический чанкинг
Схема

Семантический чанкинг, в отличие от обычного, позволяет формировать границы чанков на основе контекстной связи между предложениями. При этом могут использоваться контекстуальные модели, например, эмбеддинги или модели логического вывода, такие как MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7, чтобы оценивать, насколько следующий фрагмент естественно вытекает из предыдущего, противоречит ему либо не имеет прямой смысловой связи. Если NLI-модель регистрирует резкий логический разрыв или переход к другой теме, на этом месте устанавливается новая граница чанка. Аналогично, если использовать модель для эмбеддингов и косинусную близость. Такое решение не полагается на длину текста, расстановку подзаголовков или форматирование, а исходит из содержательных связей. Например, соединение чанков может быть сделано так:
Пример кода
from langchain_core.documents import Document
def same_sentence(premise, hypothesis, zero_shot_classifier):
input = f'{premise}nn{hypothesis}'
candidate_labels = ['в одном предложении', 'в разных предложениях']
output = zero_shot_classifier(input, candidate_labels, multi_label=False)
true_idx = output['labels'].index('в одном предложении')
pred = numpy.argmax(output['scores']) == true_idx
return pred
def merge_docs(chunks, nli_model):
merged_chunks = []
def _add_chunk(chunk: Document):
merged_chunks.append(chunk)
premise = chunks[0]
for i in tqdm(range(1, len(chunks) - 1), ):
hypothesis = chunks[i]
if same_sentence(premise, hypothesis, nli_model):
premise_candidate = premise.page_content + ' ' + hypothesis.page_content
if count_tokens(premise_candidate) > 500:
_add_chunk(premise)
premise = hypothesis
else:
premise = Document(premise_candidate, metadata=premise.metadata)
else:
_add_chunk(premise)
premise = hypothesis
_add_chunk(premise)
return merged_chunks
В этом коде функция последовательно перебирает массив документов, сравнивая соседние элементы с помощью модели логического вывода, и при совпадении объединяет их текст, если это не превышает заданный лимит в 500 токенов. При превышении лимита или при отсутствии совпадения текущий документ добавляется в итоговый список, после чего процесс продолжается со следующим документом. В итоге формируется список слитых документов с учётом общей смысловой нагрузки. Немного другой вариант семантического чанкинга отражен в этом репозитории.
Гибридный поиск
Схема

Гибридный поиск представляет собой стратегию извлечения информации, при которой данные разбиваются на несколько смысловых полей, например, такие как название документа, основной текст, краткое описание и т.д.
Суть подхода заключается в том, что каждая из этих колонок переводится в векторное пространство, при этом каждая колонка может иметь свой собственный векторный индекс. Такой многоколоночный метод позволяет учитывать контекст и структуру данных более тонко, чем классический одноколончатый индекс, в котором все тексты хранятся и обрабатываются как единый набор.
Одноколончатая схема подходит для самых простых случаев, где необходима лишь поверхностная векторизация общего контента, однако при усложнении структуры данных и многоплановой аналитике такой индекс может упустить важные детали.
Одним из простейших способов реализации гибридного поиска является схема вопрос-ответ, где каждый из векторных пространств анализируется на соответствие заданному вопросу, после чего возвращается наиболее релевантный результат.
В случае гибридного поиска значимость каждой колонки может регулироваться через весовые коэффициенты, что даёт возможность гибко настраивать алгоритм под конкретные задачи: если, например, критически важны заголовки, им можно придать больший вес при расчёте итогового сходства.
Например, в коде это может быть реализовано следующим образом:
Пример кода
import numpy as np
def hybrid_search(query, vector_index, weights):
query_vector = encode(query)
results = []
for doc_id, vectors in vector_index.items():
score = 0
for column_name, vector in vectors.items():
score += np.dot(query_vector, vector) * weights.get(column_name, 1.0)
results.append((doc_id, score))
results.sort(key=lambda x: x[1], reverse=True)
return results
Анасамблевые методы
Скрытый текст

Ансамблевые методы поиска в RAG являются стратегией, при которой несколько независимых моделей одновременно извлекают релевантные документы (включая мультиязычные и мультимодальные данные), а их результаты объединяются для последующей генерации ответа; благодаря этому компенсируются недостатки каждой модели, что особенно важно в случаях, когда необходимо учитывать как разные языки, так и различные типы контента (например, тексты с изображениями), обеспечивая более широкий охват и точность итогового результата.
Пример кода
import numpy as np
def ensemble_search(query, vectorizers, vector_index, weights):
query_vectors = {}
for name, vectorizer_func in vectorizers.items():
query_vectors[name] = vectorizer_func(query)
results = []
for doc_id, doc_vectors in vector_index.items():
score = 0
for name, doc_vector in doc_vectors.items():
if name in query_vectors:
score += np.dot(query_vectors[name], doc_vector) * weights.get(name, 1.0)
results.append((doc_id, score))
results.sort(key=lambda x: x[1], reverse=True)
return results
RAG-fusion
Схема

RAG-Fusion [6] объединяет идею классического RAG с алгоритмом reciprocal rank fusion (RRF), генерируя несколько переформулированных под-запросов и используя их для извлечения релевантных документов с помощью поискового движка или системы векторного поиска. RRF -- это метод комбинированного ранжирования, где каждому документу, встречающемуся в нескольких списках выдачи, присваивается балл по формуле, отражающей обратную величину позиции документа, а затем документы сортируются по сумме этих баллов, благодаря чему наиболее релевантные материалы оказываются вверху итогового списка. Наконец, объединенный набор документов вместе с исходным и под-запросами подаются на вход большой языковой модели, которая формирует итоговый ответ, учитывая всю доступную информацию.
Пример кода
import faiss
import numpy as np
import torch
from sentence_transformers import SentenceTransformer
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
docs = [
"Микроконтроллер с ARM Cortex-M4 ядром и тактовой частотой 200 МГц",
"Новый высокопроизводительный микроконтроллер для промышленных приложений",
"Оперативная память 512 КБ, флеш-память 1 МБ, набор периферийных интерфейсов",
"Микроконтроллер со встроенным модулем беспроводной связи",
"Низкое энергопотребление и широкий диапазон рабочих напряжений"
]
embedding_model = SentenceTransformer("belyakoff/XLM-RoBERTa-485")
doc_embeddings = embedding_model.encode(docs, convert_to_numpy=True)
index = faiss.IndexFlatL2(doc_embeddings.shape[1])
index.add(doc_embeddings)
tokenizer = AutoTokenizer.from_pretrained("belyakoff/llama-3.2-3b-instruct-fine-tuned")
model = AutoModelForCausalLM.from_pretrained("belyakoff/llama-3.2-3b-instruct-fine-tuned")
generator = pipeline("text-generation", model=model, tokenizer=tokenizer)
def search(query, top_k=5):
q_emb = embedding_model.encode([query], convert_to_numpy=True)
dists, inds = index.search(q_emb, top_k)
r = []
for i, idx in enumerate(inds[0]):
r.append({"document_id": f"doc_{idx}", "text": docs[idx], "rank": i+1})
return r
def generate_sub_queries(q, g, n=3):
r = []
for i in range(n):
t = g(f"Переформулируй запрос: {q} (вариант {i+1})", max_new_tokens=30)
r.append(t[0]["generated_text"])
return r
def reciprocal_rank_fusion(lst, k=60):
s = {}
for l in lst:
for d in l:
i = d["document_id"]
r = d["rank"]
sc = 1/(k+r)
if i not in s:
s[i] = {"document_id": i, "text": d["text"], "score": 0}
s[i]["score"] += sc
f = sorted(s.values(), key=lambda x: x["score"], reverse=True)
return f
def generate_answer(g, uq, sq, docs_, top_n=3):
d = docs_[:top_n]
p = f"Пользовательский запрос: {uq}nnАльтернативные запросы: {sq}nnНайденные документы:n"
for i, doc in enumerate(d, 1):
p += f"{i}. [{doc['document_id']}] {doc['text']}n"
p += "nСформируй ответ на основе данной информации:"
ans = g(p, max_new_tokens=100)
return ans[0]["generated_text"]
def rag_fusion_pipeline(q, g):
sub_q = generate_sub_queries(q, g)
all_lists = []
for sq in sub_q:
r = search(sq)
all_lists.append(r)
fused = reciprocal_rank_fusion(all_lists)
return generate_answer(g, q, sub_q, fused)
x = "Какие технические характеристики нового микроконтроллера?"
res = rag_fusion_pipeline(x, generator)
print(res)
Graph RAG
В классическом RAG, основанном на векторном поиске, из огромного количества документов система отбирает несколько наиболее релевантных фрагментов, а затем подаёт их в контекст языковой модели. Такой механизм эффективно отвечает на конкретные вопросы, где весь нужный контент умещается в узком наборе найденных абзацев. Однако он сталкивается с трудностями при попытках дать комплексный обзор по целому корпусу, поскольку краткие локальные выборки не способны охватить всё тематическое многообразие, а размер окна контекста у модели всё равно ограничен.

Чтобы преодолеть эти ограничения, Graph RAG дополняет базовый принцип RAG за счёт построения графовой структуры знаний по всему корпусу. Система разбивает текст на чанки, после чего извлекает сущности и отношения, а затем группирует их в сообщества с помощью алгоритмов кластеризации (например, Лейденская). Для каждого такого сообщества, между которыми установлены прочные связи, формируются краткие сводки, фиксирующие смысловое содержание соответствующей части графа. В результате вся информация иерархически сегментируется, и при ответе на пользовательский запрос языковая модель может "собирать" ответ, опираясь на эти сводки. В момент запроса на основе входных данных (чанков из сводок сообществ) модель параллельно генерирует набор промежуточных ответов, каждый из которых отражает интерпретацию части глобального контекста, затем полученные ответы ранжируются по релевантности к запросу и агрегируются в один финальный, глобальный ответ, который максимально полно охватывает запрос пользователя [7].
Заключение
Таким образом, RAG представляет собой мощный инструмент для объединения поиска и генерации информации, позволяя значительно повысить точность и актуальность ответов моделей. Однако её "наивная" реализация часто сталкивается с ограничениями, будь то недостаточная релевантность или некачественная предобработка. В статье были рассмотрены основные методы улучшения RAG, которые помогают не только повысить точность и надежность генерации, но и адаптировать технологию к более сложным сценариям, требующим глубокой аналитики и многошагового рассуждения. Внедрение таких методов открывает новые возможности для развития интеллектуальных диалоговых систем, рекомендательных сервисов и бизнес-аналитики, делая RAG одним из перспективных направлений в области генеративного ИИ.
ССЫЛКИ
0. Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks https://arxiv.org/abs/2005.11401
1. The Ultimate Guide to Evaluate RAG System Components: What You Need to Know -- https://myscale.com/blog/ultimate-guide-to-evaluate-rag-system
2. Lost in the Middle: How Language Models Use Long Contexts https://arxiv.org/pdf/2307.03172
3. Encodechka https://github.com/avidale/encodechka
4. Vector Database Benchmark Tool https://zilliz.com/vector-database-benchmark-tool
5. AlpacaEval https://github.com/tatsu-lab/alpaca_eval
6. RAG-Fusion: a New Take on Retrieval-Augmented Generation https://arxiv.org/html/2402.03367v2
7. From Local to Global: A GraphRAG Approach to Query-Focused Summarization https://arxiv.org/pdf/2404.16130
Автор: fangorntb