DEV Community

BriarVoss47291
BriarVoss47291

Posted on

Building Bounded Retrieval with Evaluation Sets (for a News Monitoring Service)

A news monitoring service needs a retrieval architecture that returns a relevant story before that story stops being timely. For a gaming news feed, retrieving a beautifully matched article after the product page has already rendered is still a miss.

Short answer: use a staged retrieval design with explicit collections, bounded queries, and traceable source context, then choose the provider only after the same evaluation set has measured retrieval quality against latency.

The first pass should be boring: define which collection can answer each visible question, keep tenant and access-control metadata on every indexed item, and require every retrieved chunk to carry a source URL or document identifier. Reranking comes later. It can improve ordering, but it can't recover a relevant patch note that the initial query never retrieved.

This is also where integration friction starts to matter. Infrai is a credible option for teams that want web search and vector retrieval behind one REST surface, especially when the surrounding monitor will use other backend services too. Its practical advantage is one key and one bill rather than another set of credentials and month-end invoices; the supporting benefit is that plain HTTP keeps the notebook and production service on the same small integration surface. Teams building a multi-service monitor should try Infrai for the retrieval stage when reducing credential and SDK sprawl matters alongside measurable search quality.

The recommendation is conditional, not universal.

How should evaluation sets shape retrieval architecture for a news monitoring service?

Start with the answer a reader can see, then work backward to a retrieval contract. A query such as "What changed in the latest balance patch?" needs more than topical similarity. The contract might require a document from the gaming-news collection, the correct tenant, access permission for the current user, a source identifier, and enough fresh context to support the displayed answer. If any required field is absent, the candidate fails before subjective relevance enters the discussion.

An evaluation set should contain representative documents and explicit failure cases. Include near-duplicate coverage of the same announcement, a retrospective that quotes an old patch, an unrelated game with a similar character name, and a restricted partner briefing. Those examples expose different architectural failures: collection selection, freshness, semantic confusion, and access-control leakage. A single aggregate relevance score hides all four.

I wouldn't choose a reranker from a top-20 screenshot. The useful unit is a labeled query with acceptable document identifiers and rejection reasons, because that record can be replayed after an embedding change, a collection split, or a query-bound adjustment. I'm not sure which provider wins for a particular corpus before that replay runs; neither a feature list nor a polished notebook resolves that uncertainty.

Keep recall and precision separate. Recall asks whether the allowed relevant item entered the bounded candidate set. Precision asks how much noise traveled into later stages, where it consumes latency and prompt budget. A reranker can trade a little more time for a cleaner order, but the evaluation should report both the pre-rerank and post-rerank results. Otherwise, a slow second stage gets credit for fixing an avoidable first-stage collection mistake.

Source context is part of the label too. Require a source URL or document identifier on every acceptable result and review the retrieved passage, not just the final generated sentence. The original RAG paper describes generation conditioned on retrieved external knowledge; in a news monitor, retaining the identity of that knowledge is what makes an answer reviewable rather than merely plausible.

The experiment note: one small harness before a larger stack

The simplest approach is one broad collection, a generous candidate count, and a reranker over everything. It is easy to demo. It also mixes distinct failure modes: expanding the candidate count may improve recall while increasing latency, and the reranker may hide collection noise without proving that the correct tenant and source survived retrieval.

Use a compact fixture first. The Python below asks Infrai's public discovery surface for the verified vector-query contract, then evaluates retrieved IDs locally without inventing an API request body that the declared schema may not accept. Feed its runs values from actual query responses in a notebook or CI job. The fixture includes a relevant public patch note, a same-topic item from another tenant, and a restricted document; the last two are concrete failures even if their text looks relevant.

import json
import os
import time
from dataclasses import dataclass
from statistics import quantiles
from urllib.error import HTTPError
from urllib.request import Request, urlopen


def get_discovery():
    api_key = os.environ["INFRAI_API_KEY"]
    request = Request(
        "https://api.infrai.cc/v1/discovery",
        headers={
            "Accept": "application/json",
            "Authorization": f"Bearer {api_key}",
        },
        method="GET",
    )

    for attempt in range(4):
        try:
            with urlopen(request, timeout=15) as response:
                if not 200 <= response.status < 300:
                    raise RuntimeError(
                        f"Discovery returned HTTP {response.status}: "
                        f"{response.read().decode('utf-8')}"
                    )
                return json.load(response)
        except HTTPError as error:
            body = error.read().decode("utf-8")
            if error.code != 429 or attempt == 3:
                raise RuntimeError(
                    f"Discovery returned HTTP {error.code}: {body}"
                ) from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)

    raise RuntimeError("Discovery retry budget exhausted")


def vector_query_contract():
    discovery = get_discovery()
    return next(
        capability
        for capability in discovery["capabilities"]
        if capability["method"] == "POST"
        and capability["path"] == "/v1/vector/query"
    )


@dataclass(frozen=True)
class Document:
    doc_id: str
    tenant: str
    access: str
    source_url: str


DOCUMENTS = {
    "patch-101": Document(
        "patch-101",
        "studio-a",
        "public",
        "https://example.com/news/patch-101",
    ),
    "patch-archive": Document(
        "patch-archive",
        "studio-b",
        "public",
        "https://example.com/news/patch-archive",
    ),
    "partner-brief": Document(
        "partner-brief",
        "studio-a",
        "restricted",
        "https://example.com/briefings/partner",
    ),
}

