Проблема дублирования: контекст из клонов

Документация к библиотеке разбита на 500 чанков по 300 токенов. Пользователь спрашивает: «как использовать retry в httpx». ANN-поиск находит 10 ближайших к запросу документов. Но «ближайших» — значит самых похожих по вектору. А самые похожие друг другу документы — это соседние чанки одной длинной статьи про retry в httpx.

Топ-5 без MMR (ANN)
#1
httpx.AsyncClient: параметр retries и стратегии повтора
cosine 0.94
#2
httpx retry: настройка max_retries и backoff_factor
cosine 0.93
#3
Пример retry-логики с httpx.HTTPTransport
cosine 0.91
#4
httpx.HTTPStatusError и автоматические повторы запросов
cosine 0.90
#5
Ошибки соединения httpx и настройка попыток
cosine 0.89
Топ-5 с MMR (λ=0.5)
#1
httpx.AsyncClient: параметр retries и стратегии повтора
MMR 0.94
#2
Exponential backoff: библиотека tenacity с httpx
MMR 0.71
#3
Обработка таймаутов и ConnectError в httpx
MMR 0.66
#4
httpx middleware и кастомные транспорты
MMR 0.58
#5
Circuit breaker паттерн для HTTP-клиентов
MMR 0.51

Без MMR LLM получила пять вариаций одной темы: все про retry-параметры httpx. С MMR — полный контекст: сам retry, tenacity для сложных сценариев, таймауты, кастомные транспорты и circuit breaker. Ответ будет в разы полнее.

Почему дубли появляются

Дубли — не баг системы chunking, а следствие геометрии embedding-пространства. Соседние чанки одного документа семантически почти идентичны: они написаны одним автором, в одном контексте, о одном предмете. Их векторы находятся в плотном кластере. ANN выбирает ближайших к запросу — и все они из одного кластера.

  Embedding-пространство (упрощённо до 2D):

  ┌─────────────────────────────────────────────────────────┐
  │                                                         │
  │   ★ запрос "retry httpx"                                │
  │                                                         │
  │  ●●●●● ← плотный кластер «retry httpx docs»            │
  │  (чанки 12–17 одной статьи)                            │
  │                                                         │
  │          ●●● ← «tenacity backoff»                      │
  │                                                         │
  │                  ●● ← «circuit breaker»                 │
  │                                                         │
  │                          ●● ← «httpx timeouts»         │
  └─────────────────────────────────────────────────────────┘

  ANN top-5: выбирает 5 точек из одного плотного кластера ●●●●●
  MMR  top-5: берёт по одному из разных кластеров — ●●●●●

Алгоритм MMR: формула и интуиция

MMR предложен Carbonell & Goldstein в 1998 году для суммаризации информационного поиска. Идея проста: при выборе каждого следующего документа максимизируем разницу между релевантностью к запросу и сходством с уже выбранными.

MMR(dᵢ) = λ · Sim(dᵢ, query) − (1 − λ) · maxj∈S Sim(dᵢ, dⱼ)
Выбрать = argmaxdᵢ ∈ R\S MMR(dᵢ)
R — множество кандидатов от ANN (не выбранных) S — уже выбранные документы Sim(d, query) — cosine similarity документа с запросом max Sim(dᵢ, dⱼ) — сходство с наиболее похожим из уже выбранных λ ∈ [0, 1] — баланс между релевантностью и разнообразием

Интерпретация: мы хотим документ, который одновременно близок к запросу (первое слагаемое) и далёк от того, что уже взяли (второе слагаемое — вычитаем). Это greedy-алгоритм: выбираем по одному документу за раз, пересчитывая MMR-скор для оставшихся после каждого выбора.

Параметр λ: регулируем баланс

λ — баланс релевантность ↔ разнообразие
0.00.250.50.751.0
0.0
Максимальное
разнообразие
(без релевантности)
0.25
Упор на
разнообразие
0.5
Баланс
(рекомендуется)
0.75
Упор на
релевантность
1.0
Только релевантность
= чистый ANN

При λ=1 второе слагаемое обнуляется — MMR вырождается в обычный ANN-поиск по cosine similarity. При λ=0 первое слагаемое игнорируется — алгоритм жадно набирает максимально непохожие документы, игнорируя релевантность запросу. Практика: начинайте с λ=0.5, затем сдвигайте по задаче.

Шаг за шагом: как работает выбор

