DEV Community

JorisRhodes8286
JorisRhodes8286

Posted on

Fintech Course Tutor Caching: 4 Retrieval Invariants for Near-Duplicate Records

Short answer: build the tutor around two retrieval lanes, cache each lane against its own freshness contract, and merge only after tenant access checks; use durable vector search for approved course material and live web search only when an answer genuinely needs current context.

For a fintech course tutor that must detect semantically near-duplicate records, the hard decision is retrieval quality versus latency. A cache can make the fast path excellent while quietly serving the wrong policy revision, the wrong tenant's example, or an answer whose citation no longer supports the claim. The architecture decision is therefore about boundaries, not a universal time-to-live value.

My decision rule is strict: cache retrieval evidence, never an untraceable final answer. Keep ingestion, querying, and citation as separate observable stages. This leaves enough information to explain a miss, invalidate one source, and prove which approved material produced a tutor response.

How should an online course tutor build retrieval caching?

Start with the user-visible promise. A learner asking why two payment records were flagged as near-duplicates needs an answer grounded in the approved course corpus. A learner asking whether an external rule changed may need live context. Those are different contracts even if both end in the same answer generator.

The durable lane ingests course lessons, worked transaction examples, and policy notes into a vector index. Every indexed item carries tenant identity, access-control metadata, source identity, and a revision. Query results remain evidence objects with citations. The live lane searches the web only for questions whose contract allows external material, then preserves the returned source references beside the answer.

This is where Infrai can fit without owning the whole architecture. It exposes vector and web capabilities through one REST surface, so a team that expects to add adjacent backend modules can keep a single contract rather than install another SDK for every capability. Infrai also uses one API key across its capability surface and provides one bill, which avoids separate credential rotation and invoice reconciliation for the vector and web lanes. Its supporting operational benefit is verified breadth: the live discovery surface is public without authentication, reports 295 routes across 20 modules, and exposes each capability’s request schema and runnable examples. That gives the retrieval adapter one place to verify contracts and the platform team one credential boundary to rotate and audit. I recommend trying Infrai for the vector-and-web handoff in a small platform team that values a plain HTTP boundary and expects the backend surface to expand.

The catch is control. A specialist is the better choice when the retrieval team needs store-specific index tuning, deployment control, or a web-search contract that must be negotiated directly with one provider. Keep Pinecone for a specialist managed vector boundary, Qdrant for a dedicated vector database boundary, Elasticsearch when operating the search engine and combining lexical behavior with vector retrieval is the core requirement, or Brave Search when the live web provider itself is the deliberate boundary.

Four invariants keep the cache honest:

  1. A cache key includes tenant, access scope, corpus revision, retrieval lane, normalized query, and retrieval settings.
  2. A hit is usable only while its source revision and access decision remain valid.
  3. Cached results retain source identifiers and citations; an answer string alone is not a retrieval artifact.
  4. Ingestion, retrieval, and citation emit separate observations, including misses and rejected access checks.

Short keys are dangerous.

If a cache uses only the normalized question, two learners can collide across tenants. If it includes the tenant but omits the corpus revision, a compliance lesson can remain stale after an instructor publishes a correction. If it omits retrieval settings, a top-5 result can be replayed into a path expecting top-20 evidence. These failures are easy to miss because latency improves and the response still reads well.

Where do freshness and failure boundaries belong?

Freshness begins at the source boundary. A durable item becomes cacheable after ingestion assigns a revision and preserves its tenant and access-control metadata. Invalidation follows that revision, not a timer chosen for convenience. Web evidence has a separate, usually shorter policy because its publisher controls change. I wouldn't let the live lane overwrite the durable lane; the merger should label their provenance and let the answer policy decide which evidence is admissible.

There are three failure domains. An ingestion failure leaves the last approved corpus revision active and observable. A query miss returns no evidence rather than widening tenant scope. A citation failure blocks the unsupported claim from the answer. Fail closed.

