Зниження витрат на токени в технології RAG на 67% за допомогою функцій Multivectors та ізоляції речень у 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.
- Надішліть до LLM лише відповідні речення.
Ціна для тестування припускає 3,00 долара за мільйон вхідних токенів, що відповідає середньому комерційному моделюванню.
Схема збору даних
Наведений нижче скрипт конфігурації налаштовує стандартні щільні ембеддинги та багатовекторні моделі ColBERT поруч одна з одною, причому моделі FastEmbed завантажуються локально на CPU.
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. Оскільки цей процес виконується лише з останніх кількох фрагментів, де текст є коротким, не виникає проблем із масштабуванням бази даних, а точність на рівні речень може бути досягнута з невеликими зусиллями на CPU.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:"
Розмір частини та «чесне» число
Саме скорочення кількості токенів є привабливим показником. Теоретично можна досягти скорочення понад 85%, об’єднавши десять–дванадцять юридичних розділів у одну частину обсягом 2 000 слів та потім застосувавши метод ізоляції речень. Однак у реальних юридичних системах такий підхід погіршує точність пошуку. Велика частина тексту заглушує сигнал конкретної клозули, наприклад, обмеження щодо відшкодування збитків від третіх сторін, серед навколишнього тексту в щільному векторному просторі. Запити до таких «брудних», багатоклозульних частин часто повертають несумісні розділи, такі як гарантії, оскільки алгоритм пошуку не може ізолювати потрібний об’єкт.
Рішення полягає у суворому обмеженні — один юридичний пункт на кожен блок, де кожен блок містить рівно один окремий розділ та пункти реалістичної довжини. За цією схемою тестування показало підтверджене зменшення кількості токенів запиту на 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 із чітким описом того, що вирішує кожен інструмент, та чого доводиться реалізовувати самостійно.