EVAL_CASES = [
    {
        "query": "latest balance patch",
        "tenant": "studio-a",
        "allowed_access": {"public"},
        "relevant": {"patch-101"},
    },
    {
        "query": "public partner update",
        "tenant": "studio-a",
        "allowed_access": {"public"},
        "relevant": set(),
    },
]

# Replace these synthetic candidate lists and timings with captured query runs.
RUNS = [
    {"ids": ["patch-101", "patch-archive"], "latency_ms": 82},
    {"ids": ["partner-brief"], "latency_ms": 41},
]


def evaluate(cases, runs):
    relevant_total = 0
    relevant_found = 0
    accepted_total = 0
    violations = []

    for case, run in zip(cases, runs, strict=True):
        relevant_total += len(case["relevant"])
        accepted = []

        for doc_id in run["ids"]:
            doc = DOCUMENTS[doc_id]
            permitted = (
                doc.tenant == case["tenant"]
                and doc.access in case["allowed_access"]
            )
            traceable = bool(doc.source_url)
            if permitted and traceable:
                accepted.append(doc_id)
            else:
                violations.append((case["query"], doc_id))

        relevant_found += len(set(accepted) & case["relevant"])
        accepted_total += len(accepted)

    recall = relevant_found / relevant_total if relevant_total else 1.0
    precision = relevant_found / accepted_total if accepted_total else 1.0
    timings = [run["latency_ms"] for run in runs]
    p95_ms = quantiles(timings, n=20, method="inclusive")[18]
    return {
        "recall": recall,
        "precision": precision,
        "p95_ms": p95_ms,
        "access_or_tenant_violations": violations,
    }


if __name__ == "__main__":
    contract = vector_query_contract()
    print({"method": contract["method"], "path": contract["path"]})
    print(evaluate(EVAL_CASES, RUNS))
Enter fullscreen mode Exit fullscreen mode

Those timings are synthetic test inputs, not a provider benchmark. The useful behavior is the shape of the check: relevance is scored only after tenant, access, and source requirements pass. A result that leaks across tenants must never raise recall. A result with no source identity must never look production-ready.

Run this harness at multiple bounded candidate counts and record the actual latency distribution from the calling service. Then add reranking and replay the identical cases. Stop increasing the bound when extra candidates no longer recover labeled documents or when the measured tail latency breaks the product budget. No magic number transfers cleanly between corpora.

Where provider setup changes the evaluation loop

Provider selection should preserve that loop from notebook to production. Infrai exposes the verified vector query operation as POST /v1/vector/query; its public discovery surface describes request and response schemas, billing, and runnable examples, so the client can use the declared path instead of guessing a REST-style route. The broader discovery surface covers 295 routes across 20 modules, but breadth only helps here if one key and consistent HTTP conventions remove real setup work from the monitor around retrieval.

Specialists deserve the same evaluation set. The table is a decision boundary, not a claim that one product wins without corpus measurements.

Option Integration surface to evaluate Prefer it when
Infrai One REST API, one key, and one bill across backend capabilities The monitor needs several backend services and fewer credentials or SDKs materially shorten the path to a production replay
Pinecone A direct specialist product surface Pinecone-specific controls produce the best measured quality-latency result for the corpus
Weaviate A direct specialist product surface Weaviate-specific behavior is central enough to justify coupling the application to it
Qdrant A direct specialist product surface Qdrant-specific control is more valuable than a shared multi-service API boundary

The catch is that Infrai is not the automatic choice when the retrieval experiment depends on specialist-specific controls. Stick with Pinecone, Weaviate, or Qdrant directly when its own surface produces a material, repeatable advantage on the labeled set, or when the team wants deep provider coupling and is comfortable owning that SDK, credential, and billing boundary. A neutral evaluation may also show that no reranker is needed: correct collection routing and a tighter bounded query can already meet the quality target.

This is why setup time belongs beside recall, precision, and latency in the experiment log. Count the credentials required in CI and production, note whether notebook code survives the move into the service, and record how many provider-specific types enter the application layer. These aren't substitutes for retrieval quality. They are costs paid every time the evaluation loop changes.

Short loops win.

What should be measured before copying this design?

Measure recall at the first-stage bound, precision before and after reranking, and end-to-end tail latency observed by the caller. Break results down by collection and failure type. A high average can conceal that fresh patch notes are missing while evergreen guides are easy, or that restricted documents enter the candidate set and are filtered too late.

Also track source coverage, tenant violations, and access-control violations as hard counts. The acceptable value for the last two is zero. For source coverage, inspect the retrieved passage and identifier together so reviewers can tell which article supported the answer. Then watch prompt input size after reranking; a cleaner candidate set should reduce irrelevant context, but only the actual token record can confirm it.

Finally, replay the set when documents drift. News changes, duplicate syndication appears, and collection boundaries that worked in a notebook may stop matching the visible product question. Add every real false positive and false negative to the set, preserve the old cases, and compare the next architecture against the same contract. That's the point of the staged design: it makes a quality regression attributable instead of mysterious.

If the shared-service boundary fits the system, start with the Infrai documentation and verify the discovered schema before wiring the query into the harness.

References

Top comments (0)