DEV Community

ApexZ69
ApexZ69

Posted on

Caching Travel Listings for Itinerary Retrieval — Freshness Without Fragmented Results

Short answer: cache normalized travel listings by source revision and stable chunk identity, then cache retrieval results only for queries whose freshness tolerance is explicit; don't cache the final itinerary as if it were source data.

That rule separates two jobs that age at different speeds. A description of a museum may remain useful across many retrievals, while an opening-hours update can change whether Tuesday's plan is possible. The cache has to preserve that difference instead of hiding it behind one time-to-live.

How should a travel itinerary planner cache its retrieval architecture?

Start by choosing the boundary, not the cache engine. For a planner that aggregates listings from multiple sources, there are four serious places to reuse work:

Cache boundary Pick this when Invalidate with Main trade-off
Raw source response Re-fetching the same source is expensive and its response has a usable revision signal Source revision or a conservative expiry Fast ingestion, but source-specific data still needs normalization
Normalized listing Several downstream jobs share the same canonical place or activity Listing version plus source identity Good reuse, but merge policy must remain visible
Embedded chunk Text changes less often than retrieval traffic Content hash plus embedding configuration Avoids repeated computation, but a poor chunk boundary becomes sticky
Retrieval result Queries repeat and tolerate bounded staleness Query fingerprint plus index generation Lowest read latency, but easiest place to serve an obsolete candidate set

The default shape is a layered cache: raw responses for polite ingestion, normalized listings for shared processing, and embedded chunks keyed by content. Add a retrieval-result cache only after measuring repeated queries and defining a freshness budget. This isn't one global TTL. It is a chain of explicit validity decisions.

Picture the request path in words: source adapters feed a normalizer; the normalizer emits versioned listings; a chunker emits stable chunks; an embedding stage fills the retrieval index; the planner retrieves candidates and applies current constraints before composing an itinerary. Each arrow may reuse prior output, but only when the input identity still matches.

Pick cache layers by what can become stale

Raw-response caching belongs at the ingestion edge. Pick it when a source supplies repeated pages or when several normalization attempts need the same payload. Keep the source identifier and source revision beside the payload. A blind expiry can cap risk, but it can't prove that the listing is current.

Normalized-listing caching is usually the useful center. It gives every source adapter one target shape and lets deduplication, chunking, and retrieval operate on the same fields. The catch is conflict resolution: if two sources disagree about a venue's hours, a cached merged object must retain provenance and field revisions. Flattening the disagreement into one timeless value makes invalidation guesswork.

Embedded-chunk caching is the strongest fit when listing prose is stable but availability-like fields move quickly. Put descriptive text in retrievable chunks. Keep volatile constraints in structured fields checked after retrieval. Then an hours update doesn't force the description through the entire pipeline, yet the planner still rejects a candidate that no longer fits the requested day.

Retrieval-result caching is optional.

Pick it for genuinely repeated query shapes, such as the same destination, date window, interests, and index generation. Don't use it as the first optimization for open-ended requests with rapidly changing inputs. A result cache can be internally consistent and still be wrong for the user because its candidate set predates a listing revision.

Chunk listings for stable identity, not maximum size

A travel listing is not one fact. It mixes identity, descriptive prose, location, schedules, restrictions, and source metadata. One giant chunk couples all of those fields: changing a schedule invalidates the same unit that carries a stable description. Tiny chunks go the other way — they can retrieve an attractive sentence without enough context to identify the place or assess it.

Use semantic field groups with deterministic identities. A practical listing might emit a summary chunk for name, category, neighborhood, and description; an access chunk for structured eligibility or accessibility prose; and no text chunk at all for values that the application can filter exactly. The identity should follow the logical field group, while the content hash decides whether its computed representation can be reused.

Short IDs matter.

They make invalidation surgical. If only the access content changes, the summary chunk keeps its identity and cached representation. If chunk IDs are based on array position, inserting one section shifts every later ID and creates avoidable churn. The same principle applies when three source listings merge into one canonical place: source records need their own versions, and the canonical record needs a new version whenever the merge output changes.

Consider a listing assembled from a city guide, a venue feed, and an accessibility directory. The city guide changes two words in its description, the venue feed moves Friday's closing time, and the directory remains unchanged. A monolithic cache key treats the whole listing as new, so every chunk is recomputed and every cached retrieval containing it becomes suspect. Stable field-group keys produce a narrower chain: the description selects a new summary content hash, the schedule advances the canonical listing revision used by the constraint check, and the untouched access chunk remains addressable. A retrieval result from the older index generation is no longer selected, but the application doesn't throw away unrelated computed data. This example also shows why a TTL can't carry the full policy. All three inputs could be the same age while only two have changed, and only one change affects whether the place fits Friday's itinerary.

The retrieval-augmented generation paper describes a system that combines parametric generation with retrieved non-parametric memory. For an itinerary planner, that separation is operationally useful: retrieval supplies evidence-bearing candidates, while current structured constraints still decide whether a candidate belongs in the plan. Cached retrieval is not permission to skip validation.

