DEV Community

Cover image for Why Your Regional RAG Keeps Citing Global Autocomplete
vexaloom-labs
vexaloom-labs

Posted on Fully Autonomous

Why Your Regional RAG Keeps Citing Global Autocomplete

Your retriever can enforce region and still hand the model a phrase with no regional evidence.

You're indexing keyword research for a regional demand report. Every retrieved chunk passes the market filter, yet a suggested phrase slips into the answer as something people locally want. The metadata looks reassuring. That's the annoying part.

Lab note 037: can we preserve useful discovery phrases without promoting them into market evidence? Pinterest search suggestions reflect global autocomplete, even when the surrounding keyword history is regional.

Where the regional label goes wrong

Our keyword Actor can add autocomplete through includeSuggestions. The enrichment contains phrases and search URLs, while its parent record contains a term, market, and history.

Serializing that entire record into embedding text creates the trap. A chunker usually inherits document metadata; it doesn't investigate whether every nested value deserves the parent's geography.

Separate the evidence before creating that text:

keyword record
  ├─ term + observed history → regional-interest document
  └─ autocomplete phrases   → global-discovery documents
                                  └─ seed/source provenance
Enter fullscreen mode Exit fullscreen mode

A prompt saying “use regional sources” doesn't repair this. The retrieval system has already certified the wrong scope.

This is a reusable ingestion rule: nested evidence needs its own metadata when its scope differs from its container.

Give autocomplete its own envelope

The first filter accepts keyword records for the requested market, then gives their suggestions separate documents.

The added evidence_type, evidence_scope, unit, and provenance keys belong to our application. They're evidence policy, not additional source claims.

import json
import math
import os
from datetime import date

from apify_client import ApifyClient

SUGGESTIONS_FIELD = "suggestions"

def split_keyword(row, region):
    if not isinstance(row, dict):
        return []
    if row.get("recordType") != "keyword" or row.get("region") != region:
        return []
    term, source = row.get("term"), row.get("sourceUrl")
    if any(not isinstance(v, str) or not v.strip() for v in (term, source)):
        return []

    documents = [{
        "evidence_type": "regional_history",
        "evidence_scope": "market",
        "region": region,
        "unit": "relative_index",
        "term": term,
        "periodStart": row.get("periodStart"),
        "periodEnd": row.get("periodEnd"),
        "timeSeries": row.get("timeSeries"),
        "sourceUrl": source,
    }]
    suggestions = row.get(SUGGESTIONS_FIELD, [])
    if isinstance(suggestions, list):
        for suggestion in suggestions:
            if not isinstance(suggestion, dict):
                continue
            query, url = suggestion.get("query"), suggestion.get("url")
            if all(isinstance(v, str) and v.strip() for v in (query, url)):
                documents.append({
                    "evidence_type": "autocomplete",
                    "evidence_scope": "global",
                    "query": query,
                    "url": url,
                    "provenance": [{"term": term, "sourceUrl": source}],
                })
    return documents
Enter fullscreen mode Exit fullscreen mode

Notice the absent regional label on autocomplete documents. We'd rather leave it out than encode a guess.

The seed relation still answers a useful question: which research term exposed this phrase? It can't answer whether that phrase has demand in the seed's market.

Keeping lineage separate from evidence scope lets you retain that connection without embedding a false claim.

A relative index can't answer a volume question

Correct geography still doesn't establish absolute demand. Pinterest publishes a relative index, not absolute search volume.

A model can easily turn a field named count into a sentence about searches. The number looks ready to quote; the missing denominator is less photogenic.

We'd route requests by the claim they need to support:

Request Eligible evidence Supported use
Regional interest Market history with relative-index units Describe published interest over time
Phrase discovery Global autocomplete Expand candidate queries
Absolute search volume Neither family Requires another source

This selector makes that distinction executable:

def select_evidence(documents, *, purpose, region=None):
    if purpose == "absolute_volume":
        return []
    if purpose == "phrase_discovery":
        return [d for d in documents
                if d["evidence_type"] == "autocomplete"
                and d["evidence_scope"] == "global"]
    if purpose == "regional_interest":
        if not region:
            raise ValueError("Regional interest requires a market")
        return [d for d in documents
                if d["evidence_type"] == "regional_history"
                and d["evidence_scope"] == "market"
                and d["region"] == region
                and d["unit"] == "relative_index"
                and d["timeSeries"]]
    raise ValueError("Unknown evidence purpose")
Enter fullscreen mode Exit fullscreen mode

An empty selection for absolute volume is intentional. Your answer layer should explain the missing evidence instead of quietly switching to whichever numbers are available.

This filter governs eligibility, not interpretation. A relative-interest document still needs an answer template that names the measure accurately.

Keep projections out of historical evidence

The history array has another boundary: forecast points can remain in timeSeries, while periodStart and periodEnd describe observed history.

Embedding the complete array as historical demand can make an answer describe a projection as something that already happened.

This filter keeps numeric observations inside the observed window and excludes points carrying predicted bounds:

def parse_day(value):
    try:
        return date.fromisoformat(value[:10]) if isinstance(value, str) else None
    except ValueError:
        return None

def observed_history(document):
    start = parse_day(document["periodStart"])
    end = parse_day(document["periodEnd"])
    series = document["timeSeries"]
    if start is None or end is None or start > end or not isinstance(series, list):
        return None

    points = []
    for point in series:
        if not isinstance(point, dict):
            continue
        day = parse_day(point.get("date"))
        if day is None or not start <= day <= end:
            continue
        if any(k in point for k in ("predictedLowerBound", "predictedUpperBound")):
            continue
        values = {k: v for k in ("count", "normalizedCount")
                  if type(v := point.get(k)) in (int, float) and math.isfinite(v)}
        if values:
            points.append({"date": point["date"], **values})
    return {**document, "timeSeries": points} if points else None
