n4nAI

How to rank and filter search results for research agents

Practical steps to rank and filter search results for research agents using embeddings, LLM scoring, and deterministic rules—with runnable Python code.

n4n Team4 min read892 words

Audio narration

Coming soon — every post will get a voice note here.

Building a research agent that can rank filter search results agents is harder than wiring up a search API. The raw hits are noisy, duplicated, and often off-topic; you need a pipeline that scores, filters, and presents only what the agent can act on.

Step 1: Fetch raw results from a search source

Most agents start with a call to a web search provider. Keep the integration thin. You want a function that returns a normalized list of candidate documents, not a tangle of provider-specific fields. Never trust a single provider’s ranking—their relevance signal optimizes for ad revenue, not your agent’s task. Pull from two sources if you can; the dedupe step merges them.

import requests

def fetch_search_results(query: str, api_key: str, count: int = 20) -> list[dict]:
    # Example using Bing Web Search; swap for SerpAPI, Google, or your internal index
    endpoint = "https://api.bing.microsoft.com/v7.0/search"
    headers = {"Ocp-Apim-Subscription-Key": api_key}
    params = {"q": query, "count": count, "textDecorations": False}
    resp = requests.get(endpoint, headers=headers, params=params, timeout=10)
    resp.raise_for_status()
    web_pages = resp.json().get("webPages", {}).get("value", [])
    return [
        {
            "url": item["url"],
            "title": item.get("name", ""),
            "snippet": item.get("snippet", ""),
            "source": "bing",
        }
        for item in web_pages
    ]

If you don’t have a key, stub the function with a local JSON file. The rest of the pipeline doesn’t care where the bytes came from. Add pagination only when you see the top 20 missing obvious sources; premature pagination just multiplies noise.

Step 2: Normalize and deduplicate

Search providers return near-duplicates: same article on two domains, or AMP mirrors. Collapse them before spending tokens. Hash the normalized URL (strip query params, lowercase host) and keep the variant with the longest snippet.

from urllib.parse import urlparse, urlunparse

def normalize_url(u: str) -> str:
    p = urlparse(u)
    clean = p._replace(query="", fragment="")
    return urlunparse(clean).rstrip("/").lower()

def dedupe(results: list[dict]) -> list[dict]:
    seen = {}
    for r in results:
        key = normalize_url(r["url"])
        if key not in seen or len(r["snippet"]) > len(seen[key]["snippet"]):
            seen[key] = r
    return list(seen.values())

After this step you should have a manageable set—typically under 30 items. If you pull 100 raw hits, expect 40–60% reduction from dedupe alone. Store the original source field so you can debug later why a domain was kept.

Step 3: Pre-filter with embedding similarity

LLM calls are expensive and slow. Cut the list further with a cheap cosine similarity against the query embedding. Use a local model like all-MiniLM-L6-v2 to avoid network round-trips and to keep latency under 50ms per batch.

from sentence_transformers import SentenceTransformer
import numpy as np

model = SentenceTransformer("all-MiniLM-L6-v2")

def embed(texts: list[str]) -> np.ndarray:
    return model.encode(texts, normalize_embeddings=True)

def cosine(a: np.ndarray, b: np.ndarray) -> float:
    return float(np.dot(a, b))

def prefilter(query: str, results: list[dict], threshold: float = 0.25) -> list[dict]:
    q_emb = embed([query])[0]
    docs = [f"{r['title']} {r['snippet']}" for r in results]
    d_embs = embed(docs)
    scored = []
    for r, d_emb in zip(results, d_embs):
        score = cosine(q_emb, d_emb)
        if score >= threshold:
            r["emb_score"] = round(score, 3)
            scored.append(r)
    return scored

This step drops clearly irrelevant pages. Tune the threshold on a labeled sample; 0.25 is a sane start for MiniLM on short snippets. If you scale past a few thousand candidates, move to FAISS or a vector DB instead of looping in Python.

Step 4: Use an LLM to rank filter search results agents

The embedding pre-filter catches topical mismatch, but not trustworthiness, freshness, or agent-specific utility. This is where you rank filter search results agents with a language model. Send a compact prompt with the query and the surviving candidates, and ask for a JSON array of judgments.

We point the OpenAI client at an OpenAI-compatible gateway so we can swap models without code changes. n4n.ai exposes one endpoint that fronts 240+ models and falls back automatically when a provider is degraded, which keeps the ranking step from becoming a single point of failure.

from openai import OpenAI
import json

client = OpenAI(
    base_url="https://api.n4n.ai/v1",  # OpenAI-compatible, 240+ models
    api_key="YOUR_KEY",
)