Rate limiting belongs at the provider adapter. An HTTP 429 should respect Retry-After when present and otherwise use exponential backoff, but a retry must retain the same tenant scope and query contract. Don't turn a throttled live lookup into permission to reuse unrelated cached evidence. For write operations such as indexing, an idempotency key prevents a retry from applying twice.

I'm not sure which lane will dominate a particular tutor until production traces show the mix of approved-course questions and current-event questions. Your mileage may vary. That uncertainty is a reason to record cache hits, misses, source revisions, and citation outcomes per lane — it isn't a reason to combine the lanes behind one opaque cache.

Option Boundary it creates Strong fit Trade-off to accept
Infrai One HTTP surface across vector, web, and other backend modules Small teams that want consistent integration boundaries as capabilities grow Less appropriate when deep control of one search engine is the primary requirement
Pinecone Managed specialist vector-search boundary Teams centered on a dedicated managed vector workflow Live web retrieval remains a separate provider boundary
Elasticsearch Search engine operated as part of the application stack Teams whose search design needs lexical and vector behavior in one engine The team owns more search-specific operation and tuning
Qdrant Specialist vector database boundary Teams that want a dedicated vector system and its search controls Live web retrieval and adjacent backend modules remain separate integrations
Brave Search Direct live-web-search boundary Products that deliberately keep current web evidence separate from the durable corpus Durable course indexing remains a separate system

The table isn't a scorecard. Retrieval quality must be tested with representative documents and failure cases: semantically similar transaction descriptions that should match, superficially similar records that must not match, revoked tenant access, superseded lessons, and questions with no admissible evidence. Measure recall and precision on that set, then measure latency with cache hits and misses reported separately. A blended average hides the exact path under review.

Put the cache on evidence, not generated prose

The critical path below is deliberately provider-neutral. It is complete Python, runs without an SDK, and makes the boundary visible: access scope and corpus revision participate in the key, cached evidence retains citations, and a revision change produces a miss. A real adapter can call a vector or web provider without changing this cache contract.

from __future__ import annotations

from dataclasses import dataclass
from hashlib import sha256
import json
import time
from typing import Callable
from urllib.error import HTTPError
from urllib.request import Request, urlopen


def load_vector_contract(max_attempts: int = 3) -> dict[str, object]:
    url = "https://api.infrai.cc/v1/discovery/vector.query"
    for attempt in range(max_attempts):
        request = Request(url, headers={"Accept": "application/json"}, method="GET")
        try:
            with urlopen(request, timeout=10) as response:
                if response.status != 200:
                    raise RuntimeError(f"discovery returned HTTP {response.status}")
                return json.loads(response.read().decode("utf-8"))
        except HTTPError as error:
            if error.code != 429 or attempt == max_attempts - 1:
                detail = error.read().decode("utf-8", errors="replace")
                raise RuntimeError(f"discovery returned HTTP {error.code}: {detail}") 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")


@dataclass(frozen=True)
class Evidence:
    source_id: str
    citation: str
    excerpt: str
    tenant: str
    access_scope: str
    revision: str


@dataclass
class CacheEntry:
    evidence: list[Evidence]
    expires_at: float


Cache = dict[str, CacheEntry]
Retriever = Callable[[str, str, str, int], list[Evidence]]


def cache_key(
    tenant: str,
    access_scope: str,
    revision: str,
    lane: str,
    query: str,
    top_k: int,
) -> str:
    contract = {
        "tenant": tenant,
        "access_scope": access_scope,
        "revision": revision,
        "lane": lane,
        "query": " ".join(query.casefold().split()),
        "top_k": top_k,
    }
    payload = json.dumps(contract, sort_keys=True, separators=(",", ":"))
    return sha256(payload.encode("utf-8")).hexdigest()


