AI Keyword Clustering for SEO Agencies: Validate Clusters Before Sign-Off

Coding Liquids tutorial cover featuring Sagnik Bhattacharya for AI Keyword Clustering for SEO Agencies: Validate Clusters Before Sign-Off.
Coding Liquids tutorial cover featuring Sagnik Bhattacharya for AI Keyword Clustering for SEO Agencies: Validate Clusters Before Sign-Off.

Export and clean the list, cluster it with embeddings or a prompt, then test the clusters against live search results before anyone signs off. Pull the top results for about eight keywords per cluster, flag members that share too little with their head keyword and heads that overlap heavily, fix those, and agree the cluster map with the client.

Clustering costs almost nothing now: embedding 10,000 keywords is a fraction of a cent. What costs money is a wrong cluster, because each one becomes a page, a brief and a promise to a client. Meaning-based methods group by what words mean, while Google groups by what it ranks, and the two disagree most on intent.

Follow me on Instagram@sagnikteaches

Three ways to cluster, and what each one can prove

The methods differ less in price than in the evidence they give you. SERP overlap groups two keywords when their top results share enough URLs, so it reflects what Google ranks today. Embeddings turn each keyword into a list of numbers and group the nearest neighbours, which reflects meaning. Prompt clustering asks a language model to sort keywords into topics, which reflects whatever the prompt and the model make of them. Prices below were read from vendor pages in November 2026.

Connect on LinkedInSagnik Bhattacharya
MethodEvidence it givesCost for 10,000 keywordsWeak spot
SERP overlap in a toolStrongest: it mirrors current rankingsKeyword Insights Basic is $58 a month for 10,000 credits, and its page lists clustering at one credit per keyword, with an asterisked condition to read before you budgetYou rely on the vendor's settings and pay again for every re-run
SERP overlap built yourselfStrong, and you control the threshold$6.00 for 10,000 SERPs at $0.0006 each on DataForSEO's standard queue (top 10 results), plus scripting timeNeeds code or a clustering tool to process the results
EmbeddingsMedium: similar meaning, not similar rankingsAbout $0.001: roughly 60,000 tokens at $0.02 per million for OpenAI's small embedding modelBlind to intent, so it lumps "what is" with "buy"
Prompt clusteringWeak to medium, depending on the promptAbout $0.07 for one pass on OpenAI's smallest current text model ($0.10 input, $0.50 output per million tokens at the standard rate)Labels drift between batches; no search evidence at all

The prompt-clustering figure assumes 50 batches of 200 keywords, about 90,000 input tokens and 120,000 output tokens in total. Even at a hundred times that price per token it would come to roughly $7, so model cost never decides the method. Analyst time does. At an assumed $60 an hour, one hour of review costs more than every API bill in the table combined.

Subscribe on YouTube@sagnikteaches

My default for agency work follows from that: propose clusters with embeddings or a prompt, because both are nearly free and fast, then validate against SERP data on a sample, because that is the only step that brings in what Google actually does. A tool with built-in SERP clustering earns its fee when you cluster many lists a month and the client has no interest in thresholds. Build your own when you need to show a client exactly how a decision was made. Keyword Insights' own documentation says it compares each keyword's top 7 results, grouping keywords that share 40% or more of their URLs by default, a percentage you can change; that is a sensible starting point for any DIY check.

The Complete Excel Course with AI Integration thumbnail

The Complete Excel Course with AI Integration

From formulas and pivot tables to Power Query, DAX, VBA and AI with Copilot, ChatGPT and Claude — hands-on from the first lesson.

By Sagnik Bhattacharya and Virginia Thorn, Primetime Emmy Award winner

Buy now

Prepare the export: the clean-up that decides cluster quality

Most bad clusters are bad inputs. Export from your keyword tool and from Search Console (through its API for larger sites) with at least these columns: keyword, monthly volume, and the client's current ranking URL and position where one exists. Then clean in a fixed order and keep every removed row in a separate sheet, because the client will ask what happened to their favourite term.

  1. Normalise. Lower-case, trim spaces, strip stray punctuation, and drop exact duplicates, keeping the highest volume.
  2. Merge trivial variants. Plurals, hyphen versus space and obvious typos can share a row. Leave anything that changes meaning ("sofa" and "couch" may rank differently) for the SERP check to judge.
  3. Remove brand and navigational terms. The client's own name, competitors and "login" style queries go to a separate list for a decision, not into topic clusters.
  4. Remove off-topic rows. Jobs, second-hand, DIY plans, free downloads: whichever words mean the searcher is not a buyer for this client. Write the rule as a word list so it can be re-applied.
  5. Sort by volume, highest first. The script below treats the first keyword in each cluster as its head, so the biggest term should lead.