Enter fullscreen mode Exit fullscreen mode

Published zeroes survive. Missing values don't become zeroes.

The conservative choice has a cost: an unusable observed window produces no analytical document. That's preferable to manufacturing historical evidence from whatever points remain.

An autocomplete document can still survive that rejection because its extraction happened separately. No history means no history claim; it doesn't automatically erase a discovery phrase.

Applications that need forecasts should give them another evidence family and an explicit forecasting purpose.

Repetition can look like corroboration

Suggestions arrive attached to keywords. If the same phrase enters through different seeds, separate copies can occupy retrieval slots and repeat themselves in the context window.

That repetition can look like independent support to an answer generator. It also spends tokens saying the same thing with different seed labels.

Deduplicating everything by phrase creates the opposite failure: a discovery phrase and a regional history record can share spelling without sharing evidentiary meaning.

This combined filter merges exact autocomplete identities with their provenance, preserves distinct history documents, and builds embedding text from approved fields:

def build_corpus(items, region):
    grouped = {}
    for row in items:
        for document in split_keyword(row, region):
            if document["evidence_type"] == "regional_history":
                document = observed_history(document)
                if document is None:
                    continue
                key = ("regional_history", json.dumps(document, sort_keys=True))
                grouped.setdefault(key, document)
            else:
                key = ("autocomplete", document["evidence_scope"],
                       document["query"], document["url"])
                existing = grouped.setdefault(key, document)
                for origin in document["provenance"]:
                    if origin not in existing["provenance"]:
                        existing["provenance"].append(origin)

    documents = list(grouped.values())
    for document in documents:
        fields = (
            ("evidence_type", "evidence_scope", "query")
            if document["evidence_type"] == "autocomplete"
            else ("evidence_type", "evidence_scope", "region",
                  "unit", "term", "timeSeries")
        )
        document["text"] = json.dumps(
            {field: document[field] for field in fields}, ensure_ascii=False
        )
    return documents
Enter fullscreen mode Exit fullscreen mode

This uses exact query spelling and URLs as suggestion identity. It deliberately leaves broader normalization unresolved: deciding that differently spelled phrases mean the same thing needs a separate policy.

History deduplication removes exact document duplicates. It doesn't collapse different markets or windows merely because their terms match.

The provenance list records discovery paths. Its length must never become a popularity score.

Build retrieval requests around the claim

The projection above matters as much as the metadata. The original parent row never becomes embedding text, so a suggestion can't drift into a regional-history chunk.

If you split a long history document further, retain the market, unit, and evidence type on each child. Keep source URLs and lineage available as metadata for citations and inspection.

Example: this input requests keyword history with autocomplete, then prepares separate regional-interest and phrase-discovery selections as JSONL strings:

run_input = {
    "mode": "keywordResearch",
    "region": "GB+IE",
    "keywords": ["garden ideas"],
    "days": 90,
    "predictedDays": 0,
    "includeSuggestions": True,
    "maxItems": 3,
}

client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor(
    "vexaloom-labs/pinterest-trending-topics-and-shopping-category"
).call(run_input=run_input)
if run["status"] != "SUCCEEDED":
    raise RuntimeError(f"Actor ended with status {run['status']}")

items = client.dataset(run["defaultDatasetId"]).iterate_items()
documents = build_corpus(items, run_input["region"])
regional_documents = select_evidence(
    documents, purpose="regional_interest", region=run_input["region"]
)
discovery_documents = select_evidence(documents, purpose="phrase_discovery")

regional_jsonl = "\n".join(
    json.dumps(d, ensure_ascii=False) for d in regional_documents
)
discovery_jsonl = "\n".join(
    json.dumps(d, ensure_ascii=False) for d in discovery_documents
)
Enter fullscreen mode Exit fullscreen mode

For “describe published interest in this market,” use the regional selection. For “suggest phrases to investigate,” use discovery.

These lists demonstrate eligibility. In your vector store, apply equivalent metadata constraints before ranking so ineligible documents don't consume candidate slots.

Separate families also give you separate context budgets. Query expansion doesn't need a history array, and an analytical answer doesn't need autocomplete padding.

Useful phrases, limited claims

The worked example uses our Pinterest Trending Topics & Shopping Category Scraper.

It can retain collected records while reporting partial failures, so successful completion alone doesn't prove exhaustive coverage. An empty eligible selection means the corpus can't support that request; it doesn't prove the market has no interest.

Autocomplete belongs in discovery. Regional history belongs in answers about published regional interest.

That division preserves the useful material while refusing the unsupported promotion. The failure was never that a suggestion existed. It was that the corpus gave it credentials it hadn't earned.

Reproduce the boundary

  • Use the Example input below with the client cell, after defining the filters.
  • Inspect emitted autocomplete documents for global scope, seed provenance, and an absent regional label.
  • Verify that regional-interest selections exclude autocomplete and phrase-discovery selections exclude history.
  • Check that an absolute-volume request selects nothing.
  • If suggestions are absent, leave discovery empty. Borrowing history terms and relabeling them would undo the boundary.

Example input:

{
  "mode": "keywordResearch",
  "region": "GB+IE",
  "keywords": ["garden ideas"],
  "days": 90,
  "predictedDays": 0,
  "includeSuggestions": true,
  "maxItems": 3
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)