<!-- source: https://docs.redpine.ai/docs/filtering -->
# Filtering

> Narrow results by metadata, dates, and identifiers.

## Operators

| Operator  | Description             | Example value         |
| --------- | ----------------------- | --------------------- |
| `eq`      | Equals                  | `"research"`          |
| `ne`      | Not equals              | `"deleted"`           |
| `in`      | Matches any in list     | `["tech", "science"]` |
| `not_in`  | Excludes values in list | `["spam", "junk"]`    |
| `gt`      | Greater than            | `2020`                |
| `gte`     | Greater than or equal   | `2020`                |
| `lt`      | Less than               | `2025`                |
| `lte`     | Less than or equal      | `2025`                |
| `between` | Range (inclusive)       | `[2020, 2025]`        |

## Building filters in the SDKs

The SDKs ship a builder, `F`, that produces the structured DSL below without hand-writing JSON. Pick a field, apply one operator, combine with and, or, not. The result goes straight into the `filters` argument of `search` and `assisted_search`. A raw object in either format is accepted in the same place if you already have one.

<CodeBlockTabs defaultValue="Python" groupId="lang">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="TypeScript">
      TypeScript
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Go">
      Go
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="Python">
    ```python  
    from redpine import F

    recent = F("publication_date").between("2020-01-01", "2025-12-31")
    by_journal = F("issn").in_(["1234-5679", "2345-6787"]) | F("publisher").eq("Example Publishing")
    not_one = ~F("issn").eq("2345-6787")
    high_impact = F("journal_metric.2yr_mean_citedness").gte(5)

    r = client.search("crispr delivery", collection="my-collection", filters=recent & by_journal & not_one)
    ```
  </CodeBlockTab>

  <CodeBlockTab value="TypeScript">
    ```typescript  
    import { F } from "@redpine-ai/sdk";

    const recent = F("publication_date").between("2020-01-01", "2025-12-31");
    const byJournal = F("issn").in(["1234-5679", "2345-6787"]).or(F("publisher").eq("Example Publishing"));
    const notOne = F("issn").eq("2345-6787").not();
    const highImpact = F("journal_metric.2yr_mean_citedness").gte(5);

    const r = await client.search("crispr delivery", {
      collection: "my-collection",
      filters: recent.and(byJournal).and(notOne),
    });
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Go">
    ```go  
    recent := redpine.F("publication_date").Between("2020-01-01", "2025-12-31")
    byJournal := redpine.F("issn").In("1234-5679", "2345-6787").Or(redpine.F("publisher").Eq("Example Publishing"))
    notOne := redpine.F("issn").Eq("2345-6787").Not()
    highImpact := redpine.F("journal_metric.2yr_mean_citedness").Gte(5)

    r, err := client.Search(ctx, "crispr delivery", redpine.SearchOptions{
        Collection: "my-collection",
        Filters:    recent.And(byJournal).And(notOne),
    })
    ```
  </CodeBlockTab>
</CodeBlockTabs>

Operators map one to one onto the table above: `eq`, `ne`, `in`, `not_in`, `gt`, `gte`, `lt`, `lte`, `between`. Python spells them `in_` and `not_in`, TypeScript `in` and `notIn`, Go `In` and `NotIn`. Combining two `and` filters flattens into one `and` list rather than nesting, so the JSON sent is the compact form shown in the sections below.

## Simple format

Key-value pairs where each key is a metadata field name. Supports exact match, list membership, range operators, and negation. An object value takes only range keys (`gt`, `gte`, `lt`, `lte`) or `not`.

Don't mix the two formats: a filter with `and`, `or` or `not` beside plain field keys is rejected, because the field keys would be ignored. Put them inside the list as `{"field": ..., "eq": ...}` conditions.

### Exact match

```json
{
  "filters": {
    "journal": "Nature"
  }
}
```

### Range

```json
{
  "filters": {
    "publication_date": { "gte": "2020-01-01", "lte": "2025-12-31" }
  }
}
```

### Any-of list

Pass an array to match any value in the list (equivalent to `in`).

```json
{
  "filters": {
    "issn": ["1234-5679", "2345-6787"]
  }
}
```

### Negation

```json
{
  "filters": {
    "publisher": { "not": "Example Publishing" }
  }
}
```

## Structured DSL

Use boolean combinators (`and`, `or`, `not`) with explicit field conditions for complex filtering logic. Supports arbitrary nesting.

Each condition takes one operator. The range bounds `gt`, `gte`, `lt` and `lte` are the exception and combine in one condition (`{"field": "publication_date", "gte": "2020-01-01", "lte": "2025-12-31"}`); `between` does not combine with `gte` or `lte`. Any other pair, such as `gte` with `ne`, is rejected: put each in its own condition under `and`.

### Basic AND condition

```json
{
  "filters": {
    "and": [
      { "field": "publication_date", "gte": "2020-01-01" },
      { "field": "publisher", "eq": "Example Publishing" }
    ]
  }
}
```

### OR condition

```json
{
  "filters": {
    "or": [
      { "field": "publisher", "eq": "tech" },
      { "field": "publisher", "eq": "science" }
    ]
  }
}
```

### Nested combinators

Combinators can be nested to build complex expressions. This example matches documents published between 2020 and 2025 that are either from one of two specific ISSNs or from Example Publishing.

```json
{
  "filters": {
    "and": [
      { "field": "publication_date", "gte": "2020-01-01" },
      { "field": "publication_date", "lte": "2025-12-31" },
      {
        "or": [
          { "field": "issn", "in": ["1234-5679", "2345-6787"] },
          { "field": "publisher", "eq": "Example Publishing" }
        ]
      }
    ]
  }
}
```

### NOT combinator

Use `not` to exclude results matching a condition. Like `and` and `or`, it takes an **array** of conditions: passing a bare object is a parse error.

```json
{
  "filters": {
    "and": [
      { "field": "publisher", "eq": "Example Publishing" },
      {
        "not": [
          { "field": "issn", "eq": "2345-6787" }
        ]
      }
    ]
  }
}
```

## Filtering by journal and article identity

`issn` and `doi` are both indexed. Prefer `issn` over `journal` when you mean a specific journal: the same journal appears in the corpus under several title spellings ("Example Medical Journal", "Example Med J", "EXAMPLE MEDICAL JOURNAL"), while its ISSN does not change.

ISSN accepts hyphenated or bare, upper- or lower-case check character. All four forms select the same journal:

```json
{ "filters": { "issn": "1234-561X" } }
```

`"1234-561X"`, `"1234-561x"`, `"1234561X"` and `"1234561x"` are equivalent.

DOIs are matched case-insensitively, and a resolver prefix is optional:

```json
{ "filters": { "doi": "10.1234/example.2020.001" } }
```

`"10.1234/EXAMPLE.2020.001"`, `"https://doi.org/10.1234/example.2020.001"` and `"doi:10.1234/example.2020.001"` are equivalent.

When a search that pins one or more DOIs returns nothing, `filterWarnings` can carry a `doi_not_in_collection` entry for each pinned DOI your key cannot reach in the collection, so you can tell a missing paper from a filter that is too narrow. A retracted paper counts as not available. The check is best effort: it covers up to ten DOIs and is left out if it cannot finish in time, so a DOI without a warning is not proof the collection holds it. A paper that is available but excluded by another condition, such as `section`, is not reported missing.

Exclusion uses the operators you already have: `ne`, `not_in` and `not`. There is no separate exclusion syntax. Documents carrying no ISSN at all are not removed by an `issn` exclusion.

```json
{
  "filters": {
    "and": [
      { "field": "issn", "not_in": ["1234-5679", "2345-6787"] }
    ]
  }
}
```

## Journal metrics

Filter by a journal-level citation metric. These fields accept **range operators only** (`gt`, `gte`, `lt`, `lte`, `between`) and are resolved server-side into the matching ISSNs, so they cost nothing extra at search time.

Available: `journal_metric.2yr_mean_citedness`, `journal_metric.h_index`, `journal_metric.i10_index`, `journal_metric.impact_factor`.

**`2yr_mean_citedness` is not an impact factor.** The value is OpenAlex's 2-year mean citedness, published under CC0. The Journal Impact Factor is Clarivate's proprietary metric, computed over the Web of Science corpus with a different citation window; the two are not interchangeable and the numbers will not agree.

OpenAlex scores a journal with no citations in the window `0`, so `lt` and `lte` thresholds include such journals. A `0` can also mean OpenAlex indexes few or no works for that journal, so it is not evidence of coverage. To leave out high-impact journals, use `not` with `gte` (below). A `not` condition also keeps documents with no ISSN or no metric.

`impact_factor` is an impact factor computed by Redpine from OpenAlex citation data with the JIF formula. It is not Clarivate's Journal Impact Factor. The 2025 value is the citations received in 2025 by everything a journal published in 2023 and 2024, divided by the articles and reviews it published in those two years. Journals that publish many news items, letters and editorials (for example BMJ, JAMA, The Lancet) score far below their JCR figure, because OpenAlex classifies those items as articles.

A journal with fewer than 20 articles and reviews in those two years, or where they are under half of what it published, has no `impact_factor` rather than `0`: no threshold matches it, and a `not` keeps it.

When a condition matches no journal, the response carries a `filterWarnings` entry with code `journal_metric_matched_nothing`.

```json
{ "filters": { "journal_metric.2yr_mean_citedness": { "gte": 5.0 } } }
```

```json
{ "filters": { "journal_metric.impact_factor": { "gte": 3 } } }
```

Exclude by metric:

```json
{
  "filters": {
    "not": [
      { "field": "journal_metric.2yr_mean_citedness", "gte": 20 }
    ]
  }
}
```

The response reports what each threshold expanded to, so the selection is inspectable. The expansion is capped at 35,000 ISSNs per condition and 70,000 values per request, counting every metric expansion plus your own `in` and `not_in` lists. A filter over either limit returns `422 INVALID_FILTER` naming the limit, rather than a truncated list, because a partial list would return confidently wrong results.

```json
{
  "results": [ ... ],
  "journalMetricExpansions": [
    {
      "field": "journal_metric.2yr_mean_citedness",
      "condition": "gte 5.0",
      "matchedJournals": 2883,
      "issnCount": 4949,
      "sampleIssns": ["0028-0836", "1476-4687", "0027-8424"]
    }
  ]
}
```

### Metric bounds

A threshold with only one end has to stay inside these bounds. They are checked before anything is searched, and they leave room for the data to grow, so a threshold that works today keeps working when the metrics are refreshed.

| Field                               | Lower bound alone: lowest `gte` | Upper bound alone: highest `lte` |
| ----------------------------------- | ------------------------------- | -------------------------------- |
| `journal_metric.2yr_mean_citedness` | 1.1                             | 0                                |
| `journal_metric.h_index`            | 35                              | 3                                |
| `journal_metric.i10_index`          | 160                             | none: add a lower end            |
| `journal_metric.impact_factor`      | 0.9                             | 0.2                              |

`gt` and `lt` are held to the same bounds by what they select. `h_index` and `i10_index` are whole numbers, so `"gt": 34` on `h_index` selects what `"gte": 35` does and is accepted, and `"lt": 4` selects what `"lte": 3` does. On citedness, `"gt": 1.1` is accepted and `"gt": 1.0` is not; on impact factor, `"gt": 0.9` is accepted and `"gt": 0.8` is not.

A range with both ends (`between`, or `gte` with `lte`) is accepted whenever it matches at most 35,000 ISSNs, so a narrow band such as `"between": [0.5, 0.9]` works. The bounds come from the current OpenAlex snapshot (2026-09-23) and can move when it is refreshed; this table moves with them.

Two metric conditions near their bounds use most of a request's 70,000 values, so a third, or a long `in` or `not_in` list beside them, can be refused. That 422 names the counts; raise a threshold to make room. A filter can also hold at most 8 `journal_metric` conditions; put both bounds for one metric in a single condition.

Outside the bounds, the `422 INVALID_FILTER` names the field, the value you sent and the bound, and carries the bound in `details.min_gte` and `details.max_lte`. To fix it:

* A `gte` or `gt` below the minimum: raise it to the minimum or above, or add an upper end to make a band.
* An `lt` or `lte` on its own above the maximum: to filter below a value X, wrap the opposite range in `not`. X must be at or above the metric's lowest `gte` in the table. `"lt": 2` on citedness would match about 126,000 ISSNs; `{"not": [{"field": "journal_metric.2yr_mean_citedness", "gte": 2}]}` sends about 19,000 and keeps the same journals. A `not` also keeps documents with no ISSN or no metric. Below the lowest `gte` (citedness `"lt": 0.5`, say) no one-sided filter is that narrow: the error suggests the narrowest `not` allowed and says it is broader than what you sent.
* A band over 35,000 ISSNs: narrow it.

## Filtering on other fields

Indexed on every collection: `journal`, `publisher`, `keywords`, `publication_date`, `doi`, `issn`, `article_type`, `section`, `isbn`, `open_access`, `chapter_number`, `chapter_title` and `chapter_authors`.

Additionally indexed on editorial collections only: `topic`, `url`, `medical_board_approved`, `last_updated_date`, and the clinical-trial fields `nct_id`, `overall_status`, `phases` and `has_results`. A meta-collection skips a member that has no index for one of these fields instead of failing; a collection you name directly that lacks the index still rejects the filter.

### Open access

`open_access` is a boolean on each result. Where a collection holds only open-access content, its articles do not carry the field; the collection answers the filter instead:

* `open_access: true` matches everything in that collection, so its results are returned alongside per-article matches from other collections
* `open_access: false`, or `open_access` under `or` / `not`, excludes that collection and the response says so with a `filterWarnings` entry, code `open_access_collection_excluded`

Results from such a collection carry `open_access: true` in their metadata.

`license` (the Creative Commons licence id, where the publisher declares one) is returned in result metadata but is not a filter: sending it is rejected with `INVALID_FILTER`. Filter on `open_access` instead.

Other fields returned in result metadata, such as `pmid`, `title` or `authors`, have no index. Collections that support scan matching still apply them, but slowly, and large collections can time out; the response carries a warning. Collections that do not support scan matching reject them with `422 INVALID_FILTER`. The warning looks like this:

```json
{
  "results": [ ... ],
  "filterWarnings": [
    {
      "code": "unindexed_filter_field",
      "field": "authors",
      "message": "Filter field 'authors' has no payload index, so it is matched by scanning and may be slow or time out on large collections. Indexed fields: article_type, chapter_authors, chapter_number, chapter_title, doi, isbn, issn, journal, keywords, last_updated_date, medical_board_approved, open_access, publication_date, publisher, section, topic, url."
    }
  ]
}
```

## Rejected filters

A filter that cannot be applied exactly is rejected before the search runs, with `422 INVALID_FILTER` naming the field. Nothing is charged, and retrying the same filter fails the same way. Rejected:

* A field that is not a result metadata field at all, such as a typo
* `doc_id`, which is never returned on results: filter on `doi` to select one document
* A `journal_metric.*` condition naming an unknown metric, using a non-range operator, outside the [metric bounds](#metric-bounds) or matching more than 35,000 ISSNs, a request whose filter values exceed 70,000 in total, or a filter with more than 8 `journal_metric` conditions (see [Journal metrics](#journal-metrics))
* A field the collection has no index for, where that collection cannot match it by scanning. The message names the field and the collection; on a multi-collection search each collection is checked
* An unknown operator, a condition mixing operator families, or the two formats mixed
* An `article_type` outside `research-article`, `review`, `case-report`, `editorial`, `letter`, `correction`, `retraction`, `brief-report`, `protocol`, `commentary`, `book`, `book-chapter`, `preprint`, `monograph`, `dataset`, `trial-registration` and `other`. Common synonyms such as `review-article` are accepted
* A date that is not a real calendar date, or not in an accepted form (see [Date filtering](#date-filtering))

```json
{
  "error": {
    "code": "INVALID_FILTER",
    "message": "'journall' is not a filterable field. Indexed fields: article_type, chapter_authors, chapter_number, chapter_title, doi, isbn, issn, journal, keywords, last_updated_date, medical_board_approved, open_access, publication_date, publisher, section, topic, url.",
    "details": {
      "filter": { "journall": "Nature" },
      "field": "journall"
    },
    "requestId": "req_a1b2c3d4"
  }
}
```

A search that runs echoes the filters it applied as `filtersApplied`, on `POST /api/v1/search/query` and `POST /api/v1/search/preview`, and on the MCP `search` and `preview` tools. It is omitted when no filters were sent.

## Date filtering

ISO date strings (`YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS`) are automatically detected and used for datetime range queries. No special syntax is needed: just pass the date string as a value.

`publication_date` also accepts a year (`YYYY`) or a year and month (`YYYY-MM`). `last_updated_date` takes a full date.

### Date range with gte/lte

```json
{
  "filters": {
    "and": [
      { "field": "publication_date", "gte": "2024-01-01" },
      { "field": "publication_date", "lte": "2024-12-31" }
    ]
  }
}
```

### Date range with between

```json
{
  "filters": {
    "field": "publication_date",
    "between": ["2024-01-01", "2024-12-31"]
  }
}
```

***

The `filters` parameter is part of the Search API request body. See [Search](/docs/search) for the full endpoint reference.