> ## Documentation Index
> Fetch the complete documentation index at: https://docs.particle.pro/llms.txt
> Use this file to discover all available pages before exploring further.

# Episode search

> Search inside podcast episodes by what is said — by meaning, by exact phrase, or both. For 'every line about a person or company', see Mentions.

`GET /v1/podcasts/episodes/search` finds dialogue inside podcast episodes. Each result is a segment of an episode, returned with bounded transcript windows centered on the highest-relevance dialogue lines (flagged `is_match: true`) and any highlight clips that overlap the segment.

<Note>Available to MCP agents as [`particle_podcast_search_transcripts`](/mcp/tools/podcasts/podcast-search-transcripts).</Note>

<Warning>
  This endpoint searches **dialogue**, not podcasts. To find a *podcast*
  by name, use [`GET /v1/podcasts/search?q=…`](/podcasts/search). To resolve
  a podcast from an Apple / Spotify / YouTube identifier, use
  [`GET /v1/podcasts/lookup`](/podcasts/lookup).
</Warning>

## When to use Episode search vs Mentions

The Particle podcast surface ships two complementary dialogue-search endpoints. Pick by what you're really asking.

| You want…                                                                  | Use this                                                                     |
| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| Dialogue that *means* something — paraphrase tolerant                      | [`/v1/podcasts/episodes/search?semantic_search=…`](/podcasts/episode-search) |
| Dialogue containing exact tokens or phrases (BM25)                         | [`/v1/podcasts/episodes/search?keyword_search=…`](/podcasts/episode-search)  |
| Both: rank by an idea while boosting — or, quoted, requiring — exact terms | `/v1/podcasts/episodes/search?semantic_search=…&keyword_search=…`            |
| **Every line where a person or company is mentioned**                      | [`/v1/podcasts/mentions?entity_id=…`](/podcasts/mentions)                    |
| Conceptual search scoped to a person/company                               | `/v1/podcasts/episodes/search?semantic_search=…&entity_id=…`                 |

Episode search ranks segments by relevance. Mentions returns episodes with all of their mention windows in time order. Different shapes, different jobs.

## `semantic_search` — search by meaning

Vector search is the right tool when the surface words in dialogue might not match the surface words in your query. Two speakers can discuss the same idea using totally different vocabulary, and a lexical engine misses both. Express your query the way you'd describe the topic to a colleague — full sentences are welcome.

```bash theme={"dark"}
curl --get "https://api.particle.pro/v1/podcasts/episodes/search" \
  --data-urlencode "semantic_search=hosts arguing about whether the Fed is staying too hawkish given how much core inflation has cooled" \
  -H "X-API-Key: $PARTICLE_API_KEY"
```

The above will surface segments that talk about *restrictive monetary policy*, *the FOMC's bias*, *PCE moderation*, or *real rates being too high* — even when the words "hawkish" or "Fed" never appear.

What `semantic_search` is **not** good at:

| Don't ask it…                                                                                                                                                                                    | Use this instead                                                              |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| "Every line about Sam Altman" — that's an entity question, not a content question.                                                                                                               | [`/v1/podcasts/mentions?entity_id=sam-altman`](/podcasts/mentions)            |
| "Episodes that mention OpenAI" — `semantic_search=OpenAI` is a name, not a topic; vector search drifts to *adjacent* dialogue and misses plain mentions.                                         | [`/v1/podcasts/mentions?company_id=openai`](/podcasts/mentions)               |
| "Podcasts where Sam Altman is a guest" — structural metadata about an episode, not its dialogue.                                                                                                 | [`/v1/podcasts/mentions?entity_id=sam-altman&role=guest`](/podcasts/mentions) |
| `"NVDA H100"` — when a specific token (ticker, model number) must appear verbatim, BM25 is more reliable than vector similarity.                                                                 | `keyword_search=NVDA H100`                                                    |
| `"AGI AND not safety"` — boolean logic isn't supported. Express the underlying intent in natural language; add a *quoted* `keyword_search` phrase if a literal term must appear in every result. | `semantic_search=…&keyword_search="…"`                                        |

## `keyword_search` — search by exact tokens (BM25)

Use this when the exact form of a token matters: company tickers, drug names, model numbers, hashtags. Tokens are matched after the same normalization the index applies (lowercased, English tokenizer, no stemming); punctuation splits tokens, and single-letter tokens without a digit are dropped, so spell terms out rather than abbreviating to one character. Multi-word queries are matched as a bag of tokens, ranked by BM25.

Every token you type must appear somewhere in the matched passage. That is what makes results trustworthy — a hit is guaranteed to contain your terms — but it also means a loose pile of related words that never co-occur in one passage returns nothing. Pass `keyword_match=ranked` for that case and the words steer relevance instead of excluding anything.

To require an exact ordered phrase rather than independent tokens, wrap it in double quotes — `keyword_search="machine learning"`. Multiple quoted phrases must all appear (`"central bank" "interest rates"`). Quoted phrases filter under **both** modes, so to relax a phrase remove its quotes rather than switching mode.

