Python can cluster keywords by shared words, character patterns, vector similarity or overlapping search-result URLs. Choose the representation before choosing the algorithm: wording-based groups help organize an export, while a page assignment also needs evidence about the reader's task and the actual search results.
These five approaches use the same CSV and preserve every original row, including repeated keywords. Four examples were executed on the illustrative dataset below. The embedding example was checked against documented APIs but could not be executed because a local application-control policy blocked a dependency; it has no reported model result. The examples demonstrate grouping behavior, not search demand, SEO accuracy or processing-speed benchmarks.
Set up one input and a lossless output
Start with a small, single-language sample. The examples use Python 3.10.5, NumPy 2.2.6, SciPy 1.15.3 and scikit-learn 1.7.2. These are the checked versions, rather than a claim to use the newest release. Keep the work in its own folder and install the numerical dependencies there:
python -m venv .venv
.venv\Scripts\python -m pip install numpy==2.2.6 scipy==1.15.3 scikit-learn==1.7.2
On macOS or Linux, use .venv/bin/python in place of .venv\Scripts\python. The token and SERP examples use only Python's standard library. The embedding example adds a model dependency in its own section.
Save this as keywords.csv in the working folder:
row_id,keyword
Q001,bike repair near me
Q002,bicycle repair nearby
Q003,how to repair bicycle tyre
Q004,how to fix a flat bike tire
Q005,buy road bike
Q006,road bicycles for sale
Q007,bicycle size chart
Q008,bike sizing guide
Q009,bike repair near me
Q010,bicycle tyre pressure
Q011,bike tire presure
Q012,grinder
The 12 rows are authored teaching examples, with 11 distinct keyword strings. Q001 and Q009 deliberately repeat a query while retaining different IDs. The misspelling in Q011 and the ambiguous Q012 are deliberate too. These rows contain no measured volume or ranking data.
Choose a method by the job it can actually do:
| Approach | Useful starting point | Main limitation |
|---|---|---|
| Token Jaccard | Conservative groups with shared words | Misses paraphrases; word overlap can hide different tasks |
| Word TF-IDF + K-means | Broad topic buckets when you choose a group count | Assigns outliers as well as good matches |
| Character n-grams + DBSCAN | Candidate spelling and wording variants | Needs calibration; leaves noise and can form chains |
| Embeddings + complete linkage | Candidate paraphrases with little word overlap | Model-dependent meaning similarity is not verified search intent |
| SERP-overlap graph | Relationships in an existing search-result snapshot | Connected components can bridge weakly related queries |
Save the following as common.py. Every method imports it. It rejects ambiguous headers, malformed rows, duplicate IDs and blank queries, then checks the saved output against the original fields. Normalization is confined to working text; the original keyword remains intact.
import csv
import re
import unicodedata
from collections import Counter
def load_rows(path="keywords.csv"):
with open(path, encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
fields = reader.fieldnames or []
if not fields or len(set(fields)) != len(fields) or any(not k for k in fields):
raise ValueError("Headers must be nonblank and unique")
if not {"row_id", "keyword"} <= set(fields):
raise ValueError("Expected row_id and keyword columns")
rows = list(reader)
if not rows or any(None in r or any(v is None for v in r.values()) for r in rows):
raise ValueError("Empty input or malformed CSV row")
ids = [r["row_id"] for r in rows]
if any(not x.strip() for x in ids) or len(set(ids)) != len(ids):
raise ValueError("row_id must be nonblank and unique")
if any(not r["keyword"].strip() for r in rows):
raise ValueError("Blank keyword; fix the input explicitly")
return rows
def work_text(text):
return " ".join(unicodedata.normalize("NFKC", text).casefold().split())
def save_groups(path, rows, labels):
if len(labels) != len(rows):
raise ValueError("Every input row needs an output label")
counts = Counter(labels)
fields = list(rows[0]) + ["cluster_id", "review_status"]
if len(set(fields)) != len(fields):
raise ValueError("Input already contains output columns")
with open(path, "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fields)
writer.writeheader()
for row, label in zip(rows, labels):
status = ("missing_serp" if label is None else
"noise" if label == -1 else
"singleton" if counts[label] == 1 else "proposed")
writer.writerow({**row,
"cluster_id": "" if label is None or label == -1 else f"C{label}",
"review_status": status})
with open(path, encoding="utf-8", newline="") as f:
output = list(csv.DictReader(f))
if [r["row_id"] for r in output] != [r["row_id"] for r in rows]:
raise ValueError("Output IDs or order changed")
if any(any(out[k] != row[k] for k in row) for out, row in zip(output, rows)):
raise ValueError("Original fields changed")
Save each method below as its named file beside common.py, and run it from that folder, for example .venv\Scripts\python jaccard_groups.py. The SERP script also imports jaccard_groups.py. Each method writes a different CSV, so the results remain separate.
Repeated observations can affect cluster density and centroids. Decide whether to fit on unique terms in your real project, then join assignments back to the original IDs; do not silently discard provenance. This example retains repeats deliberately. If you prefer reviewing a prompted grouping workflow, the ChatGPT keyword clustering guide covers that alternative.
1. Token Jaccard for strict lexical groups
Use token overlap when shared wording matters more than discovering synonyms. Jaccard similarity is the number of shared tokens divided by the number of distinct tokens across both queries. Repeated words count once because each query becomes a set.
Save this as jaccard_groups.py:
from common import load_rows, save_groups, work_text
import re
def jaccard(a, b):
return len(a & b) / len(a | b) if a | b else 0.0
def strict_token_groups(rows, threshold=0.5):
tokens = [set(re.findall(r"(?u)\b\w+\b", work_text(r["keyword"])))
for r in rows]
groups, labels = [], []
for i, current in enumerate(tokens):
for label, members in enumerate(groups):
if all(jaccard(current, tokens[j]) >= threshold for j in members):
members.append(i)
labels.append(label)
break
else:
labels.append(len(groups))
groups.append([i])
return labels
if __name__ == "__main__":
rows = load_rows()
save_groups("token_groups.csv", rows, strict_token_groups(rows))
The example threshold is 0.5. A query joins a group only when it meets that threshold against every existing member, rather than just one representative. That prevents an accepted pair from pulling in a third query that fails the rule against another member.
This is a greedy, order-dependent grouping procedure, not a globally optimal clustering solution. Keep input order fixed when comparing runs. Changing the order or the threshold can change the groups.
In the supplied sample, only Q001 and Q009 form a multi-row group at this setting; the other ten rows remain singletons. That is an execution result for this small dataset, not a recommended threshold. The method does not know that “bike” and “bicycle” can refer to the same thing.
It also cannot reliably distinguish tasks that share most of their words. Keep meaningful modifiers and negations during review. The tokenizer reduces punctuation to word boundaries, so terms where punctuation carries meaning need a different feature rule. Use the output as a conservative lexical baseline, then inspect the searches before assigning a page.
2. Word TF-IDF and K-means for broad buckets
Word TF-IDF represents each query using weighted words and word sequences. Terms that occur throughout the dataset carry less distinguishing weight than terms concentrated in fewer rows. It is useful for organizing broad topic buckets, particularly when you already have a reason to choose the number of groups.
This example uses unigrams and bigrams with TfidfVectorizer. Save it as word_kmeans.py:
from common import load_rows, save_groups, work_text
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.cluster import KMeans
rows = load_rows()
texts = [work_text(r["keyword"]) for r in rows]
X = TfidfVectorizer(ngram_range=(1, 2)).fit_transform(texts)
if (X.getnnz(axis=1) == 0).any():
raise ValueError("A query has no usable word features")
k = 4 # Illustrative broad-bucket count, not an SEO optimum.
if not 1 <= k <= len(set(texts)):
raise ValueError("k exceeds the number of distinct working texts")
labels = KMeans(n_clusters=k, random_state=42, n_init=10).fit_predict(X)
save_groups("word_groups.csv", rows, labels.tolist())
K-means requires n_clusters. Here, k=4 is an illustrative choice, while random_state and an explicit n_init make the run's settings reproducible. They do not establish the correct number of page targets. Cluster IDs can still change when the dataset, settings or library version changes.
Every valid row receives a cluster assignment, including poor matches. In the observed sample, grinder landed in a bucket with Q004 and Q011, which concern bike repairs and tire wording. That is a reason to reject the bucket as a page plan, even though the code completed successfully.
Inspect group members rather than judging the output by its clean four-bucket shape. Consider several plausible values of k on a representative sample and review whether the resulting tasks are coherent. A mathematical score can support that investigation, but it cannot certify SEO intent.
TF-IDF also depends on the vocabulary and tokenization of this particular input. The default word tokenizer excludes single-character tokens and does not preserve every punctuation distinction. The guard stops if a query has no usable features; do not silently delete that row to make the script run.
3. Character n-grams and DBSCAN for near variants
Character features compare pieces of words rather than whole words. They can help find candidate spelling variations and closely related wording that a word-based representation separates. They still measure surface form, so they can miss paraphrases or connect similar-looking terms with different meanings.
Save this as character_dbscan.py:
from common import load_rows, save_groups, work_text
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.cluster import DBSCAN
rows = load_rows()
X = TfidfVectorizer(analyzer="char_wb", ngram_range=(3, 5)).fit_transform(
[work_text(r["keyword"]) for r in rows])
if (X.getnnz(axis=1) == 0).any():
raise ValueError("A query has no usable character features")
labels = DBSCAN(eps=0.45, min_samples=2, metric="cosine").fit_predict(X)
save_groups("character_groups.csv", rows, labels.tolist())
The char_wb analyzer builds character n-grams within word boundaries, with padding at the edges. Here the features have lengths three through five. DBSCAN groups dense neighborhoods and marks noise with -1, which the shared writer leaves without a cluster ID.
min_samples=2 includes the point itself, so a duplicate pair can form a group at these settings. In this sample, the duplicate query pair was grouped and ten other rows remained noise. The near-spelling representation did not automatically recover every useful variant at eps=0.45. No setting is presented as an SEO default.
Keep noise rows separate for review. They are not one miscellaneous topic and do not all deserve new pages. Increasing eps may connect more neighbors, but the parameter limits neighborhood distance rather than the maximum distance between every pair in the final cluster. Chains can therefore produce broad groups.
Use this approach when you can inspect candidate variants and tolerate unresolved rows. If you need broad topical discovery across different vocabulary, character similarity alone is a poor fit.
4. Embeddings and complete linkage for paraphrases
Embeddings offer a different representation: a model converts each query into a numerical vector. Related wording can then be close even when the queries share few literal tokens. That makes embeddings useful candidates for paraphrase discovery, while buying, repairing and comparing can still be close in the same topic space.
This example uses FastEmbed's documented TextEmbedding interface and the English BGE small model. It requires a model download and permission to run the installed native dependencies. Add the checked package version in the isolated environment:
.venv\Scripts\python -m pip install fastembed==0.9.1
Save this as semantic_groups.py:
from common import load_rows, save_groups
from fastembed import TextEmbedding
from sklearn.cluster import AgglomerativeClustering
import numpy as np
rows = load_rows()
if len(rows) < 2:
raise ValueError("Use at least two queries")
encoder = TextEmbedding(model_name="BAAI/bge-small-en-v1.5",
cache_dir="model-cache")
X = np.asarray(list(encoder.embed([r["keyword"] for r in rows])))
if not np.isfinite(X).all() or (np.linalg.norm(X, axis=1) == 0).any():
raise ValueError("Invalid or zero embedding vector")
labels = AgglomerativeClustering(
n_clusters=None, metric="cosine", linkage="complete",
distance_threshold=0.25).fit_predict(X)
save_groups("semantic_groups.csv", rows, labels.tolist())
The code is documentation-based. Its syntax and API choices were reviewed, but a local application-control policy blocked a dependency import, so model download, inference and semantic cluster output were not executed for this guide. There is no semantic result or timing comparison to report. Use an environment that permits the dependencies; if a policy blocks execution, stop and seek the appropriate approval rather than disabling the control.
Complete linkage uses the largest pairwise distance when considering a merge. It is stricter than accepting a group because its average relationship looks close. With cosine distance, 0.25 corresponds to a cosine similarity of 0.75; neither number means “75% accurate.” This threshold is illustrative and needs calibration on the chosen model and data.
The named model is English-specific and has an input-length limit; check the model card before supplying unusually long inputs. Do not assume its thresholds or behavior transfer to another model or language. Review actual members, especially intent-changing modifiers, and check search results before using semantic groups as page targets. A confident-looking cluster label does not supply that evidence.
5. SERP-overlap graphs for search-result relationships
Use this approach when you already have a search-result snapshot for the relevant queries. Compare organic result URLs, rather than just their domains, using the same country, language, device, collection window and location where relevant. Record the actual collection time and source with real data. Missing results must remain missing.
The code below does not fetch Google or call a paid API. Save this deliberately synthetic example as serps.json beside keywords.csv:
{
"source": "illustrative, not a Google observation",
"snapshot_id": "toy-us-en-desktop",
"country": "US",
"language": "en",
"device": "desktop",
"top_k": 3,
"collected_at": null,
"results": {
"Q001": ["https://example.test/a","https://example.test/b","https://example.test/c"],
"Q002": ["https://example.test/a","https://example.test/b","https://example.test/d"],
"Q003": ["https://example.test/b","https://example.test/d","https://example.test/e"],
"Q004": ["https://example.test/f","https://example.test/g","https://example.test/h"],
"Q005": ["https://example.test/i","https://example.test/j","https://example.test/k"],
"Q006": ["https://example.test/i","https://example.test/j","https://example.test/l"],
"Q007": ["https://example.test/m","https://example.test/n","https://example.test/o"],
"Q008": ["https://example.test/m","https://example.test/n","https://example.test/p"],
"Q009": ["https://example.test/a","https://example.test/b","https://example.test/c"],
"Q010": ["https://example.test/q","https://example.test/r","https://example.test/s"],
"Q011": ["https://example.test/q","https://example.test/r","https://example.test/t"],
"Q012": null
}
}
These example.test URLs are fictional. Each available toy result set has three URLs, and collected_at is null because no Google results were collected. Replace the fixture with authorized, consistently scoped observations before making a real search-intent decision. A metadata label alone cannot prove that the supplied results have that scope.
Save this as serp_graph.py. Keep jaccard_groups.py from method 1 in the same folder:
import json
from urllib.parse import urldefrag
from common import load_rows, save_groups
from jaccard_groups import jaccard
def serp_components(rows, snapshot, threshold=0.5):
required = ("snapshot_id", "country", "language", "device", "top_k", "results")
if any(k not in snapshot for k in required):
raise ValueError("Missing snapshot context")
sets = []
for row in rows:
urls = snapshot["results"].get(row["row_id"])
if urls is not None and not isinstance(urls, list):
raise ValueError("Expected a URL list or null")
sets.append({urldefrag(u)[0] for u in urls} if urls else set())
parent = list(range(len(rows)))
def root(i):
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
for i in range(len(rows)):
for j in range(i):
if sets[i] and sets[j] and jaccard(sets[i], sets[j]) >= threshold:
parent[root(i)] = root(j)
return [root(i) if sets[i] else None for i in range(len(rows))]
if __name__ == "__main__":
rows = load_rows()
with open("serps.json", encoding="utf-8") as f:
snapshot = json.load(f)
save_groups("serp_groups.csv", rows, serp_components(rows, snapshot))
An edge connects two queries when their URL-set Jaccard similarity reaches the example threshold of 0.5. The denominator is the union of URLs, not the configured number of search results. Removing a fragment is the only URL adjustment here; path case and query parameters are preserved. This is not a general canonical-URL resolver.
The important failure mode is transitivity. In the fixture:
| Pair | Shared URLs / union | Jaccard similarity |
|---|---|---|
| Q001 and Q002 | 2 / 4 | 0.5 |
| Q002 and Q003 | 2 / 4 | 0.5 |
| Q001 and Q003 | 1 / 5 | 0.2 |
All three enter one connected component, even though the first and third do not meet the edge threshold. Q009, which repeats the first query's result set, joins them too. Calling that component a single approved page cluster would hide the weak pair.
Inspect all-pair relationships or split the component into coherent tasks before mapping URLs. SERP overlap is evidence to interpret with the actual search intent, rather than a universal numerical rule. Q012 stays missing_serp; the script does not substitute a text-based guess for unavailable results.
Review the outputs before mapping pages
The executed examples produced the following row states on the supplied 12-row dataset. These are descriptions of the output, not accuracy scores:
| Method | Rows in multi-row groups | Singleton rows | Noise rows | Missing SERP rows |
|---|---|---|---|---|
| Token Jaccard, threshold 0.5 | 2 | 10 | 0 | 0 |
| Word TF-IDF, K-means k=4 | 12 | 0 | 0 | 0 |
| Character DBSCAN, eps=0.45 | 2 | 0 | 10 | 0 |
| Embeddings | Not executed | Not executed | Not executed | Not executed |
| SERP graph, threshold 0.5 | 10 | 1 | 0 | 1 |
More assigned rows did not produce a better page plan: K-means assigned the unrelated grinder query, while the SERP graph joined a weak pair through an intermediate query. Conversely, a noise or singleton row can still be valuable. It simply has not met that method's grouping rule in this dataset.
Before using an export, reopen it and confirm that each original ID appears once with the same keyword and source fields. The shared helper checks this for the files it writes. Keep the input, package versions, method, parameters and any model or SERP snapshot identity alongside the output. Cluster IDs such as C0 belong to a particular run; do not join two different runs on those labels alone.
Start with a representative sample before expanding the workload. The token and graph examples compare pairs, and distance-based methods can require substantial pairwise work or memory. One dense 10,000-by-10,000 float64 array alone holds 800,000,000 bytes, before other arrays and program overhead. This is a size calculation, not a total-RAM requirement or a claim that every method allocates that exact array. DBSCAN's documented worst-case memory behavior is another reason to measure your own workload.
Review groups for mixed tasks, accidental topic bridges, missing evidence and unsupported language or market assumptions. Compare candidate page purposes against published pages, drafts and planned work before deciding to create anything. The keyword mapping templates help record those decisions; a cluster assignment alone does not establish a need for a new page.
Use a method that matches the evidence you have, preserve unresolved rows, and keep the reason behind each page decision with the export.