# 🗂️ Review Theme Clusterer - Themes From Reviews & Tickets (`that_red_bird/review-theme-clusterer`) Actor

⚡ Turn thousands of reviews, support tickets or survey answers into RANKED THEMES, not just a sentiment score. ✅ TF-IDF phrase weighting, blocking-based similarity clustering with no fixed k, top-distinguishing-term labels, volume/share/rating per theme, and rising/falling trend flags over time.

- **URL**: https://apify.com/that\_red\_bird/review-theme-clusterer.md
- **Developed by:** [mohamed alaya](https://apify.com/that_red_bird) (community)
- **Categories:** AI, E-commerce
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Review Theme Clusterer

Turn thousands of reviews, support tickets or survey answers into **ranked themes** — not
just a sentiment score. Everyone ships "68% positive, 32% negative." Almost nobody ships
"battery life complaints are up 40% since March, here are the 12 reviews that say so." This
Actor ships the second thing. Deterministic and lexical — **no LLM, no embeddings**.

### What it actually does

**1. Text prep.** Every review is normalised (lowercase, accents folded, punctuation
stripped) and tokenised. Stopwords are removed with a RAKE-style rule: candidate phrases
(unigrams, bigrams, trigrams) are only built from **runs of consecutive content words**, so
a phrase never starts or ends on filler — "the battery life is" yields `battery`, `life`,
`battery life`, never `the battery`.

**2. TF-IDF weighting.** Term frequency per review, inverse document frequency across the
whole corpus, so "great"/"product"/"would recommend" (in every review) don't drown out
"battery life" or "customer service" (the phrases that actually distinguish reviews from
each other).

**3. Clustering with no fixed k — and NOT O(n²).** This is the moat:

- **Inverted-index blocking.** Each review's own top-K highest-weight terms become bucket
  keys. Only reviews that share a bucket are ever compared — a review about tents is never
  compared against a review about headphones. Oversized buckets (a term so common it stopped
  discriminating) are skipped rather than allowed to reintroduce the O(n²) blowup.
- **Verified cosine similarity.** Candidate pairs from blocking are checked with an exact
  cosine similarity on the full TF-IDF vector — blocking only proposes candidates, it never
  decides a match.
- **Average-link agglomeration.** Verified pairs above the similarity threshold are merged
  strongest-first, but the merge test compares **cluster centroids**, not just the one seed
  pair. This resists the classic single-link "chaining" failure where A~B~C~D drags together
  a cluster where A and D share nothing in common.

**4. Labelling.** Each theme is labelled with its top **distinguishing** terms — ranked by
summed TF-IDF weight in the cluster, not raw word frequency — so a theme reads as
`battery, life, drains, charge, overnight`, not `good, product, would`.

**5. Volume, share, rating, quotes.** Each theme reports how many reviews it covers, what
share of the corpus that is, the average rating of its members (if a rating field exists),
and representative example quotes — the reviews whose own TF-IDF vector is closest to the
theme's centroid.

**6. Trend over time.** If a date field exists, each theme also gets a per-period
(week/month) volume timeline and a `rising` / `falling` / `stable` flag, computed by
comparing the first half of the observed timeline against the second half. This is the
output that actually changes what a team does next — not just "here's a topic" but "here's a
topic that's getting worse."

### Input

Pass `reviews` inline and/or `sourceDatasetIds`. Leave `textField`/`ratingField`/`dateField`
empty and they're inferred from column names:

```json
{
  "reviews": [
    { "text": "Battery life is terrible, drains in a few hours.", "rating": 1, "date": "2026-01-05" },
    { "text": "Customer service was unhelpful and rude.", "rating": 2, "date": "2026-01-12" }
  ],
  "similarityThreshold": 30,
  "minThemeSize": 2
}
```

Thresholds are 0–100 integers (Apify input schemas have no float type).

### Output

`theme` rows — one per cluster at or above `minThemeSize`, with `label`, `terms` (label
terms plus their TF-IDF weight), `volume`, `sharePercent`, `avgRating`, `exampleQuotes`, and
`trend` (null if no date field was found) · one `other` row aggregating reviews whose cluster
fell below `minThemeSize`, so small groups are summarised rather than silently dropped ·
optional `review` rows tagging every input review with its assigned theme
(`includeReviewAssignments`).

### Honest limitations

- **This is lexical clustering, not semantic embeddings.** It groups reviews that share
  distinguishing *wording* ("battery life", "battery drains fast"). It will not reliably
  merge a review that says "the phone dies too quickly" with one that says "poor battery
  life" — there's no shared vocabulary for TF-IDF to weight. A true embedding model catches
  paraphrases; this does not, and does not pretend to.
- **English-oriented stopword list.** Other languages will still cluster (TF-IDF and cosine
  similarity are language-agnostic) but candidate-phrase quality degrades without a matching
  stopword list.
- **Threshold tuning is a real trade-off**, same as any clustering method: raise
  `similarityThreshold` for tighter, more numerous themes; lower it for broader, fewer
  themes, and expect more chaining risk near the low end even with average-link merging.
- **Trend detection is a first-half-vs-second-half comparison**, not a statistical forecast.
  It is a deliberately simple, explainable "is this showing up more lately" signal, not a
  significance test.
- Capped at 200,000 records per run.

# Actor input Schema

## `reviews` (type: `array`):

The reviews/tickets/answers to cluster, as an array of flat objects. Combine freely with sourceDatasetIds.

## `sourceDatasetIds` (type: `array`):

Apify dataset IDs to pull reviews from. Rows are merged with any inline reviews before clustering.

## `textField` (type: `string`):

Field holding the free text to cluster. Leave empty to auto-detect from common names (text, review, comment, feedback, body, description) or, failing that, the longest string field.

## `ratingField` (type: `string`):

Field holding a numeric rating/score. Leave empty to auto-detect (rating, score, stars). Used to compute average rating per theme; omitted entirely if none is found.

## `dateField` (type: `string`):

Field holding the review/ticket date. Leave empty to auto-detect (date, created, timestamp, posted). Enables per-period volume and the rising/falling trend flag; skipped entirely if none is found.

## `ngramSize` (type: `integer`):

Longest candidate phrase to consider, in words. 1 = single words only, 3 = up to trigrams like "battery life drains". Phrases never cross a stopword boundary.

## `minTermDocFrequency` (type: `integer`):

A candidate phrase must appear in at least this many reviews to enter the vocabulary. Higher = less noise from one-off phrasing, but risks losing small real themes. Auto-lowered for small inputs.

## `similarityThreshold` (type: `integer`):

Minimum cosine similarity (on TF-IDF vectors) for two reviews to end up in the same theme, and for two themes to merge. Higher = tighter, more numerous themes; lower = broader, fewer themes. Expressed 0-100; 30 means 0.30.

## `blockingTermsPerReview` (type: `integer`):

How many of a review's own highest-weight terms are used as inverted-index bucket keys during candidate generation. Higher finds more true matches but compares more pairs; this is what keeps clustering off O(n^2).

## `maxBucketSize` (type: `integer`):

Blocking buckets larger than this are skipped, so a near-universal term (e.g. "product" in a product-review corpus) can't reintroduce an O(n^2) comparison.

## `minThemeSize` (type: `integer`):

Clusters smaller than this are not reported as their own theme; they are rolled into a single "other" summary row instead of being silently dropped.

## `labelTermCount` (type: `integer`):

How many top distinguishing terms (by TF-IDF weight, not raw frequency) to include in each theme's label.

## `exampleQuoteCount` (type: `integer`):

How many representative example reviews (closest to the theme's centroid) to include per theme.

## `period` (type: `string`):

Bucket size used for the per-period volume timeline and the rising/falling trend flag, when a date field is available.

## `includeReviewAssignments` (type: `boolean`):

Emit one extra row per input review naming the theme it was assigned to (or "other"). Useful for auditing but roughly doubles output size on large inputs.

## `includeOtherTheme` (type: `boolean`):

Emit one aggregate row summarising reviews that fell into clusters smaller than minThemeSize, instead of silently omitting them from the output entirely.

## Actor input object example

```json
{
  "reviews": [
    {
      "text": "The battery life is terrible, it drains within a few hours.",
      "rating": 1,
      "date": "2026-01-05"
    },
    {
      "text": "Battery life is really bad, doesn't last the whole day.",
      "rating": 2,
      "date": "2026-02-10"
    },
    {
      "text": "Customer service was unhelpful and rude when I called support.",
      "rating": 2,
      "date": "2026-01-12"
    },
    {
      "text": "Support staff were dismissive during my call, never resolved it.",
      "rating": 1,
      "date": "2026-03-02"
    },
    {
      "text": "Great value for the price, very happy with this purchase.",
      "rating": 5,
      "date": "2026-02-20"
    }
  ],
  "ngramSize": 3,
  "minTermDocFrequency": 2,
  "similarityThreshold": 30,
  "blockingTermsPerReview": 8,
  "maxBucketSize": 500,
  "minThemeSize": 2,
  "labelTermCount": 5,
  "exampleQuoteCount": 3,
  "period": "month",
  "includeReviewAssignments": false,
  "includeOtherTheme": true
}
```

# Actor output Schema

## `results` (type: `string`):

No description

## `downloadCsv` (type: `string`):

No description

## `summary` (type: `string`):

No description

## `count` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "reviews": [
        {
            "text": "The battery life is terrible, it drains within a few hours.",
            "rating": 1,
            "date": "2026-01-05"
        },
        {
            "text": "Battery life is really bad, doesn't last the whole day.",
            "rating": 2,
            "date": "2026-02-10"
        },
        {
            "text": "Customer service was unhelpful and rude when I called support.",
            "rating": 2,
            "date": "2026-01-12"
        },
        {
            "text": "Support staff were dismissive during my call, never resolved it.",
            "rating": 1,
            "date": "2026-03-02"
        },
        {
            "text": "Great value for the price, very happy with this purchase.",
            "rating": 5,
            "date": "2026-02-20"
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("that_red_bird/review-theme-clusterer").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "reviews": [
        {
            "text": "The battery life is terrible, it drains within a few hours.",
            "rating": 1,
            "date": "2026-01-05",
        },
        {
            "text": "Battery life is really bad, doesn't last the whole day.",
            "rating": 2,
            "date": "2026-02-10",
        },
        {
            "text": "Customer service was unhelpful and rude when I called support.",
            "rating": 2,
            "date": "2026-01-12",
        },
        {
            "text": "Support staff were dismissive during my call, never resolved it.",
            "rating": 1,
            "date": "2026-03-02",
        },
        {
            "text": "Great value for the price, very happy with this purchase.",
            "rating": 5,
            "date": "2026-02-20",
        },
    ] }

# Run the Actor and wait for it to finish
run = client.actor("that_red_bird/review-theme-clusterer").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "reviews": [
    {
      "text": "The battery life is terrible, it drains within a few hours.",
      "rating": 1,
      "date": "2026-01-05"
    },
    {
      "text": "Battery life is really bad, doesn'\''t last the whole day.",
      "rating": 2,
      "date": "2026-02-10"
    },
    {
      "text": "Customer service was unhelpful and rude when I called support.",
      "rating": 2,
      "date": "2026-01-12"
    },
    {
      "text": "Support staff were dismissive during my call, never resolved it.",
      "rating": 1,
      "date": "2026-03-02"
    },
    {
      "text": "Great value for the price, very happy with this purchase.",
      "rating": 5,
      "date": "2026-02-20"
    }
  ]
}' |
apify call that_red_bird/review-theme-clusterer --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,that_red_bird/review-theme-clusterer"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/PYNMnqIO3oBP51MfF/builds/8v5mGg6KDbVKYz9fB/openapi.json
