Сокращение расхода токенов в RAG на 67% с помощью мультивекторов Qdrant и изоляции предложений
Пайплайн для правового поиска на основе RAG, который переранжирует результаты с использованием ColBERT внутри Qdrant, изолирует соответствующее предложение и жертвует небольшой задержкой ради подтвержденной экономии 67,1% токенов.
Источники: How Qdrant Reduced RAG Token Costs by 67% with Native ColBERT Reranking
Проблема токенов стала серьезной заботой для команд, использующих технологии генерации с усилением через поиск информации. Крупные компании ищут способы сократить расходы, и многие команды обнаруживают, что неоптимизированный процесс работы с большими языковыми моделями может обойтись дороже, чем время, которое он должен был сэкономить. Проверка юридических документов является хорошей площадкой для тестирования, поскольку документы длинные, ответы краткие, а объем большой.
Страница, которая никому не была нужна
Представьте адвоката на третьей неделе рассмотрения дела по спору о контракте, который должен просмотреть множество соглашений о предоставлении услуг и корпоративных документов. Вопрос конкретен: до какой суммы может достигать лимит компенсации в случае утверждения третьей стороны о нарушении прав интеллектуальной собственности? Ответ на него содержится в одном предложении где-то в сорокалистовом контракте.
Стандартная система RAG возвращает три лучших фрагмента по 500–1 000 токенов каждый; в основном это изложения, определения, условия оплаты и положения о применимом законодательстве, которые не имеют никакого отношения к претензиям в области интеллектуальной собственности. Единственное полезное предложение находится посередине. Это раздражает читателя, а в масштабах — стоит дорого: при аудите тысяч документов в рамках судебного разбирательства поставщик LLM получает оплату за обработку огромного количества избыточного шаблонного текста.
Возникают два вопроса: насколько большая часть полученного текста действительно необходима? И можно ли выделить именно это предложение без использования дополнительного сервиса для переранжирования?
Ничто в этой проблеме связано непосредственно с законодательством. Любой набор длинных документов в шаблонном формате, таких как страховые полисы, контракты с поставщиками или руководства по соблюдению норм, имеет одинаковую структуру: краткий ответ, окружённый множеством страниц текста, которые при каждом поиске включаются в запрос. Расходы растут пропорционально размеру полученных фрагментов, а не размеру ответа, поэтому важно определить, какая часть каждого фрагмента попадёт к модели.
Почему происходит переранжирование в векторной базе данных
Типичная двухэтапная схема включает быстрый плотный поиск для отбора примерно 100 лучших кандидатов, затем второй сетевой запрос к внешнему инструменту переранжирования, такому как Cohere, SageMaker или пользовательский контейнер, для их сортировки. Этот подход работает, но имеет три недостатка.
- Проводятся два сетевых запроса двум разным поставщикам.
- Если инструмент переранжирования выходит из строя, поиск терпит неудачу.
- Этот проект предусматривает хранение векторов и переупорядочивание запросов.
Qdrant предлагает альтернативу. Начиная с версии 1.10 он поддерживает нативные многовекторные поля с компаратором MAX_SIM, который обеспечивает работу по принципу позднего взаимодействия в стиле ColBERT. Сравнение токенов выполняется непосредственно в базе данных в рамках одного запроса, благодаря чему достигается точность позднего взаимодействия без необходимости дополнительных операций. В сочетании с изоляцией предложений такая конфигурация позволяет сократить затраты на обработку токенов на 67%.
Настройки памяти дополняют этот профиль.
- Бинарная квантизация в плотном индексе. Плотные векторы сокращаются до одного бита на измерение, и эти небольшие векторы остаются в ОЗУ (always_ram=True), что обеспечивает очень быстрое предзагрузчивание кандидатов.
- Матрицы ColBERT на диске. Тяжелые матрицы токенов хранятся на диске (on_disk=True) и загружаются только для набора лучших кандидатов — примерно 50–100 фрагментов — во время пересчёта оценок, что позволяет снизить нагрузку на оперативную память.
Поток обработки
Процесс состоит из шести шагов, включающих один запрос к базе данных и не использующих внешние API для переурочивания кандидатов.
- Разделите документы на разделы так, чтобы ни один фрагмент не разрезал юридическую клаузулу пополам.
- Зашифруйте каждый фрагмент дважды: один раз в виде стандартного плотного вектора для быстрой обработки, а второй раз — в виде многовекторной матрицы ColBERT для пересчёта оценок на уровне токенов.
- Загрузите заранее лучшие N кандидатов с использованием двоично квантизированных плотных векторов.
- Пересчитайте оценки этих кандидатов с помощью алгоритма ColBERT MAX_SIM внутри Qdrant.
- Возьмите лучшие фрагменты и оцените их отдельные предложения локально по сравнению с вектором запроса ColBERT.
- Отправьте в ЯЗЫК-модель только соответствующие предложения.
Цена для эталонного теста исчислялась в размере 3,00 доллара за миллион входных токенов, что соответствует стандартной коммерческой модели среднего уровня.
Схема сбора данных
Приведённый ниже скрипт конфигурации настраивает стандартные плотные эмбеддинги и многовекторные модели ColBERT рядом друг с другом, причём модели FastEmbed загружаются локально на процессоре.
from qdrant_client import QdrantClient, models
# ADDED: Load FastEmbed models locally on CPU
from fastembed import TextEmbedding, LateInteractionTextEmbedding
COLLECTION_NAME = "legal_discovery"
DENSE_DIM = 384 # BAAI/bge-small-en-v1.5
COLBERT_DIM = 128 # colbert-ir/colbertv2.0
# ADDED: Instantiate the vector models
dense_model = TextEmbedding("BAAI/bge-small-en-v1.5")
colbert_model = LateInteractionTextEmbedding("colbert-ir/colbertv2.0")
client = QdrantClient("<http://localhost:6333>")
client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config={
"dense": models.VectorParams(
size=DENSE_DIM,
distance=models.Distance.COSINE,
quantization_config=models.BinaryQuantization(
binary=models.BinaryQuantizationConfig(always_ram=True),
),
),
"colbert": models.VectorParams(
size=COLBERT_DIM,
distance=models.Distance.COSINE,
multivector_config=models.MultiVectorConfig(
comparator=models.MultiVectorComparator.MAX_SIM
),
on_disk=True,
hnsw_config=models.HnswConfigDiff(m=0),
),
},
)
Среди параметров векторов ColBERT выделяется одна настройка: конфигурация HNSW с параметром m, установленным в 0. Поскольку ColBERT используется только для пересчёта оценок предварительно отфильтрованного набора данных, для него не требуется граф HNSW. Игнорирование графа позволяет существенно сократить время создания индекса и объём хранилища.
Одна операция для предзагрузки и пересчёта оценок
Универсальный API запросов Qdrant позволяет выполнять предзагрузку на первом этапе и пересчёт оценок на втором этапе в виде одной операции. Не требуется формирование запроса, парсинг ID и повторный обращение к механизму переоценки; весь процесс осуществляется в рамках одной операции обмена данными с базой данных.
# ADDED: Generate query embeddings (ColBERT uses query_embed to add prefix padding)
dense_query = next(dense_model.query_embed(query)).tolist()
colbert_query = next(colbert_model.query_embed(query)).tolist()
# Run the two-stage query in one network round-trip
results = client.query_points(
collection_name=COLLECTION_NAME,
prefetch=models.Prefetch(
query=dense_query,
using="dense",
limit=prefetch_limit,
params=models.SearchParams(
quantization=models.QuantizationSearchParams(rescore=False),
),
),
query=colbert_query,
using="colbert",
limit=top_k,
with_payload=True,
)
Заслуживает внимания флаг rescore=False внутри блока prefetch. При бинарной квантовке Qdrant обычно загружает кандидатов с квантизированными векторами, а затем загружает векторы с полной точностью типа float32 для их повторной оценки. Поскольку этап ColBERT и так выполняет более точную оценку, отключение процедуры повторной оценки с полной точностью позволяет избежать чтения векторов с полной точностью с диска и снижает нагрузку на базу данных.
Функция MAX_SIM выдает одну общую оценку для каждого фрагмента и не указывает, какие токены повлияли на неё. Чтобы извлечь только релевантный текст, на последнем этапе происходит разделение победивших фрагментов на предложения, и для каждого из них вычисляются оценки ColBERT late-interaction. Поскольку этот процесс выполняется только с последними несколькими фрагментами, где объём текста невелик, не возникает проблем с масштабированием базы данных, а точность на уровне предложений достигается с небольшими затратами ресурсов процессора.import re
import numpy as np
# ADDED: Basic sentence splitter regex
SENTENCE_SPLIT = re.compile(r"(?<=[.;])\s+(?=[A-Z])")
def max_sim(query_vecs: np.ndarray, doc_vecs: np.ndarray) -> float:
# Compute token-to-token similarity matrix
sims = query_vecs @ doc_vecs.T # (num_query_tokens, num_doc_tokens)
# Sum the maximum similarity scores along the document axis
return float(sims.max(axis=1).sum())
def isolate_sentences(chunk_text: str, query_vecs: np.ndarray, colbert_model, top_n: int = 1):
# ADDED: Split chunk text into candidate sentences
sentences = [s.strip() for s in SENTENCE_SPLIT.split(chunk_text) if len(s.strip()) > 15]
if not sentences:
return [(chunk_text, 0.0)]
# Embed each sentence locally using ColBERT
sentence_vecs = list(colbert_model.embed(sentences))
scored = [(sentences[i], max_sim(query_vecs, sentence_vecs[i])) for i in range(len(sentences))]
scored.sort(key=lambda pair: pair[1], reverse=True)
return scored[:top_n]
import re
import numpy as np
# ADDED: Basic sentence splitter regex
SENTENCE_SPLIT = re.compile(r"(?<=[.;])\s+(?=[A-Z])")
def max_sim(query_vecs: np.ndarray, doc_vecs: np.ndarray) -> float:
# Compute token-to-token similarity matrix
sims = query_vecs @ doc_vecs.T # (num_query_tokens, num_doc_tokens)
# Sum the maximum similarity scores along the document axis
return float(sims.max(axis=1).sum())
def isolate_sentences(chunk_text: str, query_vecs: np.ndarray, colbert_model, top_n: int = 1):
# ADDED: Split chunk text into candidate sentences
sentences = [s.strip() for s in SENTENCE_SPLIT.split(chunk_text) if len(s.strip()) > 15]
if not sentences:
return [(chunk_text, 0.0)]
# Embed each sentence locally using ColBERT
sentence_vecs = list(colbert_model.embed(sentences))
scored = [(sentences[i], max_sim(query_vecs, sentence_vecs[i])) for i in range(len(sentences))]
scored.sort(key=lambda pair: pair[1], reverse=True)
return scored[:top_n]
Локальные вычисления происходят быстро, поскольку они затрагивают лишь три-пять верхних фрагментов. После того как определяются лучшие предложения, остальная часть каждого фрагмента игнорируется, и формируется полезная нагрузка запроса.
def build_optimized_prompt(query: str, chunk_texts: list[str], colbert_model) -> str:
query_vecs = next(colbert_model.query_embed(query))
context_parts = []
for i, text in enumerate(chunk_texts):
top_sentences = isolate_sentences(text, query_vecs, colbert_model, top_n=1)
isolated_text = " ".join(s for s, _ in top_sentences)
context_parts.append(f"[Source Chunk {i+1}]: {isolated_text}")
context_str = "\n\n".join(context_parts)
return f"Context:\n{context_str}\n\nQuestion: {query}\nAnswer:"
Размер блока и «честное» число
Само сокращение количества токенов кажется привлекательным показателем. Теоретически, собрав десять–двенадцать юридических разделов в один блок из 2 000 слов и затем применив метод изоляции предложений, можно добиться сокращения объема более чем на 85%. Однако в реальных юридических системах такой подход снижает точность поиска. Большой блок заглушает сигнал конкретной клаузулы, например, ограничения ответственности за нарушение прав третьих сторон, среди окружающего «шаблонного» текста в плотном векторном пространстве. Запросы к таким «загрязненным» блокам, содержащим несколько клаузул, часто возвращают несвязанные разделы, такие как гарантии, поскольку алгоритмы поиска не могут выделить целевой элемент.
Решение заключается в строгом ограничении: в каждом блоке должна находиться ровно одна юридическая клаузула, каждый блок содержит именно один отдельный раздел, а длина клаузул должна быть реалистичной. Согласно этой схеме тест показал подтверждённое сокращение количества токенов запроса на 67,1%. Ручная проверка запросов, касающихся ответственности и прав собственности на ИС, подтвердила наличие правильных клаузул, несмотря на то, что общий показатель снизился с теоретических 87,3% до практических 67,1%. Чёткие границы между разделами важнее, чем более высокий процент.
Компромисс между задержкой и производительностью
Бинарная квантизация позволяет предположить, что двухэтапная схема может превзойти обычный плотный поиск по скорости. Однако это не так. В ходе множественных тестов двухэтапный поиск был в среднем в два–четыре раза медленнее, в зависимости от оборудования и уровня заполнения кэша: 39,5 мс против 20,4 мс. Причина проста. Поиск с использованием только плотных эмбеддингов предполагает один запрос к базе данных, тогда как двухэтапная версия запускает оба кодера — плотный и ColBERT — и выполняет сравнение токенов внутри базы данных. Отдельный тест поиска с использованием только плотных эмбеддингов показал среднее время в 7,4 мс.
Изменение лимита предзагрузки с 10 кандидатов до 500 практически ничего не изменило, и задержка осталась на прежнем уровне. Дополнительное время обусловлено самой двухэтапной структурой, а не размером окна предзагрузки.
В целом задержка в работе системы типа RAG от начала до конца определяется временем генерации модели LLM, а не запросами к базе данных. Сокращение количества токенов на запрос до более чем 400 позволяет ускорить последующую обработку и генерацию на сотни миллисекунд, в результате чего вся приложение становится заметно быстрее.
Ценность экономии
При стоимости 3,00 доллара за миллион входных токенов сокращение количества токенов в запросах на 67,1% имеет значительные последствия. Для процесса анализа документов в судебных делах, включающего 500 000 запросов, это означает экономию более 600 долларов на каждую проверку дела. Кроме того, более краткие запросы снижают риск упуска релевантной информации, скрытой в длинном контексте.
Что проверить перед внедрением
Три показателя определяют, подходит ли дизайн для конкретной нагрузки. Первый — это точность ответов на наборе запросов, проверенном вручную, поскольку экономия токенов мало значит, если отсутствует правильное условие. Второй — задержка всего запроса, включая процесс генерации, поскольку дополнительное время работы с базой данных незначительно по сравнению с временем вызова модели. Третий — количество токенов в каждом запросе до и после изоляции предложений, измеряемое с учетом действующей системы ценообразования. Результаты для одного типа документов могут не переноситься на другой, поэтому тестирование следует повторять на целевом корпусе данных.
Уроки для практического применения
- Сохраняйте процесс переранжирования внутри базы данных. Внешние API для переранжирования добавляют точки отказа и задержки из-за сетевых запросов, тогда как встроенный оператор MAX_SIM выполняет работу за один запрос.
- Отключите HNSW для полей, предназначенных только для пересчёта оценок. Установка значения m=0 в настройках многовекторных моделей позволяет избежать дорогостоящего этапа индексации, который не приносит никакой пользы для поиска матриц ColBERT.
- Не доверяйте слишком большим фрагментам текста. Чрезмерно большие размеры могут давать впечатляющее сокращение количества токенов на этапе теоретических расчётов, но при этом снижать семантическую точность и ухудшать качество результатов.
- Используйте настоящий токенизатор предложений. Для демонстраций подойдёт простой регулярный выражение, но в производственном коде следует использовать библиотеки вроде NLTK или spaCy, способные корректно обрабатывать юридические аббревиатуры, такие как Inc., Sec. и Exhibit A, без неправильного разделения предложений.
Нативное сравнение многовекторных данных в сочетании с локальной оценкой на уровне предложений позволяет создать точную и экономичную систему, которая возвращает необходимый пользователю фрагмент текста без увеличения ежемесячных расходов на API.
Связанные материалы
- Создание системы RAG с LangChain с нуля: загрузчики, фрагменты данных, эмбеддинги, оценка — пошаговое создание системы вопросов и ответов на основе документов: загрузка файлов, разбиение на фрагменты, использование эмбеддингов, этапы индексации и поиска, а также способы оценки качества найденных ответов.
- Claude Agent SDK против OpenAI Agents SDK против Google ADK: один агент, три варианта реализации — создание одного и того же агента для работы с этикетками пищевых продуктов с использованием Claude Agent SDK, OpenAI Agents SDK и Google ADK, с четким описанием того, что обрабатывает каждый инструмент, и чего придется сделать самостоятельно.