# Text match filters for agents

> Semantic search returns results close in meaning, not results that answer what you meant. See how Pinecone's text match filters fix unstated query context, no pre-labeling required.

Josh Coyne · 2026-07-14

Semantic search returns results that are close in meaning, not necessarily results that answer what someone meant to ask. A person scanning through can tell the difference and skip past the ones that miss the mark. An agent can't: it takes whatever comes back as ground truth and starts acting on it, with nobody checking the work first.

The sharpest version of that gap is unstated context: a query can be perfectly clear to the person asking it and still leave out information a system needs to answer it correctly. This post walks through that failure mode using a public dataset of 10,000 CNN news articles from Hugging Face, spanning 2022 to 2024, with each article's text and topic label stored as metadata, and shows how Pinecone's new text match filters fix it without requiring the dataset to be pre-labeled for every case in advance.

Follow along with the queries and results below in [this notebook](https://colab.research.google.com/drive/1Vjd7qAWD8aV5HB_SdMTtWUzoVEcHB9VO).

## **The ambiguity problem**

Take the query "Who are the top presidential candidates?". A user in the United States asking this almost certainly means the U.S. presidential election. Nothing in the query says so.

Dense vector search alone returns this:

| Rank | Article ID | Score | Excerpt |
|---|---|---|---|
| 1 | 3433 | 0.8168 | First round of voting, featuring 11 presidential candidates, takes place on April 23. Le Pen and Macron are tipped to... |
| 2 | 8054 | 0.8138 | Presidential candidates for France's mainstream parties failed to make the second round. Poll favorite Emmanuel Macron... |
| 3 | 3421 | 0.8130 | France holds its first round of the presidential election on Sunday. Candidates react quickly to the death of a pol... |


Every one of these is about the French election, and every one is a legitimate semantic match, yet none of them answer the question the user actually meant to ask.

The obvious fix is to have the user rewrite the query as "top US presidential candidates," but that comes with two costs. First, it pushes the burden of writing a precise query back onto the user, who came in with what felt like a simple question. Second, in an agentic pipeline, a bad first retrieval costs more than a re-query: ask an agent to chart polling trends and analyze who's rising and falling, and if it builds that analysis on French election data, the tokens spent generating the wrong answer are wasted, so are the follow-on tool calls, and the error compounds at every step downstream. Multiply that across a few thousand queries and the cost stops looking incidental.

Restricting results by country with metadata filtering is possible, but it means labeling every record for every dimension that might matter, in advance: country, election year, local versus national race. Any filter dimension discovered after the fact requires reprocessing the entire dataset, which for production systems can mean billions of records, before it can be applied.

## **The solution: text match filtering**

Full Text Search from Pinecone, now in public preview, supports text match filters: a lexical query that restricts the candidate pool for a semantic search to records matching specific text, without pre-labeling metadata for every case an application might need to handle.

Applying a text match filter for "United States" to the same query changes the candidate pool before the semantic search runs:

| Rank | Article ID | Score | Excerpt |
|---|---|---|---|
| 1 | 5326 | 0.8012 | (CNN) Former Vice President Joe Biden swept to victories across southern states on Super Tuesday, but Vermont Sen. Bernie Sanders... |
| 2 | 7641 | 0.7992 | (CNN) The Human Rights Campaign Foundation announced Thursday it will host a CNN Democratic presidential town hall in California... |
| 3 | 9859 | 0.7987 | (CNN) President Donald Trump and Democratic presidential nominee Joe Biden have taken very different positions on a range of polic... |


The query, model, and index are identical to the search above. The only difference is that the candidate pool was scoped to records containing "United States" in the article body before the vector search ran, and all three results now land on the actual U.S. race.

```json
{
  "_key": "40726406671d",
  "_type": "htmlEmbed",
  "code": {
    "_type": "code",
    "code": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>Text Match Filters: Scoping the Candidate Pool</title>\n<link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap\" rel=\"stylesheet\">\n<style>\n  /* Everything below is scoped under #tmf-root so it can't collide with, or\n     be overridden by, host-page CSS if this markup ends up injected directly\n     into the page's DOM rather than sandboxed in an iframe. Class names are\n     prefixed (tmf-) for the same reason — \".page\" / \".panel\" are exactly the\n     kind of generic names a real site already uses. */\n  #tmf-root, #tmf-root *, #tmf-root *::before, #tmf-root *::after {\n    box-sizing: border-box !important;\n    margin: 0;\n    padding: 0;\n  }\n\n  #tmf-root {\n    --bg:          #ffffff;\n    --dot:         #cac8c4;\n    --dot-opacity: 0.11;\n    --text:        #1c1917;\n    --muted:       rgba(28,25,23,0.68);\n    --muted2:      rgba(28,25,23,0.5);\n    --border:      #e7e5e4;\n    --panel-fill:  #ffffff;\n    --blue:        #002bff;\n    --blue-faint:  rgba(0,43,255,0.06);\n    --blue-border: rgba(0,43,255,0.28);\n    --skip-fill:   #e7e5e4;\n    --skip-fill2:  #d8d5d1;\n    --row-label:   rgba(28,25,23,0.55);\n    --arrow:       #a8a29e;\n\n    position: relative;\n    display: block !important;\n    width: 100% !important;\n    background: var(--bg);\n    font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n    color: var(--text);\n    text-align: left;\n    line-height: normal;\n  }\n\n  @media (prefers-color-scheme: dark) {\n    #tmf-root {\n      --bg:          #171717;\n      --dot:         #ffffff;\n      --dot-opacity: 0.035;\n      --text:        #f5f5f5;\n      --muted:       rgba(245,245,245,0.72);\n      --muted2:      rgba(245,245,245,0.5);\n      --border:      #404040;\n      --panel-fill:  #1e1e1e;\n      --blue:        #2b8bff;\n      --blue-faint:  rgba(43,139,255,0.1);\n      --blue-border: rgba(43,139,255,0.4);\n      --skip-fill:   #333333;\n      --skip-fill2:  #2a2a2a;\n      --row-label:   rgba(245,245,245,0.55);\n      --arrow:       #5c5c5c;\n    }\n  }\n\n  /* Explicit overrides (set by the tiny script below via ?theme= or a parent\n     postMessage) always win over the OS-preference media query above, since\n     a class selector beats a bare element selector — but the page looks\n     correct even if that script never runs. */\n  #tmf-root.tmf-dark {\n    --bg:          #171717;\n    --dot:         #ffffff;\n    --dot-opacity: 0.035;\n    --text:        #f5f5f5;\n    --muted:       rgba(245,245,245,0.72);\n    --muted2:      rgba(245,245,245,0.5);\n    --border:      #404040;\n    --panel-fill:  #1e1e1e;\n    --blue:        #2b8bff;\n    --blue-faint:  rgba(43,139,255,0.1);\n    --blue-border: rgba(43,139,255,0.4);\n    --skip-fill:   #333333;\n    --skip-fill2:  #2a2a2a;\n    --row-label:   rgba(245,245,245,0.55);\n    --arrow:       #5c5c5c;\n  }\n  #tmf-root.tmf-light {\n    --bg:          #ffffff;\n    --dot:         #cac8c4;\n    --dot-opacity: 0.11;\n    --text:        #1c1917;\n    --muted:       rgba(28,25,23,0.68);\n    --muted2:      rgba(28,25,23,0.5);\n    --border:      #e7e5e4;\n    --panel-fill:  #ffffff;\n    --blue:        #002bff;\n    --blue-faint:  rgba(0,43,255,0.06);\n    --blue-border: rgba(0,43,255,0.28);\n    --skip-fill:   #e7e5e4;\n    --skip-fill2:  #d8d5d1;\n    --row-label:   rgba(28,25,23,0.55);\n    --arrow:       #a8a29e;\n  }\n\n  /* Scoped to this wrapper's own box (not html/body) so it can't spill onto\n     the host page and isn't at the mercy of the host page's own sizing. */\n  #tmf-root::before {\n    content: '';\n    position: absolute; inset: 0;\n    background-image: radial-gradient(circle, var(--dot) 1px, transparent 1px);\n    background-size: 5.5px 5.5px;\n    opacity: var(--dot-opacity);\n    pointer-events: none;\n  }\n\n  #tmf-root .tmf-page {\n    position: relative;\n    z-index: 1;\n    width: 100% !important;\n    max-width: 720px !important;\n    margin-left: auto !important;\n    margin-right: auto !important;\n    padding: 28px 20px;\n  }\n\n  #tmf-root .tmf-flow-row {\n    display: flex !important;\n    align-items: center;\n    flex-wrap: wrap;\n    gap: 12px;\n    margin-bottom: 4px;\n    width: 100%;\n  }\n\n  #tmf-root .tmf-row-block { margin-bottom: 30px; }\n  #tmf-root .tmf-row-block:last-child { margin-bottom: 0; }\n\n  #tmf-root .tmf-row-tag {\n    display: inline-flex;\n    align-items: center;\n    gap: 8px;\n    font-family: 'JetBrains Mono', monospace;\n    font-size: 11px;\n    font-weight: 600;\n    letter-spacing: 0.05em;\n    text-transform: uppercase;\n    color: var(--row-label);\n    margin-bottom: 12px !important;\n  }\n  #tmf-root .tmf-row-tag .tmf-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--arrow); }\n  #tmf-root .tmf-row-tag.tmf-hit .tmf-dot { background: var(--blue); }\n  #tmf-root .tmf-row-tag.tmf-hit { color: var(--blue); }\n\n  #tmf-root .tmf-panel {\n    background: var(--panel-fill);\n    border: 1px solid var(--border);\n    padding: 14px 16px !important;\n    min-width: 0;\n    height: 190px !important;\n    display: flex !important;\n    flex-direction: column !important;\n  }\n\n  #tmf-root .tmf-panel-eyebrow {\n    font-family: 'JetBrains Mono', monospace;\n    font-size: 9.5px;\n    font-weight: 600;\n    letter-spacing: 0.06em;\n    text-transform: uppercase;\n    color: var(--muted2);\n    margin-bottom: 8px !important;\n  }\n\n  /* Query panel */\n  #tmf-root .tmf-panel-query { flex: 1 1 190px; min-width: 160px; }\n  #tmf-root .tmf-query-text {\n    font-size: 12.5px;\n    line-height: 1.4;\n    color: var(--text);\n    font-style: italic;\n  }\n  #tmf-root .tmf-filter-chip {\n    margin-top: 10px !important;\n    display: inline-flex;\n    align-items: center;\n    gap: 6px;\n    font-family: 'JetBrains Mono', monospace;\n    font-size: 10px;\n    font-weight: 500;\n    color: var(--blue);\n    background: var(--blue-faint);\n    border: 1px solid var(--blue-border);\n    padding: 4px 8px !important;\n  }\n\n  /* Pool panel */\n  #tmf-root .tmf-panel-pool { flex: 1 1 190px; min-width: 160px; }\n  #tmf-root .tmf-tick-grid {\n    display: grid !important;\n    grid-template-columns: repeat(6, 1fr);\n    grid-auto-rows: 22px;\n    gap: 4px;\n    margin-bottom: 10px !important;\n  }\n  #tmf-root .tmf-tick {\n    width: 100%;\n    height: 100% !important;\n    background: var(--skip-fill);\n    border-radius: 1px;\n  }\n  #tmf-root .tmf-tick.tmf-kept { background: var(--blue); }\n  #tmf-root .tmf-pool-caption {\n    font-size: 11px;\n    color: var(--muted);\n    line-height: 1.4;\n  }\n\n  /* Result panel */\n  #tmf-root .tmf-panel-result { flex: 1 1 190px; min-width: 160px; }\n  #tmf-root .tmf-result-excerpt {\n    font-size: 11.5px;\n    line-height: 1.45;\n    color: var(--muted);\n    margin-bottom: 10px !important;\n  }\n  #tmf-root .tmf-result-verdict {\n    display: flex !important;\n    align-items: center;\n    gap: 7px;\n    font-family: 'JetBrains Mono', monospace;\n    font-size: 11px;\n    font-weight: 600;\n    padding-top: 9px !important;\n    border-top: 1px solid var(--border);\n    margin-top: auto !important;\n  }\n  #tmf-root .tmf-mark {\n    width: 15px; height: 15px;\n    border-radius: 50%;\n    display: inline-flex; align-items: center; justify-content: center;\n    font-size: 9.5px;\n    flex-shrink: 0;\n  }\n  #tmf-root .tmf-mark.tmf-no  { background: var(--skip-fill2); color: var(--muted); }\n  #tmf-root .tmf-mark.tmf-yes { background: var(--blue); color: #fff; }\n  #tmf-root .tmf-verdict-text.tmf-no  { color: var(--muted); }\n  #tmf-root .tmf-verdict-text.tmf-yes { color: var(--blue); }\n\n  /* Arrows */\n  #tmf-root .tmf-arrow-col {\n    display: flex !important;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    gap: 4px;\n    flex: 0 0 auto;\n    width: 20px;\n  }\n  #tmf-root .tmf-arrow-glyph { color: var(--arrow); font-size: 15px; line-height: 1; }\n  #tmf-root .tmf-arrow-label {\n    font-family: 'JetBrains Mono', monospace;\n    font-size: 8.5px;\n    color: var(--muted2);\n    white-space: nowrap;\n    text-align: center;\n  }\n</style>\n</head>\n<body>\n<div id=\"tmf-root\">\n<div class=\"tmf-page\">\n  <!-- Row A: dense search alone -->\n  <div class=\"tmf-row-block\">\n    <div class=\"tmf-row-tag\"><span class=\"tmf-dot\"></span>Dense vector search alone</div>\n    <div class=\"tmf-flow-row\">\n      <div class=\"tmf-panel tmf-panel-query\">\n        <div class=\"tmf-panel-eyebrow\">Query</div>\n        <div class=\"tmf-query-text\">\"Who are the top presidential candidates?\"</div>\n      </div>\n      <div class=\"tmf-arrow-col\"><span class=\"tmf-arrow-glyph\">→</span></div>\n      <div class=\"tmf-panel tmf-panel-pool\">\n        <div class=\"tmf-panel-eyebrow\">Candidate pool</div>\n        <div class=\"tmf-tick-grid\">\n          <div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div>\n          <div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div>\n          <div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div>\n          <div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick tmf-kept\"></div>\n        </div>\n        <div class=\"tmf-pool-caption\">10,000 articles</div>\n      </div>\n      <div class=\"tmf-arrow-col\"><span class=\"tmf-arrow-glyph\">→</span><span class=\"tmf-arrow-label\">vector<br>search</span></div>\n      <div class=\"tmf-panel tmf-panel-result\">\n        <div class=\"tmf-panel-eyebrow\">Top result</div>\n        <div class=\"tmf-result-excerpt\">\"First round of voting, featuring 11 presidential candidates, takes place on April 23. Le Pen and Macron…\"</div>\n        <div class=\"tmf-result-verdict\"><span class=\"tmf-mark tmf-no\">✕</span><span class=\"tmf-verdict-text tmf-no\">Off-target: France</span></div>\n      </div>\n    </div>\n  </div>\n\n  <!-- Row B: with text match filter -->\n  <div class=\"tmf-row-block\">\n    <div class=\"tmf-row-tag tmf-hit\"><span class=\"tmf-dot\"></span>+ Text match filter</div>\n    <div class=\"tmf-flow-row\">\n      <div class=\"tmf-panel tmf-panel-query\">\n        <div class=\"tmf-panel-eyebrow\">Query + filter</div>\n        <div class=\"tmf-query-text\">\"Who are the top presidential candidates?\"</div>\n        <div class=\"tmf-filter-chip\">text_match: \"United States\"</div>\n      </div>\n      <div class=\"tmf-arrow-col\"><span class=\"tmf-arrow-glyph\">→</span></div>\n      <div class=\"tmf-panel tmf-panel-pool\">\n        <div class=\"tmf-panel-eyebrow\">Candidate pool</div>\n        <div class=\"tmf-tick-grid\">\n          <div class=\"tmf-tick\"></div><div class=\"tmf-tick\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick\"></div><div class=\"tmf-tick\"></div><div class=\"tmf-tick\"></div>\n          <div class=\"tmf-tick\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick\"></div><div class=\"tmf-tick\"></div><div class=\"tmf-tick\"></div><div class=\"tmf-tick\"></div>\n          <div class=\"tmf-tick\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick\"></div><div class=\"tmf-tick\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick\"></div>\n          <div class=\"tmf-tick\"></div><div class=\"tmf-tick\"></div><div class=\"tmf-tick\"></div><div class=\"tmf-tick tmf-kept\"></div><div class=\"tmf-tick\"></div><div class=\"tmf-tick\"></div>\n        </div>\n        <div class=\"tmf-pool-caption\">Scoped by filter</div>\n      </div>\n      <div class=\"tmf-arrow-col\"><span class=\"tmf-arrow-glyph\">→</span><span class=\"tmf-arrow-label\">vector<br>search</span></div>\n      <div class=\"tmf-panel tmf-panel-result\">\n        <div class=\"tmf-panel-eyebrow\">Top result</div>\n        <div class=\"tmf-result-excerpt\">\"Former Vice President Joe Biden swept to victories across southern states on Super Tuesday…\"</div>\n        <div class=\"tmf-result-verdict\"><span class=\"tmf-mark tmf-yes\">✓</span><span class=\"tmf-verdict-text tmf-yes\">On-target: U.S.</span></div>\n      </div>\n    </div>\n  </div>\n</div>\n</div>\n\n<script>\n(function () {\n  'use strict';\n  // Progressive enhancement only — the page above is already correct\n  // without this running at all (pure CSS layout + prefers-color-scheme).\n  var root = document.getElementById('tmf-root');\n  function applyTheme(t) {\n    root.classList.toggle('tmf-dark', t === 'dark');\n    root.classList.toggle('tmf-light', t === 'light');\n  }\n  try {\n    var param = new URLSearchParams(location.search).get('theme');\n    if (param === 'dark' || param === 'light') applyTheme(param);\n    window.addEventListener('message', function (e) {\n      if (e.data && (e.data.theme === 'dark' || e.data.theme === 'light')) applyTheme(e.data.theme);\n    });\n  } catch (_) { /* ignore */ }\n\n  // Tell a parent iframe how tall this content actually is, so a CMS embed\n  // block that sizes itself from a one-time/stale measurement doesn't clip\n  // it. No width/scale logic here — just a height number, sent whenever it\n  // changes (font load, content update, viewport resize).\n  function reportHeight() {\n    try {\n      if (window.parent === window) return;\n      var h = Math.ceil(root.getBoundingClientRect().height);\n      window.parent.postMessage({ source: 'pinecone-text-match-filters-embed', height: h }, '*');\n    } catch (_) { /* cross-origin restrictions */ }\n  }\n  reportHeight();\n  window.addEventListener('load', reportHeight);\n  if (window.ResizeObserver) {\n    new ResizeObserver(reportHeight).observe(root);\n  } else {\n    window.addEventListener('resize', reportHeight);\n  }\n})();\n</script>\n</body>\n</html>\n"
  },
  "title": "animation"
}
```

## **Where this generalizes**

News search makes the ambiguity easy to see, but the same pattern shows up anywhere a query assumes context it doesn't state: industrial manuals scoped to a machine number or error code, insurance claims scoped to a policy number or type, legal search scoped to a case or jurisdiction. Most semantic search applications run into some version of this, since most queries leave something unstated.

These filters chain: combine them with boolean operators, stack them with metadata filters, or layer on more text match filters against other fields, and the candidate pool narrows along several dimensions in a single query.

## **What changes for agentic applications**

An agent has no step where it double-checks that a semantically close result is actually the right one for the task. Whatever filtering needs to happen has to happen before the results reach the agent, not after. Text match filtering moves that correction into the query itself, without requiring the dataset to be pre-labeled for every filter an application might eventually need. For pipelines where a bad retrieval turns into a chain of wasted tool calls, narrowing the candidate pool at query time is cheaper than catching the error after the fact.