Embedding clusters in forty lines

The script below turns each keyword into an embedding with OpenAI's small embedding model, then does leader clustering: walking down the list from highest volume, a keyword joins the most similar existing cluster head if their cosine similarity clears the threshold, and otherwise starts a new cluster. It needs Python, the openai and numpy packages and an API key in your environment. OpenAI's pricing page lists the small embedding model at $0.02 per million tokens, and its documentation says the vectors are normalised to length 1, which is why a plain dot product works as cosine similarity. OpenAI's API does not train on your data by default, though a keyword list is rarely the sensitive part of a client file.

import csv, sys
import numpy as np
from openai import OpenAI

THRESHOLD = 0.70   # cosine similarity needed to join a cluster; tune it on keywords you can judge by eye

def embed(texts, client, batch=500):
    vectors = []
    for i in range(0, len(texts), batch):
        response = client.embeddings.create(model="text-embedding-3-small", input=texts[i:i + batch])
        vectors += [item.embedding for item in response.data]
    v = np.array(vectors, dtype=np.float32)
    return v / np.linalg.norm(v, axis=1, keepdims=True)

def cluster(vectors, threshold):
    heads = np.zeros_like(vectors)           # one row per cluster: its first (highest-volume) keyword
    labels, n = [], 0
    for v in vectors:
        if n:
            similarity = heads[:n] @ v        # cosine similarity, because the vectors are unit length
            best = int(similarity.argmax())
            if similarity[best] >= threshold:
                labels.append(best)
                continue
        heads[n] = v
        labels.append(n)
        n += 1
    return labels

if __name__ == "__main__":
    rows = sorted(csv.DictReader(open(sys.argv[1], encoding="utf-8")), key=lambda r: -float(r["volume"]))
    labels = cluster(embed([r["keyword"] for r in rows], OpenAI()), THRESHOLD)
    head_of = {}
    with open(sys.argv[2], "w", newline="", encoding="utf-8") as f:
        out = csv.writer(f)
        out.writerow(["keyword", "cluster", "volume"])
        for r, label in zip(rows, labels):
            head_of.setdefault(label, r["keyword"])      # name each cluster after its head keyword
            out.writerow([r["keyword"], head_of[label], r["volume"]])
    print(len(rows), "keywords ->", len(head_of), "clusters")

Run it as python embed_cluster.py keywords.csv clusters.csv, where the input has keyword and volume columns. The output names each cluster after its head keyword. Choosing the threshold is the one judgement call. Take 200 keywords you can sort by eye, run the script at 0.60, 0.70 and 0.80, and keep the setting whose clusters you would defend to the client. Too low and unrelated terms merge; too high and the list shatters into hundreds of near-duplicates. The SERP validation later is the real test, and it will tell you which way to move.

The head-keyword design has a useful side effect: every cluster has an obvious target phrase and an obvious comparison point for validation. Its weakness is that a cluster only ever reflects its head. If the head is ambiguous, the whole cluster inherits the ambiguity, which is why the intent step below comes before validation.

Prompt clustering: agree a taxonomy first, then assign

Pasting 10,000 keywords into a chat window and asking for groups fails in a predictable way: each batch invents its own topic names, so "office catering" is a cluster in batch 3 and "workplace lunches" in batch 7. Use two passes instead, so the topic list is fixed before anything is assigned.

PASS 1: Here are 400 randomly chosen keywords for a [client type]. Propose 25 to 40 topics that
would each deserve their own page. For each, give a name of 2 to 5 words and a one-line definition
saying what belongs and what does not. Do not assign keywords yet.

Edit the list yourself: merge near-twins, delete anything the client does not sell, and add the topics the sample missed. Then assign the rest in batches:

PASS 2: Assign each keyword below to exactly one topic from this list, or write NEW: [proposed name]
if none fits. Output CSV only: keyword,topic. Do not invent topics that are not on the list.
TOPICS: [paste edited list with definitions]
KEYWORDS: [200 keywords]

Batches of about 200 keep the output complete and the topics stable. Collect every NEW line, review them once, add the real ones to the list and rerun only the affected batches. If your team lives in Google Sheets, its AI function can do the assigning in cells, but Google's usage-limit page lists it at 5,000 per-cell generations a month on Business Standard and Plus and 25,000 on Enterprise Standard and Plus, with no access on Business Starter. A 10,000-keyword list would not fit the smaller allowance in a month, and every rerun of a batch uses the allowance again. Whatever the interface, remember what this method cannot do: the model has not looked at a single search result.