Implement a revision-aware cache in TypeScript

The implementation below makes validity part of the key. It does not rely on deleting every old entry at exactly the right moment. Old generations may expire naturally, while reads can only address the generation selected by the caller.

type Listing = {
  id: string;
  source: string;
  revision: string;
  name: string;
  neighborhood: string;
  description: string;
  accessNotes?: string;
};

type Chunk = {
  id: string;
  listingId: string;
  kind: "summary" | "access";
  text: string;
  contentHash: string;
};

type CacheEntry<T> = {
  value: T;
  expiresAt: number;
};

interface Cache {
  get<T>(key: string): Promise<CacheEntry<T> | undefined>;
  set<T>(key: string, entry: CacheEntry<T>): Promise<void>;
}

interface Embedder {
  configurationId: string;
  embed(text: string): Promise<number[]>;
}

async function sha256(value: string): Promise<string> {
  const bytes = new TextEncoder().encode(value);
  const digest = await crypto.subtle.digest("SHA-256", bytes);
  return Array.from(new Uint8Array(digest))
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

async function chunkListing(listing: Listing): Promise<Chunk[]> {
  const fields: Array<{ kind: Chunk["kind"]; text?: string }> = [
    {
      kind: "summary",
      text: `${listing.name}\n${listing.neighborhood}\n${listing.description}`,
    },
    { kind: "access", text: listing.accessNotes },
  ];

  return Promise.all(
    fields
      .filter((field): field is { kind: Chunk["kind"]; text: string } =>
        Boolean(field.text?.trim()),
      )
      .map(async ({ kind, text }) => ({
        id: `${listing.id}:${kind}`,
        listingId: listing.id,
        kind,
        text,
        contentHash: await sha256(text),
      })),
  );
}

async function getEmbedding(
  cache: Cache,
  embedder: Embedder,
  chunk: Chunk,
  now: number,
): Promise<number[]> {
  const key = [
    "embedding",
    embedder.configurationId,
    chunk.id,
    chunk.contentHash,
  ].join(":");
  const cached = await cache.get<number[]>(key);

  if (cached && cached.expiresAt > now) return cached.value;

  const value = await embedder.embed(chunk.text);
  await cache.set(key, {
    value,
    expiresAt: now + 30 * 24 * 60 * 60 * 1000,
  });
  return value;
}
Enter fullscreen mode Exit fullscreen mode

The 30-day expiry is an example policy, not a universal freshness claim. Content identity is the primary guard: the same text, chunk identity, and embedding configuration produce an addressable cache entry; a changed input selects a different key. Your mileage may vary on retention because storage limits and reprocessing patterns differ.

For retrieval results, include every input that can change the candidate set. A destination alone is not enough. Date constraints, locale, filters, index generation, and the normalized query all belong in the fingerprint when they affect retrieval.

type RetrievalRequest = {
  destinationId: string;
  dateStart: string;
  dateEnd: string;
  locale: string;
  interests: string[];
  indexGeneration: string;
  query: string;
};

async function retrievalKey(request: RetrievalRequest): Promise<string> {
  const stableRequest = {
    ...request,
    interests: [...request.interests].sort(),
    query: request.query.trim().toLowerCase(),
  };

  return `retrieval:${await sha256(JSON.stringify(stableRequest))}`;
}
Enter fullscreen mode Exit fullscreen mode

Don't put the final generated itinerary under that key. Generation settings and conversational context can change the answer, and live constraint checks may remove a previously retrieved listing. Cache the evidence set; rebuild the plan from validated evidence.

Measure freshness before adding more cache

Cache hit rate alone rewards the wrong outcome. Pair it with age and invalidation signals: record the age of the source revision used by each retrieved chunk, the share of candidate chunks rejected by current structured constraints, ingestion delay by source, and cache misses split into expiry, content change, configuration change, and index-generation change. Those dimensions turn “the cache is fast” into “the cache is fast enough without concealing old evidence.”

Use logs to carry listingId, source, revision, chunkId, contentHash, and indexGeneration through the pipeline. Use metrics for distributions and rates, then alert on sustained changes rather than a single miss. A rising post-retrieval rejection rate is especially useful: it can reveal that descriptive retrieval remains healthy while volatile constraints are aging faster than ingestion.

There are real limits. A result cache is not suitable when the planner must reflect near-immediate inventory or schedule changes and the source cannot provide a reliable revision signal; fetch and validate closer to the request instead. Stick with embedding-only caching when queries are diverse, because result-key cardinality can grow without producing useful reuse. For a small catalog with low traffic, skip the distributed cache entirely — deterministic chunking and a local bounded cache may be easier to operate.

I'm not sure a single freshness threshold can represent every travel source. The decision needs source update behavior and a product-level tolerance for stale suggestions. Set those explicitly, test a delayed-update case before deployment, and make the revision used for each recommendation inspectable.

References

Top comments (0)