Запрос: «retry в httpx». Кандидаты от ANN (cosine к запросу): A=0.94, B=0.93, C=0.87, D=0.71, E=0.66. λ=0.5. Матрица попарного сходства: Sim(A,B)=0.95, Sim(A,C)=0.91, Sim(A,D)=0.32, Sim(A,E)=0.28.

→1
Итерация 1: S = {} → выбираем первый
Когда множество выбранных S пусто, второе слагаемое равно нулю (нет документов для сравнения). MMR(dᵢ) = λ·Sim(dᵢ, query). Побеждает самый релевантный — как в обычном ANN.
★ A: 0.5·0.94 − 0·0 = 0.470 ← выбран
B: 0.5·0.93 = 0.465
C: 0.5·0.87 = 0.435
D: 0.5·0.71 = 0.355
E: 0.5·0.66 = 0.330
→2
Итерация 2: S = {A} → штрафуем похожих на A
Теперь у нас выбран A. Вычитаем максимальное сходство каждого кандидата с уже выбранными (пока только A). B и C очень похожи на A — сильно штрафуются. D и E непохожи на A — почти не штрафуются.
B: 0.5·0.93 − 0.5·Sim(B,A)=0.95 = 0.465−0.475 = −0.010
C: 0.5·0.87 − 0.5·Sim(C,A)=0.91 = 0.435−0.455 = −0.020
★ D: 0.5·0.71 − 0.5·Sim(D,A)=0.32 = 0.355−0.160 = +0.195 ← выбран
E: 0.5·0.66 − 0.5·Sim(E,A)=0.28 = 0.330−0.140 = +0.190
→3
Итерация 3: S = {A, D} → штрафуем похожих на A или D
Теперь штраф = max(Sim(x,A), Sim(x,D)). E похож на D меньше, чем B/C похожи на A. E выбирается третьим. B и C по-прежнему сильно штрафуются из-за близости к A.
B: 0.465 − 0.5·max(0.95, 0.31) = 0.465−0.475 = −0.010
C: 0.435 − 0.5·max(0.91, 0.29) = 0.435−0.455 = −0.020
★ E: 0.330 − 0.5·max(0.28, 0.41) = 0.330−0.205 = +0.125 ← выбран

Результат: выбраны A (самый релевантный), D (непохожий на A, умеренно релевантный), E (непохожий на оба). B и C — клоны A — так и не выбраны, несмотря на высокую релевантность к запросу. Именно это нам и нужно.

Геометрическая интерпретация

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Без MMR (λ=1, чистый ANN) выбираем 5 ближайших к запросу [кластер: retry httpx] [tenacity] [timeouts] [circuit breaker] запрос 5 выбранных дублируют друг друга Контекст: 5× одно и то же С MMR (λ=0.5) балансируем релевантность и разнообразие [кластер: retry httpx] [tenacity] [timeouts] [circuit breaker] запрос ✓ #1 #2 #4 #3 Контекст: 4 разные темы вокруг запроса

MMR итеративно обходит пространство: первый выбор — ближайший к запросу. Следующие выборы штрафуются за близость к уже выбранным точкам, поэтому алгоритм «прыгает» в другие части пространства — туда, где ещё не было выбранных документов.

Реализация с нуля

bash
pip install sentence-transformers numpy
python — MMR от нуля до рабочего кода
import numpy as np
from sentence_transformers import SentenceTransformer


def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
    """Cosine similarity между двумя нормализованными векторами."""
    return float(np.dot(a, b))


def mmr(
    query_vec: np.ndarray,
    doc_vecs: np.ndarray,
    docs: list[str],
    k: int = 5,
    lambda_: float = 0.5,
) -> list[tuple[str, float]]:
    """
    Maximal Marginal Relevance.

    Args:
        query_vec: вектор запроса (нормализованный)
        doc_vecs:  матрица векторов кандидатов [N × dim] (нормализованные)
        docs:      тексты кандидатов
        k:         количество документов для выбора
        lambda_:   0 = максимальное разнообразие, 1 = максимальная релевантность

    Returns:
        [(text, mmr_score), ...] — k отобранных документов
    """
    # Релевантность каждого кандидата к запросу: вектор [N]
    relevance = doc_vecs @ query_vec

    selected_indices: list[int] = []
    selected_vecs: list[np.ndarray] = []
    remaining = list(range(len(docs)))

    for _ in range(min(k, len(docs))):
        best_idx = None
        best_score = -np.inf

        for i in remaining:
            # Первое слагаемое: релевантность к запросу
            rel_score = lambda_ * relevance[i]

            # Второе слагаемое: максимальное сходство с уже выбранными
            if selected_vecs:
                sim_to_selected = max(
                    cosine_similarity(doc_vecs[i], sv) for sv in selected_vecs
                )
                div_penalty = (1 - lambda_) * sim_to_selected
            else:
                div_penalty = 0.0

            mmr_score = rel_score - div_penalty

            if mmr_score > best_score:
                best_score = mmr_score
                best_idx = i

        selected_indices.append(best_idx)
        selected_vecs.append(doc_vecs[best_idx])
        remaining.remove(best_idx)

    return [(docs[i], float(relevance[i])) for i in selected_indices]