Label intent before you trust any cluster

Intent is where automatic clustering fails quietly, because meaning-based methods treat "dental crm" and "what is a dental crm" as the same topic and Google does not. Label every keyword first, and cluster within each label.

Label each keyword's search intent as one of: INFO (wants to learn), COMPARE (weighing options:
best, vs, alternatives, reviews), BUY (ready to act: pricing, quote, order), NAV (looking for a
specific brand or site). Output CSV only: keyword,intent. If a keyword could be two, choose the
one a searcher would most likely mean and add a "?" to the label.

The table below lists the five intent pitfalls that most often survive an automatic check, with the signal to look for and the fix:

PitfallExample (illustrative)How it shows upFix
Learning and buying merged"corporate catering" with "what is corporate catering"The informational term has almost no URL overlap with its headCluster within an intent label; give the INFO terms a guide page
Comparison modifiers"office catering" with "best office catering companies"Results flip from service pages to list articlesTreat best, vs, alternatives and reviews as COMPARE and cluster apart
Template and tool searches"catering menu" with "catering menu template"The second wants downloads, not a serviceSplit by what the searcher wants to leave with
Brand terms inside topicsA competitor's name attached to a generic phraseThe cluster is dominated by that brand's own pagesMove to a brand and competitor list for a client decision
Words the audience treats differently"courier" with "freight forwarder"Similar meaning, separate result setsLet the SERP check decide; do not merge on meaning alone

An intent label is also what makes the next step honest. When a cluster mixes labels, the validation script will usually flag it, but the cheaper fix is to avoid creating the mixed cluster in the first place.

The SERP-overlap validation sample

This is the step the tool pages skip, and the one that lets you say "checked" to a client with a straight face. You are not re-clustering anything. You are testing, on a sample, whether the keywords you grouped really are served by the same pages on Google.

What to sample. For each cluster, take the head keyword plus up to seven members: the four highest-volume members and three chosen at random, so a cluster cannot hide a bad member behind its popular ones. Give the 20 biggest clusters by volume up to 12 members each, because an error there costs the most. How to get the results. Pull the top results for each sampled keyword from a SERP API or your rank-tracking tool. On DataForSEO's standard queue that is $0.0006 per keyword for ten results, so even 2,000 sampled keywords come to about $1.20. Save them as a file with keyword, rank and url columns, and keep the clusters file from the earlier step.

import csv, itertools, sys
from collections import defaultdict

TOP_N = 7          # how many results per keyword to compare
MIN_WITHIN = 0.4   # a member should share at least this fraction of the top results with its head
MAX_ACROSS = 0.7   # two heads sharing this fraction or more suggest the clusters should merge

def load(clusters_csv, serps_csv):
    clusters = defaultdict(list)
    for r in csv.DictReader(open(clusters_csv, encoding="utf-8")):
        clusters[r["cluster"]].append((float(r["volume"]), r["keyword"]))
    serp = defaultdict(set)
    for r in csv.DictReader(open(serps_csv, encoding="utf-8")):
        if int(r["rank"]) <= TOP_N:
            serp[r["keyword"]].add(r["url"].split("#")[0].split("?")[0].rstrip("/"))
    return clusters, serp

def validate(clusters, serp):
    heads, flags = {}, []
    for name, members in clusters.items():
        have = [k for _, k in sorted(members, reverse=True) if k in serp]
        if len(have) < 2:
            continue
        head = have[0]
        heads[name] = head
        weak = [(k, len(serp[k] & serp[head])) for k in have[1:]]
        weak = [(k, s) for k, s in weak if s / TOP_N < MIN_WITHIN]
        if weak:
            listed = ", ".join(f"{k} ({s} of {TOP_N})" for k, s in weak)
            flags.append(f'SPLIT? "{name}": {len(weak)} of {len(have) - 1} sampled members share too little with the head: {listed}')
    for (a, ha), (b, hb) in itertools.combinations(heads.items(), 2):
        shared = len(serp[ha] & serp[hb])
        if shared / TOP_N >= MAX_ACROSS:
            flags.append(f'MERGE? "{a}" + "{b}": heads share {shared} of {TOP_N} URLs')
    return len(heads), flags

if __name__ == "__main__":
    checked, flags = validate(*load(sys.argv[1], sys.argv[2]))
    print(f"{checked} clusters checked, {len(flags)} flags")
    print("\n".join(flags))
    sys.exit(1 if flags else 0)

