Ранжирование и фильтрация результатов поиска
Оценка релевантности, удаление дубликатов и выбор лучших результатов для контекста.
«Ранжирование и фильтрация результатов поиска» — бесплатный урок AI Agents на CoddyKit. Это урок 2 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения AI Agents, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс AI Agents содержит 4 уроков всего.
Почему важны ранжирование и фильтрация
API поиска возвращает 5–10 результатов, но не все они одинаково релевантны, надёжны или полезны для задачи агента. Сырые результаты, напрямую переданные LLM, расходуют токены контекста и могут вносить шум или дезинформацию.
Ранжирование и фильтрация улучшают соотношение сигнала и шума до того, как результаты попадут в LLM.
Оценка релевантности с помощью BM25
BM25 (наилучшее совпадение 25) — классический алгоритм ранжирования текста, который оценивает документы по совпадению ключевых слов с запросом. Он хорошо подходит для лексического сопоставления — когда в запросе и документе используются одни и те же слова.
Установите его командой pip install rank-bm25.
from rank_bm25 import BM25Okapi
def rank_with_bm25(query, results):
# Tokenize: lowercase and split into words
tokenized_results = [
r['content'].lower().split()
for r in results
]
bm25 = BM25Okapi(tokenized_results)
query_tokens = query.lower().split()
scores = bm25.get_scores(query_tokens)
# Sort results by score descending
ranked = sorted(
zip(scores, results),
key=lambda x: x[0],
reverse=True
)
return [(score, result) for score, result in ranked]Оценка релевантности с помощью эмбеддингов
BM25 сопоставляет только точные совпадения слов. Сходство эмбеддингов учитывает смысл — поэтому «разработка веб-приложений на Python» и «создание веб-сайтов с помощью Django» получают высокую оценку сходства даже при разных формулировках.
Используйте косинусное сходство между эмбеддингом запроса и эмбеддингами результатов.
import numpy as np
import openai
import os
client = openai.OpenAI(api_key=os.getenv('OPENAI_API_KEY'))
def embed(text):
resp = client.embeddings.create(
model='text-embedding-3-small',
input=text[:8000]
)
return np.array(resp.data[0].embedding)
def cosine_similarity(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
def rank_by_embedding(query, results):
q_emb = embed(query)
scored = []
for r in results:
r_emb = embed(r['content'][:1000])
score = cosine_similarity(q_emb, r_emb)
scored.append((score, r))
return sorted(scored, key=lambda x: x[0], reverse=True)Гибридное ранжирование: BM25 + эмбеддинги
BM25 и оценка на основе эмбеддингов отражают разные стороны релевантности. Гибридное ранжирование объединяет обе оценки с помощью взвешенного среднего, обеспечивая преимущества лексического и семантического сопоставления.
def hybrid_rank(query, results, bm25_weight=0.4, embed_weight=0.6):
# Get BM25 scores (normalized 0-1)
bm25_scored = rank_with_bm25(query, results)
max_bm25 = max(s for s, _ in bm25_scored) or 1
bm25_norm = {r['url']: s / max_bm25 for s, r in bm25_scored}
# Get embedding scores
embed_scored = rank_by_embedding(query, results)
embed_norm = {r['url']: s for s, r in embed_scored}
# Combine
combined = []
for r in results:
url = r['url']
score = (bm25_weight * bm25_norm.get(url, 0) +
embed_weight * embed_norm.get(url, 0))
combined.append((score, r))
return sorted(combined, key=lambda x: x[0], reverse=True)Устранение дубликатов по адресу
Результаты поиска часто содержат почти одинаковые элементы: одну и ту же статью из нескольких источников, публикующих её одновременно, или одну и ту же страницу с разными параметрами адреса. Перед передачей результатов в LLM такие дубликаты нужно убрать.
from urllib.parse import urlparse, urlunparse
def normalize_url(url):
parsed = urlparse(url)
# Remove query params and fragment (tracking params, etc.)
clean = parsed._replace(query='', fragment='')
return urlunparse(clean).rstrip('/')
def deduplicate_results(results):
seen_urls = set()
unique = []
for r in results:
url = normalize_url(r.get('url', ''))
if url not in seen_urls:
seen_urls.add(url)
unique.append(r)
return unique
# Also deduplicate by content similarity (near-duplicate detection)
def deduplicate_by_content(results, min_unique_ratio=0.7):
unique = [results[0]] if results else []
for candidate in results[1:]:
cand_words = set(candidate['content'].lower().split())
is_duplicate = False
for kept in unique:
kept_words = set(kept['content'].lower().split())
overlap = len(cand_words & kept_words) / max(len(cand_words), 1)
if overlap > (1 - min_unique_ratio):
is_duplicate = True
break
if not is_duplicate:
unique.append(candidate)
return unique
if __name__ == '__main__':
demo_results = [
{'url': 'https://example.com/a?utm_source=x'},
{'url': 'https://example.com/a'},
{'url': 'https://example.com/b'},
]
unique = deduplicate_results(demo_results)
print(f'{len(demo_results)} results -> {len(unique)} unique')
for r in unique:
print(' -', r['url'])
Оценка качества домена
Результат с сайта docs.python.org надёжнее результата из случайного блога. Назначьте множители качества для разных уровней доменов и учтите их в итоговом ранжировании.
DOMAIN_QUALITY = {
# Tier 1 — authoritative (1.3x boost)
'docs.python.org': 1.3,
'developer.mozilla.org': 1.3,
'arxiv.org': 1.3,
'github.com': 1.2,
'stackoverflow.com': 1.2,
# Tier 2 — good (1.0x, no change)
# Tier 3 — low quality (penalty)
'pinterest.com': 0.3,
'quora.com': 0.5,
'wikihow.com': 0.6
}
def get_domain_multiplier(url):
from urllib.parse import urlparse
domain = urlparse(url).netloc.lower().replace('www.', '')
return DOMAIN_QUALITY.get(domain, 1.0) # default: no change
def apply_domain_boost(scored_results):
boosted = []
for score, r in scored_results:
multiplier = get_domain_multiplier(r.get('url', ''))
boosted.append((score * multiplier, r))
return sorted(boosted, key=lambda x: x[0], reverse=True)
if __name__ == '__main__':
scored = [(1.0, {'url': 'https://pinterest.com/x'}), (1.0, {'url': 'https://docs.python.org/x'})]
for score, r in apply_domain_boost(scored):
print(f"{r['url']}: boosted score {score:.2f}")
Фильтрация некачественных результатов
Некоторые результаты структурно некачественны независимо от домена: они слишком короткие, в основном содержат элементы навигации или находятся на страницах, доступных только после входа в систему. Отфильтруйте их до ранжирования.
MIN_CONTENT_LENGTH = 200 # characters
LOW_QUALITY_SIGNALS = [
'sign in to view',
'please log in',
'subscribe to read',
'404 not found',
'access denied',
'this content is for members only'
]
def is_quality_result(result):
content = result.get('content', '')
# Too short
if len(content) < MIN_CONTENT_LENGTH:
return False
# Paywall / access barrier detected
content_lower = content.lower()
for signal in LOW_QUALITY_SIGNALS:
if signal in content_lower:
return False
return True
def filter_results(results):
return [r for r in results if is_quality_result(r)]
if __name__ == '__main__':
demo_results = [
{'content': 'Please log in to view this article which has plenty of extra padding text here.'},
{'content': 'A' * 250},
]
kept = filter_results(demo_results)
print(f'{len(demo_results)} results -> {len(kept)} passed quality filter')
Обрезка результатов с учётом контекстного бюджета
Даже после фильтрации у вас может остаться 5 качественных результатов по 600 символов каждый — всего 3 000 символов. Определите, сколько результатов помещается в контекстный бюджет вашего LLM, и соответственно обрежьте их.
MAX_CONTEXT_CHARS = 4000
MAX_SNIPPET_CHARS = 600
def truncate_for_context(ranked_results, budget=MAX_CONTEXT_CHARS):
selected = []
used_chars = 0
for score, result in ranked_results:
content = result.get('content', '')[:MAX_SNIPPET_CHARS]
entry = f"Source: {result['title']}\nURL: {result['url']}\nContent: {content}"
entry_len = len(entry)
if used_chars + entry_len > budget:
break
selected.append(result)
used_chars += entry_len
return selected
ranked_results = [
(0.9, {'title': 'Doc A', 'url': 'http://a', 'content': 'x' * 800}),
(0.7, {'title': 'Doc B', 'url': 'http://b', 'content': 'y' * 800}),
]
budget = 1000
selected = truncate_for_context(ranked_results, budget=budget)
print(f'Selected {len(selected)} results within {budget}-char budget')Форматирование результатов для запроса к LLM
После ранжирования, устранения дубликатов и обрезки представьте результаты в виде нумерованного списка в запросе к LLM. Благодаря нумерованным источникам модели будет проще ссылаться на них в ответе.
def format_results_for_prompt(results):
lines = ['Here are relevant search results:\n']
for i, r in enumerate(results, 1):
lines.append(f'[{i}] {r["title"]}')
lines.append(f' URL: {r["url"]}')
lines.append(f' {r.get("content", "")[:400]}')
lines.append('')
lines.append('Use these sources to answer the question. Cite sources as [1], [2], etc.')
return '\n'.join(lines)
# Usage in agent prompt
formatted = format_results_for_prompt(selected_results)
response = llm_call(
system='You are a research assistant.',
user=f'{formatted}\n\nQuestion: {user_question}'
)Кэширование результатов поиска
Один и тот же запрос может повторяться в разных сеансах или циклах агента. Кэшируйте результаты поиска с коротким TTL (например, 1 час), чтобы снизить расходы на обращения к сервисам и ускорить ответы на повторные запросы.
import hashlib
import time
search_cache = {} # In production: use Redis or disk cache
CACHE_TTL = 3600 # 1 hour
def cached_search(query, **kwargs):
cache_key = hashlib.md5(query.encode()).hexdigest()
entry = search_cache.get(cache_key)
if entry and (time.time() - entry['ts']) < CACHE_TTL:
print('Search cache hit')
return entry['results']
results = client.search(query=query, **kwargs)
search_cache[cache_key] = {
'results': results,
'ts': time.time()
}
return resultsПолный конвейер фильтрации и ранжирования
Объедините все шаги в одну функцию конвейера: получить данные → отфильтровать некачественные результаты → устранить дубликаты → выполнить ранжирование → применить повышение оценки домена → обрезать с учётом контекста → отформатировать для запроса.
def search_and_rank(query, max_context_chars=4000):
# 1. Fetch
raw = cached_search(query, max_results=8)
results = raw.get('results', [])
# 2. Filter quality
results = filter_results(results)
# 3. Deduplicate
results = deduplicate_results(results)
# 4. Rank (BM25 fast path — save embedding costs)
scored = rank_with_bm25(query, results)
# 5. Domain boost
scored = apply_domain_boost(scored)
# 6. Truncate to context budget
selected = truncate_for_context(scored, budget=max_context_chars)
# 7. Format
return format_results_for_prompt(selected)Проверка знаний
В чём состоит главное преимущество гибридного ранжирования (BM25 + эмбеддинги) по сравнению с использованием только BM25?
Повторение: ранжирование и фильтрация результатов поиска
Необработанные результаты поиска нужно обработать до передачи в LLM. Конвейер: отфильтровать некачественные результаты (слишком короткие, доступные только после оплаты) → устранить дубликаты по адресу и содержимому → ранжировать по BM25 и/или сходству эмбеддингов → применить повышения оценки качества домена → обрезать с учётом контекстного бюджета → оформить в виде нумерованных источников.
Кэшируйте результаты поиска, чтобы снизить расходы на обращения к сервисам при повторных запросах. Нумерованные ссылки в запросе позволяют LLM связывать утверждения с конкретными источниками.
Часто задаваемые вопросы
Урок «Ранжирование и фильтрация результатов поиска» бесплатный?
Да — полный текст урока «Ранжирование и фильтрация результатов поиска» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс AI Agents, подпишись на CoddyKit PRO. Курс AI Agents содержит 4 уроков всего.
Чему я научусь в уроке «Ранжирование и фильтрация результатов поиска»?
Оценка релевантности, удаление дубликатов и выбор лучших результатов для контекста. Ты практикуешь AI Agents с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.
Нужен ли мне опыт, чтобы начать AI Agents?
Предыдущий опыт не требуется. AI Agents на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 2 из 4.
Сколько времени занимает урок «Ранжирование и фильтрация результатов поиска»?
Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.
Можно ли писать и запускать код в этом уроке AI Agents?
Да. Каждый урок AI Agents включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.
Все уроки этого курса
- Tavily и SerpAPI для поиска агентов
- Ранжирование и фильтрация результатов поиска
- Шаблон цикла глубокого исследования
- Объединение веб-поиска с RAG