A quoted phrase requires those words **adjacent and in order** in the segment's spoken dialogue. A speaker's own name is not indexed as dialogue — searching `"Graham Duncan"` returns segments where the name appears in what was said, not every segment he speaks in. Use `entity_id` or [`/v1/podcasts/mentions`](/podcasts/mentions) to find a person's appearances instead. Adjacency is measured across the segment's dialogue as a whole, so in rare cases a phrase can straddle the break between two consecutive lines.

There is no boolean `OR` — a bare `OR` is matched as an ordinary word. Send alternatives as separate requests.

```bash theme={"dark"}
curl --get "https://api.particle.pro/v1/podcasts/episodes/search" \
  --data-urlencode "keyword_search=GLP-1" \
  -H "X-API-Key: $PARTICLE_API_KEY"
```

## Hybrid: `semantic_search` + `keyword_search`

When you want to rank by an idea *and* a specific term at once, send both. Each leg runs independently — vector similarity and BM25 — and the two result sets are fused via reciprocal rank fusion. Under the default `keyword_match=required` the fusion is an **intersection**: your keyword tokens filter both legs, so a segment cannot surface on vector similarity alone if it never says what you typed. Pass `keyword_match=ranked` to make it a **union** instead, where an unquoted keyword only boosts relevance and a strong vector match can surface without it. Quoted phrases are hard filters under both modes, so every result contains them no matter which leg it came from.

```bash theme={"dark"}
curl --get "https://api.particle.pro/v1/podcasts/episodes/search" \
  --data-urlencode "semantic_search=existential concerns about AI systems acting outside human control" \
  --data-urlencode "keyword_search=alignment" \
  -H "X-API-Key: $PARTICLE_API_KEY"
```

## Scoping a ranked search to an entity

`entity_id` and `company_id` here are *filters* — they narrow ranked candidates to episodes featuring the resolved entity. The ranking still comes from `semantic_search` / `keyword_search`. To read every line about an entity, use [Mentions](/podcasts/mentions) instead.

```bash theme={"dark"}
curl --get "https://api.particle.pro/v1/podcasts/episodes/search" \
  --data-urlencode "semantic_search=AGI timelines and what plausible paths to it look like" \
  --data-urlencode "entity_id=sam-altman" \
  -H "X-API-Key: $PARTICLE_API_KEY"
```

## Response

```json theme={"dark"}
{
  "data": [
    {
      "episode": {
        "id": "…",
        "title": "…",
        "published_at": "2026-…",
        "podcast": {"id": "…", "title": "All-In"}
      },
      "segment": {
        "id": "…",
        "type": "TOPIC_DISCUSSION",
        "title": "…",
        "start_seconds": 1284,
        "end_seconds": 1620
      },
      "windows": [
        {
          "start_seconds": 1305,
          "end_seconds": 1318,
          "lines": [
            {"number": 217, "speaker": "Chamath Palihapitiya", "role": "HOST", "start_seconds": 1305, "end_seconds": 1310, "text": "…"},
            {"number": 218, "speaker": "Jason Calacanis",     "role": "HOST", "start_seconds": 1310, "end_seconds": 1314, "text": "…", "is_match": true},
            {"number": 219, "speaker": "Chamath Palihapitiya", "role": "HOST", "start_seconds": 1314, "end_seconds": 1318, "text": "…"}
          ]
        }
      ],
      "clips": [
        {"id": "…", "title": "…", "engagement_score": 78, "start_seconds": 1310.5, "end_seconds": 1382.2}
      ],
      "match": {"source": "semantic", "relevance_score": 0.82}
    }
  ],
  "has_more": true,
  "cursor": "r.AbCd…",
  "entity": {"id": "…", "slug": "sam-altman", "name": "Sam Altman"}
}
```

`windows` is bounded — never the whole segment. Each window centers on one or more high-relevance lines (flagged `is_match: true`) padded with surrounding context. A single segment can produce multiple non-overlapping windows when the top-scored lines are far apart inside it. When the line-scoring path can't pinpoint a match (e.g., a degraded embedding service or no individual line scored above zero), the window falls back to the segment's opening lines and is flagged `is_preview: true`.

`match.source` is `semantic`, `keyword`, or `hybrid` — branch on it when rendering. `clips` is omitted when no highlight clip overlaps the segment. The page-level `entity` block appears when an `entity_id` or `company_id` filter was provided **and resolved successfully** — for `company_id` it is the company's *linked entity* — and a `company` block additionally appears alongside it when the filter was a `company_id`. A reference that can't be resolved is rejected with `422 unresolved_reference`, naming the parameter and the value that failed and pointing at the endpoint that turns your text into a usable slug — an unresolvable filter is a typo to fix, not an empty result to render. A `company_id` that resolves to a company we hold no linked entity for is different: the request was valid and there is simply nothing to filter on, so it returns empty `data` and still echoes the `company` block (without `entity`) so you can render what you matched.

