Переранжування в RAG: крос-енкодери проти суддів у вигляді LLM та їхній затримки
Як двоступеневий реранкер виправляє порядок кандидатів у RAG, порівняння крос-енкодерів із суддями у вигляді LLM, витрати на затримку, способи збоїв та як їх оцінювати.
Джерела: Reranking for RAG: Cross-Encoders, LLM Rerankers, and Latency Tradeoffs
Правильний фрагмент був у результатах, просто занадто низько
Уявіть собі систему RAG, чия гібридна пошукова функція нарешті почала працювати належним чином. База векторів запущена, індекс ключових слів працює, логіка об’єднання даних написана, а фрагмент, який містить відповідь, тепер з’являється серед кандидатів. Проте модель все одно формує посередню відповідь.
Журнали подій пояснюють чому. Розробник запитав, як виправити помилку PAYMENTS_API_TIMEOUT у тестовому середовищі. Гібридний пошук повернув двадцять кандидатів: загальний огляд процедур автентифікації, застарілу записку про інцидент з минулого року, загальну сторінку з інформацією про логіку повторних спроб та, на восьмому місці, фрагмент посібника для тестового середовища з точним рішенням проблеми. У запиті було місце лише для трьох фрагментів. Правильний фрагмент знаходився занадто низько, тому модель склала свою відповідь на основі неправильних даних, оскільки правильні не вписувалися у обмеження.
Механізм пошуку знаходить докази, але не розміщує їх на першому місці. Мета пошуку — відтворення інформації, тоді як мета переранжування — точність у рамках обмеженого ресурсу.
Сам по собі хороший набір кандидатів недостатній. Те, що насправді читає модель, залежить від порядку елементів цього набору, і саме цей порядок є вузьким місцем.
Що робить етап переранжування
Перший проходження побудоване так, щоб бути швидким та охоплювати багато даних. Запит до векторної бази даних чи індексу ключових слів має обробити мільйони записів за мілісекунди, тому ці системи спираються на наближення та заздалегідь обчислені індекси, і навмисно знаходять більше даних, ніж потрібно.
Поновна класифікація додає другий етап, який бере невелику підмножину результатів та детальніше їх аналізує. Він є повільнішим, але точнішим, і взагалі не торкається бази даних. Усі результати, отримані під час першого проходу — зазвичай двадцять чи п’ятдесят найкращих — піддаються переоцінці за допомогою більш потужної та точної моделі.
Перший прохід генерує кандидатів, а поновна класифікація вирішує, які з них отримають місце в обмеженому вікні контексту.
Чому перший прохід не може виконати завдання повністю
Якщо модель ембеддингів є якісною, чи не має найкращий результат просто опинитися на першому місці? Не завжди, оскільки процес пошуку під час першого проходу за своєю суттю є шумним.
Оцінка ключових слів, така як BM25, залежить від точного збігу термінів. Широка довідкова сторінка, де код помилки згадується п’ятдесят разів, часто перемагає короткий посібник з кроками, де він згадується двічі, адже алгоритм сприймає частоту як релевантність.
Отримання векторів має іншу проблему — шум. Ембеддинги вимірюють семантичну близькість, розміщуючи концепції у високовимірному просторі. Для запитань щодо усунення несправностей вони видають фрагменти, пов’язані з згаданими системами. Загальні огляди зазвичай знаходяться дуже близько до конкретних процедур у цьому просторі. Фрагмент, що описує архітектуру оплати, може мати вищий рейтинг, ніж саме команда, яка перезапускає процес оплати. Модель знає, що обидва випадки стосуються оплати, але не може відрізнити операційну інструкцію від теоретичного опису.
Гібридний підхід до отримання інформації об’єднує лексичні та семантичні списки в єдиний всеосяжний набір кандидатів, який містить необхідні докази. Однак у цьому наборі також є дублікати, застарілі документи, загальні узагальнення та фрагменти, які є лише частково релевантними.
Результати першого етапу отримання інформації ніколи не слід вважати кінцевим рівнем класифікації. Він працює як воронка, а повторна класифікація є фільтром у кінці цієї воронки.
Два способи переурядкування
Як тільки з’являються кандидати, потрібно їх якось впорядкувати. Стандартом стали два підходи. Один з них використовує крос-енкодери — спеціалізовані моделі, навчені оцінювати ступінь релевантності документа до запиту. Інший використовує великі мовні моделі, де модель на кшталт Claude чи GPT отримує запит та документ та їй ставлять запитання про те, наскільки корисним є документ.
Обидва підходи значно покращують якість ранжування порівняно з першим проходом. Вони відрізняються за часом виконання, витратами, детермінізмом та технічними вимогами.
Крос-енкодери
Крос-енкодер — це трансформер, хоча він працює інакше, ніж моделі ембеддингів у векторній базі даних.
Стандартна модель ембеддингу проходить запит через мережу, щоб отримати один вектор, а документ — щоб отримати інший, після чого порівнює ці два вектори за допомогою косинусної схожості. Це є конструкція бі-енкодера, і вона є швидкою, оскільки кожен вектор документа може бути обчислений заздалегідь.
Крос-енкодер не створює окремих векторів. Він об’єднує запит та документ у один рядок та одночасно пропускає весь цей рядок через мережу. Таким чином механізм самоуваги може на кожному рівні аналізувати слова запиту та слова документу разом, бачачи, як терміни пов’язані в контексті. Результатом є єдиний показник релевантності.
Ця конструкція чудово підходить для прийняття рішень щодо релевантності на дрібному рівні. Результати є прогнозованими та стабільними, зі значно меншою варіацією, ніж при використанні мовної моделі, і вона надійно віддає перевагу конкретному фрагменту інформації перед широким оглядом.
Ось функція переранжування, створена на основі API крос-кодеру, що працює на хостингу.
import cohere
import time
def rerank_cross_encoder(
query: str,
candidates: list[dict],
top_n: int = 5,
model: str = "rerank-v4.0-pro",
) -> list[dict]:
"""
The practical default for second-stage ranking.
Passes the query and candidate texts to a dedicated cross-encoder model.
"""
co = cohere.ClientV2()
# Extract just the text content for the API call
documents = [c["content"] for c in candidates]
resp = co.rerank(
model=model,
query=query,
documents=documents,
top_n=top_n,
)
# Reattach the original metadata and the new score
reranked = []
for r in resp.results:
original_chunk = candidates[r.index]
reranked.append({
**original_chunk,
"rerank_score": r.relevance_score
})
return reranked
У продакшені крос-енкодер підходить для десяти–п’ятдесяти найкращих кандидатів. Команди зазвичай використовують його під час пошуку документації для інженерів, у базах знань підтримки клієнтів та в внутрішніх помічниках.
Основною проблемою є швидкість. Крос-енкодер не може обробити всю базу даних. Він чекає на перший проходження, отримує кандидатів через API або локальну модель, а потім чекає на оцінки, що додає затримку через мережевий зв’язок та крок обробки даних.
Інструменти для переранкінгу LLM
Альтернативою є універсальна мовна модель, яка виступає у ролі оцінювача. Суворий запит містить запит користувача та один фрагмент кандидата, і модель повертає оцінку.
Інструменти для переранкінгу LLM ефективно справляються з тонкощами. Крос-енкодер шукає глибоку семантичну взаємозв’язок, тоді як LLM також може перевірити, чи завершена процедура, обробити суперечливі дані та відокремити фрагменти з конкретними кроками від тих, що містять лише контекст. У запиті можна вказати, щоб була застосована покарання за нечіткість або надано перевагу фрагментам із прикладами коду.
Цей запит для оцінювача використовує клієнта Anthropic.
import anthropic
JUDGE_PROMPT = """\
You are a strict relevance judge. Given a user query and a candidate document chunk,
rate how well the chunk answers the query on a scale of 0 to 10.
Respond with ONLY a JSON object in this exact format:
{"score": <int>, "reason": "<one short sentence>"}
Query: {query}
Chunk: {chunk}"""
async def rerank_llm(
query: str,
candidates: list[dict],
top_n: int = 5,
) -> list[dict]:
"""
Expensive special forces. Uses an LLM to reason about nuance and completeness.
"""
client = anthropic.AsyncAnthropic()
scored = []
for c in candidates:
resp = await client.messages.create(
model="claude-opus-4-6",
max_tokens=128,
messages=[{
"role": "user",
"content": JUDGE_PROMPT.format(query=query, chunk=c["content"]),
}],
)
import json
try:
result = json.loads(resp.content[0].text)
scored.append({
**c,
"rerank_score": result["score"],
"reason": result.get("reason", "")
})
except (json.JSONDecodeError, KeyError):
# Fallback if the model fails to follow JSON instructions
scored.append({**c, "rerank_score": 0, "reason": "parse_error"})
# Sort by the LLM-assigned score descending
scored.sort(key=lambda x: x["rerank_score"], reverse=True)
return scored[:top_n]
Від моделі вимагають навести причину разом із оцінкою. Спочатку написати обґрунтування діє як метод ланцюгових міркувань та допомагає стабілізувати кінцеве число.
Вони заробляють на запитах високої цінності, у системах з низькою пропускною здатністю, де точність відповіді виправдовує очікування, а також у складних корпусах даних, де релевантність залежить від нюансів політики, а не лише від збігу теми.
Витрати є високими. Для кожного кандидатського фрагмента виконується повне запускання LLM, і навіть коли запуски відбуваються одночасно, найповільніша відповідь обмежує результат. Витрати значно перевищують витрати крос-енкодера, а результат менш детермінований.
Як реранкінг впливає на затримку
Додатковий етап змінює спосіб роботи системи. Це призводить до додаткових запусків моделі, додаткової серіалізації текстових даних та додаткового часу на передачу даних через мережу.
Користувачам байдуже, як організовано пошук, і вони не сприймають метрики, що вимірюють ефективність відтворення інформації. Вони звертають увагу на якість відповіді та час її отримання. Теоретично ідеальний інструмент для переранжування є марним, якщо він робить асистента настільки повільним, що його неможливо вважати надійним.
Цей допоміжний інструмент вимірює час виконання функції переранжування.
def rerank_with_timing(
rerank_fn: callable,
query: str,
candidates: list[dict],
top_n: int = 5,
) -> tuple[list[dict], float]:
"""
Measure the exact cost of the reranking stage.
"""
t0 = time.perf_counter()
results = rerank_fn(query, candidates, top_n)
latency_ms = (time.perf_counter() - t0) * 1000
return results, latency_ms
Надсилання двадцяти кандидатів до API швидкого крос-енкодера може додати від 150 до 300 мілісекунд, що зазвичай є прийнятною ціною за кращий контекст. Надсилання п’ятдесяти кандидатів до моделі LLM може збільшити час на два–чотири секунди. З урахуванням ще трьох секунд на генерацію користувач чекає сім секунд, що перевищує допустимі терміни у інтерфейсі чату.
Кількість кандидатів, які підлягають переранжуванню, потрібно налаштовувати. Топ-десять — це швидкий варіант, але він може пропустити потрібну інформацію, якщо перший прохід має проблеми. Топ-п’ятдесят охоплює майже все, але вичерпує весь бюджет на затримку. Для крос-енкодера в промисловому використанні зазвичай оптимальним є варіант з топ-двадцятьма.
Коли переранжування є корисним
Додайте механізм переранжування, коли пошук на першому проході дає багато шуму, але зазвичай знаходить необхідні дані.
Якщо сліди свідчать про те, що саме цей фрагмент доказів часто зустрічається у кандидатах, але знаходиться на десятому чи п’ятнадцятому місці, переранжування негайно виправить ситуацію. Це обґрунтовано, коли широкі документи регулярно перевершують точні процедури, а якість відповіді сильно залежить від наявності найкращих трьох фрагментів у запиті, тож точність має перевагу над простою кількістю відтворених даних.
Інструкції, внутрішні асистенти з політик та інструменти вирішення проблем майже завжди потребують засобу для переранжування. Вибір правильної команди замість майже правильної може вирішити, чи буде інцидент вирішений, чи перетвориться на перерву у роботі.
Коли переранжування є зайвим
Якщо у вас дуже малий корпус даних, пропустіть цей крок. Базу знань з п’ятдесяти сторінок можна повністю вбудувати та обслуговувати за допомогою векторного пошуку.
Це також марно, коли перший етап вже майже ідеальний. У системі, де потрібно багато пошуків, і BM25 надійно розміщує правильну посилання API на першому місці, крос-кодер лише уповільнює процес, не змінюючи результату.
Дуже обмежений бюджет на затримку, як у голосових асистентів чи функцій автодоповнення коду, не залишає місця для додаткової стадії. Налаштуйте перший етап та прийміть певний рівень «шуму». А коли правильний фрагмент зазвичай вже опиняється на першому чи другому місці, ранжування вже не є перешкодою.
Форми збоїв, на які потрібно звернути увагу
Інструменти для переранжування виправляють порядок, але приносять власні проблеми. Слідкуйте за ними після розгортання.
Упередження на користь загального огляду. Деякі крос-кодери навчаються на даних, які винагороджують всебічні відповіді. Загальний огляд може мати вищий рейтинг, ніж невеликий, точний фрагмент коду, оскільки огляд має більше спільного словникового запасу з запитом.
Подвійне навантаження. Якщо корпус містить три версії одного посібника з розгортання, під час першого проходження, ймовірно, будуть завантажені всі три. Система переоцінки вважає, що кожен з них добре відповідає на запит, тож присвоює їм високі бали. Три найкращі результати у вікні контексту містять однакову інформацію, що усуває можливість включення наступного унікального доказу. Такий інструмент допомагає усувати майже ідентичні записи.
def dedupe_candidates(
candidates: list[dict],
similarity_threshold: float = 0.85,
) -> list[dict]:
seen_tokens: list[set[str]] = []
deduped = []
for c in candidates:
tokens = set(c["content"].lower().split())
is_dup = False
for s in seen_tokens:
# Calculate simple Jaccard similarity
overlap = len(tokens & s) / max(len(tokens | s), 1)
if overlap >= similarity_threshold:
is_dup = True
break
if not is_dup:
deduped.append(c)
seen_tokens.append(tokens)
return deduped
Упередженість щодо застарілих документів. Крос-кодер читає лише текст та не має уявлення про те, який зараз рік. Добре опрацьований посібник за 2022 рік може мати кращі показники, ніж поспішно створений посібник за 2026 рік, навіть якщо старіший документ вже повністю застарів. Метадані, які додаються після переранжування, коригують оцінки з урахуванням свіжості та статусу документа.
from datetime import datetime, timezone
def apply_metadata_boost(
candidates: list[dict],
freshness_halflife_days: int = 90,
) -> list[dict]:
now = datetime.now(timezone.utc)
boosted = []
for c in candidates:
score = c.get("rerank_score", 0.0)
# Hard penalty for superseded documentation
if c.get("status") == "superseded":
score *= 0.4
# Gradual decay for older documents
updated = c.get("updated_at")
if updated:
age_days = (now - updated).days
decay_factor = max(0.5, 1 - age_days / (freshness_halflife_days * 2))
score *= decay_factor
boosted.append({**c, "rerank_score": score})
# Sort again based on the adjusted scores
boosted.sort(key=lambda x: x["rerank_score"], reverse=True)
return boosted
Вибух затримки. Якість покращується після впровадження, але час від часу система зупиняється на п’ять секунд. Це зазвичай трапляється, коли кількість кандидатів різко зростає або якість зовнішнього API для переранжування погіршується. Завжди слід передбачити механізм аварійного виходу з таймаутом, який ігнорує процес переранжування та повертає результати першого проходу, якщо другий етап триває занадто довго.
Правильна оцінка інструменту переранжування
Команди часто вимірюють не те, перевіряючи, чи з’являється правильний фрагмент даних серед двадцяти найкращих результатів. Пошук першого проходу вже враховує це.
Краще запитання полягає у тому, чи підняв інструмент переранжування правильний фрагмент на достатню висоту, щоб це мало значення. Це означає необхідність вимірювання релевантності перших трьох результатів, повноти відповіді та ефективності усунення дублікатів, а також відстеження затримок та витрат на кожен запит.
Створіть інструмент оцінки, який порівнює базовий варіант першого проходження з крос-енкодером та реранкером LLM, використовуючи ідентичні набори кандидатів.
from dataclasses import dataclass
@dataclass
class RerankEvalCase:
query: str
expected_substring: str
category: str # e.g., "identifier", "procedure", "troubleshooting"
def eval_reranking(
cases: list[RerankEvalCase],
retrieve_fn: callable,
rerank_fns: dict[str, callable | None],
top_n: int = 3,
) -> dict:
"""
Compare multiple reranking strategies against a baseline.
Measures hit rate at top-N and tracks latency overhead.
"""
results = {}
for name, rerank_fn in rerank_fns.items():
hits = 0
total_latency = 0.0
by_type: dict[str, dict] = {}
for case in cases:
# Get the exact same starting candidates for every strategy
candidates = retrieve_fn(case.query)
if rerank_fn is not None:
reranked, lat = rerank_with_timing(
rerank_fn, case.query, candidates, top_n
)
total_latency += lat
else:
# Baseline: just take the top-N from first-pass retrieval
reranked = candidates[:top_n]
# Check if the expected evidence made it into the final prompt window
top_contents = [r["content"] for r in reranked]
found = any(case.expected_substring in c for c in top_contents)
hits += int(found)
# Track metrics by query category
by_type.setdefault(case.category, {"hit": 0, "total": 0})
by_type[case.category]["total"] += 1
by_type[case.category]["hit"] += int(found)
total = len(cases)
results[name] = {
"hit_rate": hits / total if total else 0,
"avg_latency_ms": total_latency / total if total else 0,
"by_type": {
t: {**v, "rate": v["hit"] / v["total"]}
for t, v in by_type.items()
},
}
return results
Набір тестів має включати кілька типів запитів: ті, що залежать від ідентифікаторів, де важлива точна послідовність символів, ті, що вимагають дотримання процедур, де важливий порядок кроків, та ті для вирішення проблем, де симптоми вказують на первинні причини.
Ці цифри точно показують, що отримує алгоритм переранжування. Якщо крос-кодер підвищує частку топ-3 результатів з 60% до 85% протягом 200 мілісекунд, його слід використовувати. Якщо алгоритм переранжування на основі LLM досягає рівня 88% протягом трьох секунд, крос-кодер, ймовірно, залишиться. Переранжування має бути обґрунтоване кращими кінцевими відповідями в межах реалістичних термінів відгуку.
Розумний стандартний набір інструментів
Надійна база для системи виробництва дотримується певної послідовності. Розділяйте документи за допомогою часткового розбиття з урахуванням структури. Використовуйте гібридний метод пошуку для збору від двадцяти до тридцяти кандидатів. Усувайте дублікати, щоб позбутися майже ідентичного тексту. Оцінюйте решту за допомогою крос-кодеру щодо семантичної релевантності. Застосуйте підвищення значення для метаданих, яке карає за застарілі документи. Потім візьміть три-п’ять найкращих фрагментів з остаточного відсортованого списку та створіть запит.
def two_stage_retrieve(
query: str,
retrieve_fn: callable,
top_k: int = 20,
top_n: int = 5,
) -> tuple[list[dict], dict]:
"""
The complete production pipeline for second-stage ranking.
"""
t0 = time.perf_counter()
# Stage 1: Fast hybrid retrieval
candidates = retrieve_fn(query)[:top_k]
# Clean up the candidate pool
candidates = dedupe_candidates(candidates)
# Stage 2: Cross-encoder rerank
# We score slightly more than top_n to allow metadata boosts to reorder the edges
score_limit = min(top_n * 2, len(candidates))
reranked = rerank_cross_encoder(query, candidates, top_n=score_limit)
# Apply business logic for freshness and status
final = apply_metadata_boost(reranked)[:top_n]
latency = (time.perf_counter() - t0) * 1000
trace = {
"query": query,
"first_pass_count": len(candidates),
"post_rerank_count": len(reranked),
"final_count": len(final),
"latency_ms": latency,
}
return final, trace
Стандартний підхід ефективний, оскільки кожен інструмент виконує ту роботу, яка йому підходить. Механізм отримання даних забезпечує відтворення кандидатів, механізм переранжування — порядок кандидатів, а крос-кодери забезпечують найкраще співвідношення якості та затримки для цього другого етапу.
Є один недолік: переранжувач не слід використовувати для усунення проблем із пошкодженими даними. При поганому розділенні даних він просто буде правильно сортувати пошкоджені частини. При відсутності метаданих він не може визначити, який документ є застарілим. Спочатку потрібно виправити процес отримання та вилучення даних.
Що все ще залишається нерозв’язаним у переранжуванні
Переранжувач гарантує, що найсильніші докази з отриманого набору даних потраплять до моделі. Однак він не може сказати, чи був пошук за векторами взагалі правильним рішенням. Візьмемо запит про кількість активних користувачів у певному регіоні: спрямування його до індексу документації є помилковим, оскільки ця цифра знаходиться у SQL-базі даних.
Якість отримання інформації має кілька рівнів. На першому етапі знаходиться текст, а на другому — він сортується, проте ще до цього приймається рішення про те, де шукати. Направлення запиту між векторним пошуком, SQL-запитами та живим веб-пошуком на основі наміру користувача є рівнем, який передує усьому вищезазначеному.