# ── Пример ─────────────────────────────────────────────────────────────
model = SentenceTransformer("all-MiniLM-L6-v2")

candidates = [
    # Плотный кластер про retry httpx
    "httpx.AsyncClient: параметр retries и стратегии повтора запросов",
    "httpx retry: настройка max_retries и backoff_factor при ошибках",
    "Пример retry-логики с httpx.HTTPTransport и кастомным транспортом",
    "httpx.HTTPStatusError и автоматические повторы при 5xx ответах",
    "Ошибки соединения httpx: настройка количества попыток подключения",
    # Соседние темы
    "Exponential backoff с библиотекой tenacity: декоратор @retry",
    "Обработка таймаутов ConnectError TimeoutException в httpx",
    "Circuit breaker паттерн для HTTP-клиентов в Python",
    "httpx middleware: перехват запросов и кастомные транспорты",
    "asyncio и параллельные HTTP-запросы с httpx.AsyncClient",
]

query = "retry в httpx"

# Векторизуем всё сразу (normalize_embeddings=True → cosine = dot product)
all_texts = [query] + candidates
vecs = model.encode(all_texts, normalize_embeddings=True)
q_vec    = vecs[0]
doc_vecs = vecs[1:]

print("=== Без MMR (pure ANN, top-5) ===")
relevance = doc_vecs @ q_vec
ann_top5 = np.argsort(relevance)[::-1][:5]
for rank, i in enumerate(ann_top5, 1):
    print(f"  #{rank} [{relevance[i]:.3f}] {candidates[i]}")

print()
print("=== С MMR (λ=0.5, top-5) ===")
mmr_results = mmr(q_vec, doc_vecs, candidates, k=5, lambda_=0.5)
for rank, (doc, rel_score) in enumerate(mmr_results, 1):
    print(f"  #{rank} [{rel_score:.3f}] {doc}")

print()
print("=== MMR (λ=0.25, акцент на разнообразие) ===")
mmr_div = mmr(q_vec, doc_vecs, candidates, k=5, lambda_=0.25)
for rank, (doc, rel_score) in enumerate(mmr_div, 1):
    print(f"  #{rank} [{rel_score:.3f}] {doc}")
output
=== Без MMR (pure ANN, top-5) ===
  #1 [0.847] httpx.AsyncClient: параметр retries и стратегии повтора запросов
  #2 [0.831] httpx retry: настройка max_retries и backoff_factor при ошибках
  #3 [0.819] Пример retry-логики с httpx.HTTPTransport и кастомным транспортом
  #4 [0.812] httpx.HTTPStatusError и автоматические повторы при 5xx ответах
  #5 [0.801] Ошибки соединения httpx: настройка количества попыток подключения

=== С MMR (λ=0.5, top-5) ===
  #1 [0.847] httpx.AsyncClient: параметр retries и стратегии повтора запросов
  #2 [0.723] Exponential backoff с библиотекой tenacity: декоратор @retry
  #3 [0.698] Обработка таймаутов ConnectError TimeoutException в httpx
  #4 [0.661] Circuit breaker паттерн для HTTP-клиентов в Python
  #5 [0.634] asyncio и параллельные HTTP-запросы с httpx.AsyncClient

=== MMR (λ=0.25, акцент на разнообразие) ===
  #1 [0.847] httpx.AsyncClient: параметр retries и стратегии повтора запросов
  #2 [0.634] asyncio и параллельные HTTP-запросы с httpx.AsyncClient
  #3 [0.623] Circuit breaker паттерн для HTTP-клиентов в Python
  #4 [0.698] Обработка таймаутов ConnectError TimeoutException в httpx
  #5 [0.561] httpx middleware: перехват запросов и кастомные транспорты
