Clasificación y filtrado de resultados de búsqueda
Puntúe la relevancia, elimine duplicados y seleccione los mejores resultados para el contexto.
Clasificación y filtrado de resultados de búsqueda es una lección gratuita de AI Agents en CoddyKit. Esta es la lección 2 de 4. Puedes leer la lección completa abajo gratuitamente — luego la practicas en el navegador con un editor de código integrado y un tutor de IA 24/7. Forma parte de la ruta de aprendizaje de AI Agents, y tu progreso se sincroniza en la web y la app de CoddyKit. El curso de AI Agents incluye 4 lecciones en total.
Por qué son importantes la clasificación y el filtrado
Una API de búsqueda devuelve entre 5 y 10 resultados, pero no todos son igual de relevantes, fiables o útiles para la tarea del agente. Enviar los resultados sin procesar directamente al LLM desperdicia tokens de contexto y puede introducir ruido o información errónea.
La clasificación y el filtrado mejoran la relación entre señal y ruido antes de que los resultados lleguen al LLM.
Puntuación de relevancia con BM25
BM25 (Best Match 25) es un algoritmo clásico de clasificación de texto que asigna una puntuación a los documentos según la coincidencia de palabras clave con la consulta. Funciona bien para la coincidencia léxica, es decir, cuando la consulta y el documento comparten las mismas palabras.
Instálelo con 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]Puntuación de relevancia con embeddings
BM25 solo encuentra coincidencias entre palabras exactas. La similitud entre embeddings captura el significado semántico, por lo que «desarrollo web con Python» y «crear sitios web con Django» obtienen una similitud alta aunque usen palabras diferentes.
Utilice la similitud coseno entre el embedding de la consulta y los embeddings de los resultados.
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)Clasificación híbrida: BM25 + embeddings
BM25 y la puntuación basada en embeddings capturan distintos aspectos de la relevancia. La clasificación híbrida combina ambas puntuaciones mediante un promedio ponderado para aprovechar al máximo la coincidencia léxica y la semántica.
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)Eliminación de duplicados por URL
Los resultados de búsqueda suelen contener casi duplicados: el mismo artículo procedente de varias fuentes que lo han sindicado, o la misma página con distintos parámetros de URL. La eliminación de duplicados los quita antes de enviar los resultados al 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'])
Puntuación de calidad del dominio
Un resultado de docs.python.org es más fiable que uno de un blog cualquiera. Asigne multiplicadores de calidad a los distintos niveles de dominio e incorpórelos a la clasificación final.
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}")
Filtrado de resultados de baja calidad
Algunos resultados tienen una calidad estructuralmente baja independientemente de su dominio: son demasiado cortos para resultar útiles, contienen principalmente texto de navegación o proceden de páginas que requieren iniciar sesión. Fíltrelos antes de clasificarlos.
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')
Truncamiento de resultados para el presupuesto de contexto
Incluso después del filtrado, puede tener 5 resultados de alta calidad con 600 caracteres cada uno: 3.000 caracteres en total. Determine cuántos resultados caben en el presupuesto de contexto de su LLM y trúnquelos según corresponda.
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')Formato de los resultados para el prompt del LLM
Después de clasificar, eliminar duplicados y truncar, dé formato a los resultados como una lista numerada en el prompt del LLM. Las fuentes numeradas facilitan que el modelo las cite en su respuesta.
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}'
)Almacenamiento en caché de los resultados de búsqueda
La misma consulta puede repetirse en distintas sesiones o ciclos del agente. Almacene los resultados de búsqueda en caché con un TTL corto (por ejemplo, 1 hora) para reducir los costes de la API y mejorar el tiempo de respuesta de las consultas repetidas.
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 resultsPipeline completo de filtrado y clasificación
Encadene todos los pasos en una única función de pipeline: obtener → filtrar los resultados de baja calidad → eliminar duplicados → clasificar → aplicar el impulso del dominio → truncar para el contexto → dar formato para el prompt.
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)Comprobación de conocimientos
¿Cuál es la principal ventaja de la clasificación híbrida (BM25 + embeddings) frente a utilizar solo BM25?
Repaso: clasificación y filtrado de resultados de búsqueda
Los resultados de búsqueda sin procesar necesitan un tratamiento antes de llegar al LLM. El pipeline es: filtrar los resultados de baja calidad (demasiado cortos o protegidos por un muro de pago) → eliminar duplicados por URL y contenido → clasificar mediante BM25 o similitud entre embeddings, o ambos → aplicar mejoras por calidad del dominio → truncar según el presupuesto de contexto → dar formato como fuentes numeradas.
Almacene los resultados de búsqueda en caché para reducir los costes de la API en las consultas repetidas. Las citas numeradas del prompt permiten que el LLM atribuya las afirmaciones a fuentes específicas.
Preguntas frecuentes
¿La lección «Clasificación y filtrado de resultados de búsqueda» es gratis?
Sí — el texto completo de «Clasificación y filtrado de resultados de búsqueda» es gratis para leer aquí en la web. Para practicarla de forma interactiva (editor de código integrado y tutor de IA 24/7) y desbloquear el resto del curso de AI Agents, actualiza a CoddyKit PRO. El curso de AI Agents incluye 4 lecciones en total.
¿Qué aprenderé en «Clasificación y filtrado de resultados de búsqueda»?
Puntúe la relevancia, elimine duplicados y seleccione los mejores resultados para el contexto. Practicas AI Agents con código real que ejecutas directamente en el navegador, y un tutor de IA 24/7 responde tus preguntas mientras trabajas en la lección.
¿Necesito experiencia previa para empezar AI Agents?
No se requiere experiencia previa. AI Agents en CoddyKit está estructurado para principiantes hasta estudiantes avanzados, así que puedes empezar aquí o desde el inicio y avanzar a tu ritmo. Esta es la lección 2 de 4.
¿Cuánto tiempo toma la lección «Clasificación y filtrado de resultados de búsqueda»?
La mayoría de las lecciones de CoddyKit toman alrededor de 5–10 minutos. Cada una es compacta e interactiva, así que avanzas constantemente y retomas exactamente por donde dejaste en la web y la app.
¿Puedo escribir y ejecutar código en esta lección de AI Agents?
Sí. Cada lección de AI Agents incluye un editor de código integrado, así que escribes y ejecutas código real directamente en tu navegador y obtienes retroalimentación instantánea de IA — sin configuración local necesaria.
Todas las lecciones de este curso
- Tavily y SerpAPI para búsquedas de agentes
- Clasificación y filtrado de resultados de búsqueda
- Patrón de ciclo de investigación profunda
- Combinación de búsqueda web con RAG