Проблема дублирования: контекст из клонов
Документация к библиотеке разбита на 500 чанков по 300 токенов. Пользователь спрашивает: «как использовать retry в httpx». ANN-поиск находит 10 ближайших к запросу документов. Но «ближайших» — значит самых похожих по вектору. А самые похожие друг другу документы — это соседние чанки одной длинной статьи про retry в httpx.
Без 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 году для суммаризации информационного поиска. Идея проста: при выборе каждого следующего документа максимизируем разницу между релевантностью к запросу и сходством с уже выбранными.
Интерпретация: мы хотим документ, который одновременно близок к запросу (первое слагаемое) и далёк от того, что уже взяли (второе слагаемое — вычитаем). Это greedy-алгоритм: выбираем по одному документу за раз, пересчитывая MMR-скор для оставшихся после каждого выбора.
Параметр λ: регулируем баланс
При λ=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.
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
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
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 — так и не выбраны, несмотря на высокую релевантность к запросу. Именно это нам и нужно.
Геометрическая интерпретация
MMR итеративно обходит пространство: первый выбор — ближайший к запросу. Следующие выборы штрафуются за близость к уже выбранным точкам, поэтому алгоритм «прыгает» в другие части пространства — туда, где ещё не было выбранных документов.
Реализация с нуля
pip install sentence-transformers numpy
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}")
=== Без 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: перехват запросов и кастомные транспорты
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-алгоритм.
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
соответствует нашему λ.
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 определяет,
сколько кандидатов ANN передаёт в MMR. Чем больше fetch_k, тем более
разнообразные документы может найти MMR, но тем дольше обработка.
Типичное соотношение: fetch_k = 5–10 × k.
Если fetch_k == k, MMR работает как обычный поиск —
нет «запаса» для диверсификации.
Contextual Compression: извлечение релевантных фрагментов
MMR решает проблему дублирования на уровне выбора документов. Есть смежная задача: чанк релевантен, но содержит 90% нерелевантного текста — только пара предложений по теме. Весь чанк занимает 300 токенов контекста, из которых полезны 30.
Contextual compression — постобработка после retrieval: LLM (или лёгкая модель) сжимает каждый извлечённый чанк до релевантного фрагмента.
(300 токенов)
httpx.Timeout(connect=5.0, read=10.0). Можно указать разные значения для connect, read, write и pool.
httpx поддерживает connection pooling и автоматически управляет соединениями. Максимальный размер пула...
(30 токенов)
httpx.Timeout(connect=5.0, read=10.0). Можно указать разные значения для connect, read, write и pool.
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()
Когда нужен 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 документов с тематическими кластерами. |
Практика
- Замерьте дублирование без MMR. Возьмите любой большой документ (README, статья, доклад), разбейте на чанки с overlap=50. Для 10 запросов выведите топ-5 ANN и посчитайте среднее косинусное сходство между выбранными документами. Это метрика «дублирования». Теперь запустите то же с MMR λ=0.5. Сравните: как изменилась метрика дублирования?
- Найдите оптимальный λ для своего корпуса. Разметьте 10 запросов: для каждого укажите, какие 5 документов вы считаете «идеальным контекстом». Запустите MMR с λ ∈ {0.2, 0.4, 0.5, 0.6, 0.8, 1.0} и fetch_k=30. Оцените качество через Recall@5 (сколько «идеальных» документов попало в выборку). Постройте график λ vs Recall.
- Сравните pipeline'ы. Реализуйте три варианта retrieval для одного корпуса и набора вопросов: (а) ANN top-5, (б) MMR(λ=0.5) top-5, (в) Rerank top-100 → top-5. Для каждого варианта замерьте: latency, MRR@5, среднее попарное сходство результатов (как прокси для разнообразия). Какой вариант лучше для вашего корпуса и почему?