The script compares the top 7 results, following the convention in Keyword Insights' documentation, and uses its 40% overlap default as the minimum for a member to stay with its head. Both numbers are settings: change them, then judge by the clusters you already trust. Run on a small demo for a catering client and a courier client, it prints this (illustrative):

python validate_clusters.py clusters.csv serps.csv
3 clusters checked, 2 flags
SPLIT? "corporate catering": 1 of 4 sampled members share too little with the head: what is corporate catering (0 of 7)
MERGE? "same day courier" + "urgent courier service": heads share 5 of 7 URLs

Read each flag as a question rather than a verdict. The first is the classic intent problem: the informational term shares no results with the service term, so it moves into a guide cluster. The second says two clusters are one topic to Google. Merge them unless the client has a business reason to keep two pages, in which case write the reason into the cluster map so nobody "fixes" it later.

Agree the pass rule before you run the check, not after. A reasonable starting rule: no more than 5% of clusters carry a SPLIT flag, every MERGE flag is either merged or documented as an exception, and the 20 biggest clusters are clean. If a second run still fails, change the clustering threshold or the intent labels, never the validation threshold, because moving the goalposts defeats the check.

Two limits are worth stating to the client. A sample proves nothing about the keywords you did not pull, so it shows the method works rather than that every row is right. And SERPs move: a cluster that passes today can drift as Google reshuffles results, which is why the map carries a re-check date. If your agency also drafts the briefs that follow, the tutorial on ChatGPT as a quality gate for keyword clusters and briefs picks up where this check ends.

10,000 keywords for a small manufacturer's online shop: the numbers

An agency takes a 10,000-row export for a small manufacturer that sells made-to-order steel storage racks online. All figures are illustrative.

StageResultTimeCost
Normalise and de-duplicate10,000 rows become 9,38040 minNone
Remove brand, competitor and navigational rows410 removed, 8,970 left20 minNone
Remove off-topic rows560 removed (jobs, second-hand, DIY plans), 8,410 left30 minNone
Embed and cluster at a 0.70 threshold264 clusters25 minAbout $0.001 for roughly 50,000 tokens
Intent labels and cluster namesEvery keyword labelled; each cluster named15 minAbout $0.05
SERP pull for the sample2,077 keywords: the 20 biggest clusters at 12 each, 203 clusters at 8, and 41 smaller clusters with 213 keywords between them20 min$1.25
First validation run38 SPLIT flags (14% of clusters), 12 MERGE flags10 minNone
Fixes146 keywords moved, 9 merges, 6 clusters split into 14120 minNone
Second validation run11 SPLIT flags (4%), 1 MERGE flag kept as a documented exception; 263 clusters30 minAbout $0.40 for the re-pulled sample
Cluster map and sign-off packOne sheet, one summary page60 minNone
Client callPriorities and exclusions agreed45 minNone

The first run's 14% SPLIT rate would have failed the 5% pass rule, and the second run's 4% passes it. The clusters had been built in 25 minutes, and the pass came from the 120 minutes of fixes that followed the sample. Total analyst time was 415 minutes, just under seven hours, and total data and model cost was about $1.70. At an assumed $60 an hour the analyst's time is roughly $415, which is why the tool route can still win: Keyword Insights' $58 month covers the clustering step for a list this size and may well save more than $58 of analyst time. What the DIY route buys is a defensible record. Every flag, move and merge is in a file the client can read.

The count after fixes, 263 clusters, is not a to-do list of 263 pages. Clustering describes demand, and the sign-off meeting decides which demand the client will serve. The tutorial on scaled content abuse and AI content checks for SEO agencies explains why turning every cluster into a page is a risk of its own, not a service.

Mapping clusters to the pages that already rank

Before the sign-off, join the cleaned clusters to what the client already has. Export query and page pairs from Search Console for the last three months, match each query to its cluster, and count how many different URLs earn impressions for each cluster. Three outcomes cover everything:

  • One URL takes most of the impressions. Map the cluster to it, then decide between keep, expand or rewrite.
  • Two or more URLs split the impressions. This is a cannibalisation candidate. Decide whether to consolidate with a redirect, separate the pages by intent, or keep both with a note explaining why.
  • No URL appears. That is a gap, and the decision is create, defer or exclude.

In the manufacturer's data (illustrative), 151 of the 263 clusters had at least one ranking URL and 112 had none. Within the 151, 31 clusters had two URLs each taking at least 20% of the impressions, an arbitrary line that the agency chose and recorded. Fourteen of those turned out to be healthy intent splits, a category page and a guide serving different searches. Seventeen were true cannibalisation, settled with 9 redirects and 8 rewrites. Of the 112 clusters with no URL, 40 became new pages and 72 were deferred or excluded, which is the conversation the sign-off meeting exists for.