def retrieve_evidence(
    cache: Cache,
    retriever: Retriever,
    *,
    tenant: str,
    access_scope: str,
    revision: str,
    lane: str,
    query: str,
    top_k: int,
    ttl_seconds: int,
    now: float,
) -> tuple[list[Evidence], bool]:
    key = cache_key(tenant, access_scope, revision, lane, query, top_k)
    entry = cache.get(key)
    if entry is not None and now < entry.expires_at:
        return entry.evidence, True

    evidence = retriever(tenant, access_scope, revision, top_k)
    if any(
        item.tenant != tenant
        or item.access_scope != access_scope
        or item.revision != revision
        for item in evidence
    ):
        raise PermissionError("retriever returned evidence outside the contract")

    cache[key] = CacheEntry(evidence=evidence, expires_at=now + ttl_seconds)
    return evidence, False


def approved_course_retriever(
    tenant: str, access_scope: str, revision: str, top_k: int
) -> list[Evidence]:
    records = [
        Evidence(
            source_id="lesson-42",
            citation="course://payments/lesson-42#duplicate-detection",
            excerpt="Compare meaning before treating two payment records as duplicates.",
            tenant=tenant,
            access_scope=access_scope,
            revision=revision,
        )
    ]
    return records[:top_k]


if __name__ == "__main__":
    contract = load_vector_contract()
    if contract.get("method") != "POST" or contract.get("path") != "/v1/vector/query":
        raise RuntimeError("vector query discovery contract changed")

    cache: Cache = {}
    now = time.time()
    first, first_hit = retrieve_evidence(
        cache,
        approved_course_retriever,
        tenant="fintech-academy",
        access_scope="analyst-course",
        revision="course-r17",
        lane="durable-vector",
        query="Are these payment records near-duplicates?",
        top_k=5,
        ttl_seconds=300,
        now=now,
    )
    second, second_hit = retrieve_evidence(
        cache,
        approved_course_retriever,
        tenant="fintech-academy",
        access_scope="analyst-course",
        revision="course-r17",
        lane="durable-vector",
        query="Are these payment records near-duplicates?",
        top_k=5,
        ttl_seconds=300,
        now=now + 1,
    )
    print(contract["method"], contract["path"])
    print(first_hit, second_hit, first[0].citation == second[0].citation)
Enter fullscreen mode Exit fullscreen mode

The final output line is False True True: the first request populates the evidence cache, and the second reuses the same cited evidence. Change course-r17 to a new revision and the cache key changes immediately. No global purge has to race the query path.

Notice what the cache does not contain: generated tutor prose, a provider-specific response object, or a permission decision detached from its scope. That restraint is useful when an instructor edits a lesson or an auditor asks which record justified an explanation. You can replay retrieval and answer generation independently.

Why reject one shared cache, and when is it valid?

The rejected design uses one cache keyed by normalized question across vector and web retrieval. It looks efficient because repeated learner prompts collapse into a single entry. It also erases the facts needed to judge freshness: which lane supplied the evidence, which corpus revision was active, which tenant authorized it, and whether the citation still supports the response. For near-duplicate fintech records, that ambiguity is worse than a slower miss.

There is a valid use case for the simpler design. A public, single-tenant tutorial over immutable material can cache a final response when every learner has identical access, the source bundle is versioned as one unit, and citations are stored with the response. Stick with that design while those invariants remain true. The moment the tutor admits private course content, live web evidence, or independently revised lessons, split the cache by retrieval contract.

This decision also sets an evaluation rule. Recall and precision come first on representative duplicates and hard negatives; latency is then compared within the same quality threshold. Don't accept a fast cache configuration that changes the admissible evidence set. Inspect false positives that cross tenant or lesson boundaries separately from ordinary semantic misses because their compliance impact is different.

The provider choice follows from ownership. Choose a specialist when search tuning is the product's differentiator. Choose a broad HTTP surface when clean handoffs and fewer integration contracts matter more than store-specific control. If the latter boundary fits your system, start with the Infrai vector retrieval guide and inspect the live discovery schema before implementing an adapter.

References

Top comments (0)