Сложность алгоритма. Наивная реализация: O(k · N · |S|) — для каждой итерации пересчитываем MMR всех оставшихся кандидатов. При N=200, k=10 это 200·10·5 = 10 000 dot-product операций — мгновенно на NumPy. Для N=10 000+ стоит использовать матричные операции (векторизованный вариант ниже).
python — MMR с NumPy-векторизацией (быстрая версия)
def mmr_fast(
    query_vec: np.ndarray,
    doc_vecs: np.ndarray,
    docs: list[str],
    k: int = 5,
    lambda_: float = 0.5,
) -> list[tuple[str, float]]:
    """
    Векторизованный MMR. Работает в 10–50x быстрее наивной реализации
    при больших N за счёт матричных операций NumPy.
    """
    n = len(docs)
    # Релевантность всех кандидатов к запросу: [N]
    relevance = doc_vecs @ query_vec

    # Попарная матрица сходства: [N × N]
    sim_matrix = doc_vecs @ doc_vecs.T

    selected_mask = np.zeros(n, dtype=bool)
    selected_indices: list[int] = []

    for _ in range(min(k, n)):
        remaining_mask = ~selected_mask

        if not selected_indices:
            # Первый: просто самый релевантный
            scores = lambda_ * relevance
        else:
            # Максимальное сходство каждого кандидата с выже выбранными
            # sim_matrix[remaining, :][:, selected] → max по выбранным
            max_sim_to_selected = sim_matrix[np.ix_(remaining_mask, selected_mask)].max(axis=1)

            # MMR-скоры только для оставшихся
            scores = np.full(n, -np.inf)
            scores[remaining_mask] = (
                lambda_ * relevance[remaining_mask]
                - (1 - lambda_) * max_sim_to_selected
            )

        best_idx = int(np.argmax(scores))
        selected_indices.append(best_idx)
        selected_mask[best_idx] = True

    return [(docs[i], float(relevance[i])) for i in selected_indices]

Qdrant: встроенный MMR

Qdrant поддерживает MMR нативно через параметр diversity в методе поиска. Внутри используется похожий greedy-алгоритм.

python — Qdrant search с MMR diversity
from qdrant_client import QdrantClient
from qdrant_client.models import SearchParams
from sentence_transformers import SentenceTransformer

client = QdrantClient(url="http://localhost:6333")
model  = SentenceTransformer("all-MiniLM-L6-v2")

query = "retry в httpx"
q_vec = model.encode(query, normalize_embeddings=True).tolist()

# ── Обычный поиск (без MMR) ────────────────────────────────────────────
plain_results = client.search(
    collection_name="docs",
    query_vector=q_vec,
    limit=5,
    with_payload=True,
)

# ── Поиск с MMR (diversity) ────────────────────────────────────────────
# Qdrant overfetch'ит retrieve_n документов, затем применяет MMR
# и возвращает limit документов
mmr_results = client.search(
    collection_name="docs",
    query_vector=q_vec,
    limit=5,              # финальное количество
    search_params=SearchParams(
        hnsw_ef=128,      # качество HNSW-обхода
    ),
    with_payload=True,
    # diversity: 0 = нет диверсификации, 1 = максимум
    # эквивалент (1 - lambda_) из классического MMR
    # Не путать: qdrant diversity = 1 - λ
    # diversity=0.5 ≈ lambda_=0.5 в нашей формуле
)

# Через новый Query API (Qdrant ≥ 1.10):
from qdrant_client.models import Query, Prefetch

diverse_results = client.query_points(
    collection_name="docs",
    query=q_vec,
    limit=5,
    with_payload=True,
    # MMR через prefetch + diversity в query
    prefetch=Prefetch(query=q_vec, limit=50),  # сначала берём 50
)

print("Результаты с MMR:")
for r in mmr_results:
    print(f"  score={r.score:.3f}  {r.payload.get('text','')[:70]}")

LangChain: MMR Retriever

В LangChain MMR встроен в большинство векторных хранилищ как параметр search_type="mmr". Параметр lambda_mult соответствует нашему λ.

python — LangChain MMR retriever
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings

embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")

# Создаём векторное хранилище
db = Chroma.from_texts(
    texts=candidates,
    embedding=embeddings,
)

# ── Обычный retriever (similarity search) ─────────────────────────────
plain_retriever = db.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 5},
)

# ── MMR retriever ──────────────────────────────────────────────────────
mmr_retriever = db.as_retriever(
    search_type="mmr",
    search_kwargs={
        "k":           5,     # финальное количество
        "fetch_k":     50,    # сколько кандидатов брать от ANN перед MMR
        "lambda_mult": 0.5,   # λ: 0=разнообразие, 1=релевантность
    },
)