def llm_rank(query: str, results: list[dict], model: str = "anthropic/claude-3.5-sonnet") -> list[dict]:
    # limit to top 15 by embedding score to stay within token limits
    candidates = sorted(results, key=lambda x: x.get("emb_score", 0), reverse=True)[:15]
    payload = {
        "query": query,
        "candidates": [
            {"id": i, "title": r["title"], "snippet": r["snippet"], "url": r["url"]}
            for i, r in enumerate(candidates)
        ],
    }
    sys = "You are a strict research triage agent. Score each candidate 0-10 for relevance, recency, and authority. Drop anything below 6. Return JSON: [{'id':int,'score':int,'keep':bool,'reason':str}]."
    resp = client.chat.completions.create(
        model=model,
        messages=[
            {"role": "system", "content": sys},
            {"role": "user", "content": json.dumps(payload)},
        ],
        response_format={"type": "json_object"},
        extra_headers={"x-routing": "cost-optimized"},  # gateway honors client directives
    )
    judgments = json.loads(resp.choices[0].message.content)
    kept = []
    for j in judgments:
        if j.get("keep") and 0 <= j["id"] < len(candidates):
            r = candidates[j["id"]]
            r["llm_score"] = j["score"]
            r["reason"] = j["reason"]
            kept.append(r)
    kept.sort(key=lambda x: x["llm_score"], reverse=True)
    return kept

The gateway forwards provider cache-control hints, so repeated queries with overlapping snippets hit cache and cut cost. Treat the LLM output as untrusted: validate id bounds and types before indexing back into your list. If the model returns malformed JSON, retry once with a stricter system prompt rather than crashing the agent loop.

Step 5: Apply deterministic filters

Model judgment is not enough. Enforce hard rules: blocklisted domains, minimum content length, and maximum age. These are faster and more predictable than asking the model to remember your policies.

from datetime import datetime, timezone
from urllib.parse import urlparse

BLOCKED = {"spam.example", "content-farm.example"}
MIN_SNIPPET = 40

def apply_rules(results: list[dict], max_age_days: int = 730) -> list[dict]:
    now = datetime.now(timezone.utc)
    out = []
    for r in results:
        host = urlparse(r["url"]).netloc.lower()
        if host in BLOCKED:
            continue
        if len(r["snippet"]) < MIN_SNIPPET:
            continue
        if r.get("date"):
            age = (now - r["date"]).days
            if age > max_age_days:
                continue
        out.append(r)
    return out

Why deterministic rules beat model memory

A model will occasionally let a known content farm through because its snippet looked plausible. A blocklist check is three lines and never hallucinates. Keep the blocklist in configuration, not code. Research agents degrade silently when a content farm sneaks into the top results; a 5-line rule saves the agent from citing garbage.

Step 6: Package results for the agent loop

The agent needs a stable schema. Emit a list of objects with the fields your tool caller expects, plus the scoring metadata so the agent can explain its choices.

def build_context(results: list[dict]) -> dict:
    return {
        "count": len(results),
        "sources": [
            {
                "url": r["url"],
                "title": r["title"],
                "summary": r["snippet"],
                "relevance": r.get("llm_score", r.get("emb_score", 0)),
                "note": r.get("reason", ""),
            }
            for r in results
        ],
    }

Feed build_context output into the agent’s retrieval tool. The agent should never see raw provider JSON. If you stream results to a UI, include the note field so a human can audit why a source was kept.

Step 7: Monitor and tune in production

Shipping the pipeline is the start. Log the score distribution at each stage: raw count, post-dedupe, post-embedding, post-LLM. A sudden drop in post-embedding survival means your query drifted from the indexed corpus or the threshold is too high.

import logging

def log_stage(name: str, n: int):
    logging.info(f"stage={name} surviving={n}")

Wire these logs to your metrics dashboard. When the LLM step fails due to provider outage, the gateway fallback should still return a response from a secondary model; log the model field from the response to confirm which one served. For local dev, stub the LLM call with a fake that returns fixed judgments to keep tests fast.

Verify the pipeline

You can’t ship a ranking filter blind. Write a fixture with 50 known documents—mix of on-topic, off-topic, duplicates, and blocklisted. Run the pipeline and assert:

  1. Duplicates collapsed (count drops).
  2. Embedding pre-filter removes at least the obvious off-topic items.
  3. LLM step returns only keep:true with scores ≥6.
  4. Blocked domains absent from final output.
pytest tests/test_rank_filter.py -q

If the LLM step fails due to provider outage, the gateway fallback should still return a response from a secondary model; log the model field from the response to confirm which one served. For local dev, stub the LLM call with a fake that returns fixed judgments to keep tests fast.

A research agent is only as good as the documents it sees. The work to rank filter search results agents is plumbing, but it’s the difference between a demo that works on the happy path and a system that survives real queries.

Tagssearch-rankingresearch-agentagentic-searchguide

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All deep research & agentic search posts →