## Filters

| Param                      | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `podcast_id`               | Slug, ID, or numeric iTunes ID.                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `episode_id`               | Restrict to a single episode.                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `entity_id` / `company_id` | Filter (not the primary query). For "every line about X" use [Mentions](/podcasts/mentions).                                                                                                                                                                                                                                                                                                                                                                                                 |
| `entity_type`              | Entity *category* slug (e.g. `company`, `school`, `book`) — narrows to episodes that mention any entity of that category. Ignored when `entity_id` or `company_id` resolves a specific entity (those are strictly narrower). When `entity_type` is doing the filtering — no `entity_id`/`company_id` — it cannot be combined with `role`: speakers are always people, so a role + category combination never matches. Slugs come from [`GET /v1/entities/types`](/knowledge-graph/entities). |
| `keyword_match`            | `required` (default) or `ranked`. Controls whether every unquoted `keyword_search` token must appear in the passage, or merely boosts its rank. Under `required` a hybrid query is the intersection of both searches. Ignored when `keyword_search` is empty.                                                                                                                                                                                                                                |
| `role`                     | `guest`, `host`, `panelist`, `correspondent`, `speaker` (any speaking role), or `mention` (talked about rather than speaking). Omit to match both speaking and mention. Requires `entity_id` or `company_id`.                                                                                                                                                                                                                                                                                |
| `type`                     | Segment type filter (e.g. `INTERVIEW`).                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `since` / `until`          | Episode `published_at` window. ISO date or date-time. A bare `until` date covers that entire day, so `until=2024-06-01` includes episodes published on 1 June. Pass a date-time for a narrower bound.                                                                                                                                                                                                                                                                                        |
| `sort`                     | `relevance` (default) or `recency`.                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `context`                  | Lines of surrounding dialogue around each matched line (1–15, default 1). Widens each transcript window in place — ask for more context instead of fetching the full transcript.                                                                                                                                                                                                                                                                                                             |

## When a search comes back empty

An empty page is ambiguous on its own: it could mean the corpus holds nothing on
your topic, or that one filter was too narrow. So when the **first** page of a
search comes back empty and we can attribute why, the response carries a
`diagnostics` block that says which it was.

Treat it as advisory — check whether it is present rather than assuming it. It
is absent whenever an empty page has nothing we can honestly attribute: paging
past the end of a result set (an empty page after a `cursor` is ordinary
exhaustion, not a filter problem), a `company_id` that resolves to a company
with no linked entity, a `language` filter we cannot reproduce in the probes,
and the case where ranked matches were found but dropped on the way out — a
data-consistency problem on our side rather than something your request can fix.
We would rather return nothing than name a filter that is not the cause.

```json theme={"dark"}
{
  "data": [],
  "has_more": false,
  "diagnostics": {
    "outcome": "filter_too_narrow",
    "summary": "The search text matches content, but role excluded all of it. Removing role alone returns results.",
    "filters": [
      {
        "parameter": "role",
        "value": "correspondent (entity_id=jerome-powell)",
        "emptied_results": true,
        "remedy": "This entity does appear in content matching your query, but not in the role you asked for. role is narrow: it distinguishes someone speaking as a guest or host from someone merely being talked about.",
        "retry_with": {"role": ""}
      }
    ],
    "next_steps": ["Remove role, then retry."]
  }
}
```

`outcome` is one of:

| Value                | Meaning                                                                                                                                                       |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `filter_too_narrow`  | Content matches your query text; a named filter excluded it. `filters[].emptied_results` marks which, and `retry_with` is the exact parameter change to make. |
| `over_constrained`   | Content matches, but no single filter is responsible — only relaxing several at once recovers results.                                                        |
| `no_match_for_query` | Nothing matches the search text itself, with every filter removed. Rephrase rather than adjusting filters.                                                    |

Each entry in `filters` reports one filter your request applied. `remedy` says
why that filter excluded everything, and `retry_with` is the parameter change to
apply to the same request — an empty value means remove that parameter. Where
one parameter cannot be cleared on its own, `retry_with` names every parameter
that has to change together.

Entries marked `emptied_results` were verified by re-running your search with
that one filter removed and everything else held fixed, so their `retry_with` is
a change already known to return results rather than a guess.

## Pagination

Standard `limit` (1–100, default 25) + opaque `cursor`. Pass the `cursor` from the previous response back as `?cursor=…` to fetch the next page. Cursors are opaque — don't parse them.

## Related

* [Podcast search](/podcasts/search) — find a *podcast* by name, not dialogue inside episodes.
* [Mentions](/podcasts/mentions) — every line where a person or company is mentioned, episode-grouped.
* [Transcripts → mentions in one episode](/podcasts/transcripts#transcript-mentions) — every entity mentioned in a single episode.
* [Episodes](/podcasts/episodes) — episode-level recall when you don't need dialogue.