The client sign-off pack: what they approve and what they do not

The client does not sign off an algorithm. They sign off a map, an exclusion list and a set of decisions. Build the pack as one sheet with one row per cluster, plus a one-page summary. This is a filled-in extract (illustrative):

Cluster (head keyword)IntentKeywordsMonthly volumeTarget pageDecisionValidation
steel storage racksBUY41218,300Existing category pageKeep and expandPassed
heavy duty shelvingBUY1879,800New category pageCreatePassed after 6 keywords moved
what is pallet rackingINFO642,900New guideCreatePassed
racking for garagesBUY913,100Existing thin pageRewriteMERGE flag with garage shelving, kept separate by decision
racking jobsNAV381,200NoneExcludedNot applicable

The summary page gives five numbers: keywords after cleaning, clusters, share of volume in clusters the client will act on, validation pass rate, and the date of the next re-check. A short sign-off checklist covers the decisions a client has to own:

  1. Priorities: which clusters matter commercially, in the client's own order, regardless of search volume.
  2. Exclusions: the word list and the removed rows, so nothing vanished unseen.
  3. Cannibalisation: where two existing URLs already rank for one cluster, whether to consolidate or keep both.
  4. Intent mapping: which page type serves each intent, so guides do not compete with category pages.
  5. Brand and competitor terms: the policy for them, since clustering cannot decide it.
  6. Known weak spots: clusters that failed validation and what the agency will do about them.
  7. Re-check trigger: a date, or an event such as a core update, after which the sample is re-pulled.
Subject: Keyword map for review: sign-off by [date]

Hi [first name],

The map is attached: [number] clusters from [number] cleaned keywords, with a target page and a
recommended decision for each. Please check three things: the priority order, the excluded terms,
and the five clusters marked "kept separate by decision". We tested a sample of every cluster
against live search results and [number]% passed; the rest are listed with what we changed.

Reply "approved" to confirm, or send changes in the sheet's comments by [date]. After that we will
treat the map as version 1 and log any later change.

[your name]

Keep a version number and a change log on the first tab. When a client asks six months later why a term lives where it does, the answer is in the file. For what to do with the signed-off map each month, see the tutorial on drafting monthly client reports with ChatGPT or Claude, and for the step after approval, the content workflow from brief to approved draft. The earlier stage, finding the keywords at all, is covered in keyword research with AI for a small business website.

Keyword clustering questions agencies ask

Is SERP-based clustering always better than embeddings?

It is better evidence, because it reflects what Google ranks today, but embeddings are far cheaper and faster for a first pass. A practical split is to propose clusters with embeddings or a prompt, then validate a sample against live results. Use a SERP-based tool when you cluster many lists a month and the client does not need to see how each decision was made.

How many keywords per cluster should I validate?

Eight is a workable sample: the head keyword, the four highest-volume members and three chosen at random. Give your biggest clusters more, perhaps twelve, since mistakes there cost the most. Smaller clusters can be checked in full. The aim is to catch mixed intent and merge candidates, not to prove every row correct.

What overlap threshold should I use between keywords?

There is no standard. Keyword Insights' documentation describes comparing each keyword's top 7 results and grouping keywords that share 40% or more of their URLs by default, and its API takes a threshold from 1 to 7 shared URLs. Treat any figure as a starting point, test it on clusters you already trust, and write the chosen setting into the sign-off pack.

Can ChatGPT or Claude cluster keywords directly?

They can group keywords by meaning, but the labels drift between batches and the model has not seen any search results. Agree a topic list first, then assign keywords in batches of about 200 against that list, and review every NEW topic once. Follow it with a SERP check on a sample before the clusters become pages.

Should every cluster become a page?

No. Clusters describe demand, and the client decides which demand to serve. Some clusters belong on an existing page, some are excluded, and some need a guide rather than a category page. Publishing one page per cluster at scale also creates its own quality risk. Agree priorities and exclusions at sign-off first.

Further reads

Sources: Keyword Insights pricing page, clustering documentation and API reference; DataForSEO Google organic SERP API pricing; OpenAI API pricing, model pages, embeddings guide and data-controls page; Google Workspace AI usage limits page (all read November 2026).

Want a cluster check your clients can trust?

On a 1:1 call we'll look at how you build keyword maps now, add a validation step and a sign-off pack to the routine, and decide which parts AI can run without extra risk.

Book a 1:1 call with me