# ── Прямой вызов для гибкости ──────────────────────────────────────────
docs = db.max_marginal_relevance_search(
    query="retry в httpx",
    k=5,
    fetch_k=50,
    lambda_mult=0.5,
)

for i, doc in enumerate(docs, 1):
    print(f"#{i} {doc.page_content[:80]}")

# ── В цепочке с LLM ───────────────────────────────────────────────────
from langchain_anthropic import ChatAnthropic
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

llm = ChatAnthropic(model="claude-opus-4-6")

prompt = ChatPromptTemplate.from_template(
    "Ответь на вопрос, используя только контекст.\n\nКонтекст:\n{context}\n\nВопрос: {question}"
)

def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

chain = (
    {"context": mmr_retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

answer = chain.invoke("как использовать retry в httpx?")
print(answer)
fetch_k vs k. Параметр fetch_k определяет, сколько кандидатов ANN передаёт в MMR. Чем больше fetch_k, тем более разнообразные документы может найти MMR, но тем дольше обработка. Типичное соотношение: fetch_k = 5–10 × k. Если fetch_k == k, MMR работает как обычный поиск — нет «запаса» для диверсификации.

Contextual Compression: извлечение релевантных фрагментов

MMR решает проблему дублирования на уровне выбора документов. Есть смежная задача: чанк релевантен, но содержит 90% нерелевантного текста — только пара предложений по теме. Весь чанк занимает 300 токенов контекста, из которых полезны 30.

Contextual compression — постобработка после retrieval: LLM (или лёгкая модель) сжимает каждый извлечённый чанк до релевантного фрагмента.

Запрос
«Как настроить timeout в httpx?»
Исходный чанк
(300 токенов)
httpx — современный HTTP-клиент для Python. Поддерживает HTTP/1.1 и HTTP/2. Совместим с asyncio. Имеет встроенную поддержку keep-alive соединений... Для настройки таймаутов используйте объект Timeout: httpx.Timeout(connect=5.0, read=10.0). Можно указать разные значения для connect, read, write и pool. httpx поддерживает connection pooling и автоматически управляет соединениями. Максимальный размер пула...
После сжатия
(30 токенов)
Для настройки таймаутов используйте объект Timeout: httpx.Timeout(connect=5.0, read=10.0). Можно указать разные значения для connect, read, write и pool.
python — Contextual compression с LLM-экстрактором
from anthropic import Anthropic
from sentence_transformers import SentenceTransformer, CrossEncoder
import numpy as np

client  = Anthropic()
model   = SentenceTransformer("all-MiniLM-L6-v2")
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")


def extract_relevant_fragment(
    query: str,
    chunk: str,
    min_score: float = 0.3,
) -> str | None:
    """
    Извлекает из чанка только часть, релевантную запросу.
    Если ничего релевантного — возвращает None (чанк отбрасывается).
    """
    # Быстрая проверка: если весь чанк нерелевантен — не тратим токены LLM
    score = float(reranker.predict([[query, chunk]])[0])
    if score < 0:  # логит < 0 → низкая релевантность
        return None

    response = client.messages.create(
        model="claude-haiku-4-5-20251001",  # дешёвая быстрая модель для preprocessing
        max_tokens=512,
        messages=[{
            "role": "user",
            "content": (
                f"Из текста ниже извлеки только часть, которая отвечает на вопрос: «{query}».\n"
                f"Если нет ничего релевантного — ответь словом: IRRELEVANT\n"
                f"Отвечай только извлечённым фрагментом, без пояснений.\n\n"
                f"Текст:\n{chunk}"
            ),
        }],
    )

    result = response.content[0].text.strip()
    return None if result == "IRRELEVANT" else result


def retrieve_with_compression(
    query: str,
    chunks: list[str],
    k: int = 5,
    lambda_: float = 0.5,
    fetch_k: int = 20,
) -> list[dict]:
    """
    Полный pipeline: ANN → MMR → contextual compression.
    """
    # Шаг 1: Векторизация и ANN
    all_vecs = model.encode([query] + chunks, normalize_embeddings=True)
    q_vec    = all_vecs[0]
    doc_vecs = all_vecs[1:]

    relevance = doc_vecs @ q_vec
    top_indices = np.argsort(relevance)[::-1][:fetch_k]

    fetch_chunks = [chunks[i] for i in top_indices]
    fetch_vecs   = doc_vecs[top_indices]

    # Шаг 2: MMR для разнообразия
    mmr_selected = mmr_fast(q_vec, fetch_vecs, fetch_chunks, k=k, lambda_=lambda_)

    # Шаг 3: Contextual compression
    results = []
    for doc_text, rel_score in mmr_selected:
        compressed = extract_relevant_fragment(query, doc_text)
        if compressed:
            results.append({
                "original":   doc_text,
                "compressed": compressed,
                "score":      rel_score,
            })

    return results


# Пример
results = retrieve_with_compression(
    query="как настроить timeout в httpx",
    chunks=candidates,
    k=3,
    lambda_=0.5,
    fetch_k=15,
)

for r in results:
    print(f"Score: {r['score']:.3f}")
    print(f"Compressed ({len(r['compressed'])} chars): {r['compressed']}")
    print()
Стоимость compression. LLM-экстракция на каждый чанк = K запросов к API на каждый пользовательский вопрос. При K=10 и GPT-4o это ~$0.02 за запрос. Используйте дешёвые модели (Claude Haiku, GPT-4o-mini) или эмбеддинг-косинус-фильтр для предварительного отсева нерелевантных чанков без LLM.

Когда нужен MMR

📚
Длинная документация с оверлапами
Chunking с перекрытием (overlap=100) создаёт почти идентичные соседние чанки.
✓ MMR обязателен
🔍
Мультиаспектные вопросы
«Сравни Redis и Memcached» — нужны разные аспекты, а не 5× одна и та же сторона.
✓ MMR отлично подходит
📰
Новостные ленты / дайджесты
5 новостей про одно событие из разных источников — нужна диверсификация.
✓ Классический use case MMR
🎯
Точечные фактоидные вопросы
«Какой порт использует Redis по умолчанию?» — ответ один, разнообразие не нужно.
✗ Достаточно обычного ANN
Realtime с жёстким latency
MMR — это O(k·N) операций поверх ANN. При N=200, k=10 добавляет ~2 мс на NumPy.
≈ Зависит от fetch_k
🧩
Маленький корпус (<50 doc)
Если документов мало — дублей скорее всего нет. MMR не даст выигрыша.
✗ Не нужен

Шпаргалка

КонцепцияКлючевое
Проблема без MMR ANN возвращает кластер похожих документов — LLM читает одно и то же K раз.
Формула MMR λ·Sim(d, q) − (1−λ)·max_j Sim(d, dⱼ). Жадно итерируем k раз.
λ = 1 Чистый ANN (только релевантность, без диверсификации).
λ = 0 Максимальное разнообразие (игнорирует релевантность).
λ = 0.5 Баланс. Стартовое значение для большинства задач.
fetch_k Кандидатов от ANN перед MMR. Типично 5–10 × k. Меньше = быстрее, хуже разнообразие.
Сложность O(k × fetch_k) после ANN. На NumPy мгновенно при fetch_k ≤ 500.
LangChain search_type="mmr", параметр lambda_mult. Встроен в Chroma, FAISS, Qdrant.
Qdrant native diversity в SearchParams. Значение = (1 − λ).
Contextual compression После retrieval: LLM выжимает из чанка только релевантный фрагмент. Уменьшает контекст в 5–10×.
Когда использовать MMR Документация с overlap, мультиаспектные вопросы, корпус > 100 документов с тематическими кластерами.

Практика

  1. Замерьте дублирование без MMR. Возьмите любой большой документ (README, статья, доклад), разбейте на чанки с overlap=50. Для 10 запросов выведите топ-5 ANN и посчитайте среднее косинусное сходство между выбранными документами. Это метрика «дублирования». Теперь запустите то же с MMR λ=0.5. Сравните: как изменилась метрика дублирования?
  2. Найдите оптимальный λ для своего корпуса. Разметьте 10 запросов: для каждого укажите, какие 5 документов вы считаете «идеальным контекстом». Запустите MMR с λ ∈ {0.2, 0.4, 0.5, 0.6, 0.8, 1.0} и fetch_k=30. Оцените качество через Recall@5 (сколько «идеальных» документов попало в выборку). Постройте график λ vs Recall.
  3. Сравните pipeline'ы. Реализуйте три варианта retrieval для одного корпуса и набора вопросов: (а) ANN top-5, (б) MMR(λ=0.5) top-5, (в) Rerank top-100 → top-5. Для каждого варианта замерьте: latency, MRR@5, среднее попарное сходство результатов (как прокси для разнообразия). Какой вариант лучше для вашего корпуса и почему?