Semantic text clustering using sentence embeddings and agglomerative clustering.
Project description
semaclust
semaclust (semantic + clustering) is a small Python library for clustering similar strings using sentence embeddings and agglomerative clustering. It is useful for deduplicating free-text fields, normalizing user-entered values, and collapsing spelling or formatting variants into a canonical form.
Documentation: https://cobanov.github.io/semaclust/
Installation
pip install semaclust
# or
uv add semaclust
Install from source:
pip install git+https://github.com/cobanov/semaclust.git
Quickstart
from semaclust import TextClusterer
texts = [
"New York", "NYC", "new york city",
"Los Angeles", "LA",
"San Francisco", "San Fran", "SF",
]
clusterer = TextClusterer(distance_threshold=1.0)
clusterer.fit(texts)
print(clusterer.n_clusters_)
# 3
print(clusterer.clusters_)
# {0: ['New York', 'NYC', 'new york city'],
# 1: ['Los Angeles', 'LA'],
# 2: ['San Francisco', 'San Fran', 'SF']}
print(clusterer.transform())
# ['NYC', 'NYC', 'NYC', 'LA', 'LA', 'SF', 'SF', 'SF']
fit_transform is the one-call shortcut:
TextClusterer(distance_threshold=1.0).fit_transform(texts)
API at a glance
| Method | Returns | Purpose |
|---|---|---|
fit(texts) |
self |
Cluster and store fitted attributes |
fit_predict(texts) |
ndarray[int] |
Cluster labels per input text |
fit_transform(texts) |
list[str] |
Each text replaced with its representative |
transform(texts=None) |
list[str] |
Replace texts seen at fit time |
get_replacement_map() |
dict[str, str] |
Mapping from original to representative |
result_ |
ClusterResult |
Frozen dataclass with labels, clusters, reps |
Fitted attributes (sklearn convention): labels_, clusters_,
representatives_, n_clusters_, texts_.
Choosing a model and threshold
The default encoder is all-MiniLM-L6-v2 (22M params, 384-dim, ~80 MB). It's fast,
small, and a sensible drop-in, but it isn't the strongest option for every task.
The table below summarizes 9 encoders on three workloads of escalating
difficulty: short text with abbreviations (cities), medium text with synonyms
(job titles), and long sentences with topical themes (customer feedback). See
benchmarks.md for the full methodology, raw thresholds, and
per-test breakdowns.
ARI (Adjusted Rand Index) of 1.0 means an exact match with the ground-truth clustering. Bold = perfect.
| Model | Params | Dim | cities | job titles | feedback | Good threshold |
|---|---|---|---|---|---|---|
all-MiniLM-L6-v2 (default) |
23M | 384 | 1.000 | 0.672 | 1.000 | 1.0 (cities) / 1.3 (feedback) |
all-MiniLM-L12-v2 |
33M | 384 | 1.000 | 0.512 | 1.000 | 1.20 |
all-mpnet-base-v2 |
109M | 768 | 0.619 | 0.512 | 1.000 | - |
BAAI/bge-small-en-v1.5 |
33M | 384 | 1.000 | 0.672 | 1.000 | 0.95 - 1.00 |
BAAI/bge-m3 |
568M | 1024 | 1.000 | 0.520 | 1.000 | - |
nomic-ai/nomic-embed-text-v1.5 * |
137M | 768 | 1.000 | 0.520 | 1.000 | 0.70 - 0.85 |
nomic-ai/nomic-embed-text-v2-moe * |
475M | 768 | 1.000 | 0.672 | 1.000 | 1.15 - 1.35 |
mixedbread-ai/mxbai-embed-large-v1 |
335M | 1024 | 1.000 | 0.815 | 1.000 | 0.95 - 1.05 |
Qwen/Qwen3-Embedding-0.6B |
596M | 1024 | 0.529 | 0.672 | 1.000 | - |
* Requires a clustering: prefix on each input. The bench applies this
automatically; if you swap the model in directly, wrap it in a custom encoder
that prepends the prefix.
Practical takeaways:
- Bigger isn't better.
all-mpnet-base-v2andQwen3-Embedding-0.6Bboth fail the simplest test (cities) - they merge Los Angeles with San Francisco before merging SF with San Francisco. - Best small drop-in:
BAAI/bge-small-en-v1.5matches the default's size class and has a single threshold window (0.95-1.00) that works for both cities and feedback. - Best overall:
mixedbread-ai/mxbai-embed-large-v1- the only model to exceed 0.8 ARI on job titles, with a 0.10-wide cities+feedback overlap at 0.95-1.05. Costs 15x more parameters than the default. - No model solves the job-titles case. Synonymy across roles
(
SWE~ Programmer ~ Software Engineer, Product Manager ~ Product Owner) defeats every encoder we tested.
To swap the encoder, pass a string or a custom Encoder:
from semaclust import TextClusterer
clusterer = TextClusterer(
encoder="BAAI/bge-small-en-v1.5",
distance_threshold=1.0,
)
Plugging in your own encoder
Any object implementing encode(texts: list[str]) -> np.ndarray satisfies the
Encoder protocol:
from semaclust import TextClusterer, Encoder
import numpy as np
class MyEncoder:
def encode(self, texts: list[str]) -> np.ndarray:
return np.random.rand(len(texts), 384).astype(np.float32)
clusterer = TextClusterer(encoder=MyEncoder())
CLI
semaclust ships a small typer-based CLI:
# Cluster lines from a file, write JSON
semaclust cluster items.txt --threshold 1.0 --output clusters.json
# Replace each line with its cluster representative
cat items.txt | semaclust replace --threshold 1.0
Run semaclust --help for the full reference.
Migration from 0.1.x
The 0.3 release is a breaking change. The single cluster(texts) entry point
is gone; the new API mirrors scikit-learn:
| 0.1.x | 0.3.x |
|---|---|
clusterer.cluster(texts) |
clusterer.fit(texts).clusters_ |
clusterer.get_replacement_map(texts) |
clusterer.fit(texts).get_replacement_map() |
clusterer.replace_values(texts) |
clusterer.fit_transform(texts) |
The representative_selector argument moved from per-call to the constructor.
Development
git clone https://github.com/cobanov/semaclust.git
cd semaclust
uv sync --extra dev
uv run pytest
uv run ruff check src tests
uv run mypy src/semaclust
Install the pre-commit hooks once:
uv run pre-commit install
License
MIT, see LICENSE.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file semaclust-0.4.0.tar.gz.
File metadata
- Download URL: semaclust-0.4.0.tar.gz
- Upload date:
- Size: 14.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e14046a56d1f369506e92b40489152cc62bdc0fa71b21e4caee5194c5fca147e
|
|
| MD5 |
1d71ff7be44b403c88de45a32f72f2a2
|
|
| BLAKE2b-256 |
023f7a2c22dabcd470c24ca14aeaec5bd9530609e177bb212461dfd9093056ce
|
Provenance
The following attestation bundles were made for semaclust-0.4.0.tar.gz:
Publisher:
release.yml on cobanov/semaclust
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
semaclust-0.4.0.tar.gz -
Subject digest:
e14046a56d1f369506e92b40489152cc62bdc0fa71b21e4caee5194c5fca147e - Sigstore transparency entry: 1549195711
- Sigstore integration time:
-
Permalink:
cobanov/semaclust@f53c1349b767d0f33725e288b0202dd5f54b9616 -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/cobanov
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f53c1349b767d0f33725e288b0202dd5f54b9616 -
Trigger Event:
push
-
Statement type:
File details
Details for the file semaclust-0.4.0-py3-none-any.whl.
File metadata
- Download URL: semaclust-0.4.0-py3-none-any.whl
- Upload date:
- Size: 11.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d33af3fe1a03fed0ec2d1ffe2cc475958d9f7f61086375ff3517a4732399d323
|
|
| MD5 |
beda628cb473bfadddbf349648742bb7
|
|
| BLAKE2b-256 |
6850c1aefe02b5743f0ecf7e460cdfe512bb6e0cbc66cee6fc0158debb40b4f2
|
Provenance
The following attestation bundles were made for semaclust-0.4.0-py3-none-any.whl:
Publisher:
release.yml on cobanov/semaclust
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
semaclust-0.4.0-py3-none-any.whl -
Subject digest:
d33af3fe1a03fed0ec2d1ffe2cc475958d9f7f61086375ff3517a4732399d323 - Sigstore transparency entry: 1549195786
- Sigstore integration time:
-
Permalink:
cobanov/semaclust@f53c1349b767d0f33725e288b0202dd5f54b9616 -
Branch / Tag:
refs/tags/v0.4.0 - Owner: https://github.com/cobanov
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f53c1349b767d0f33725e288b0202dd5f54b9616 -
Trigger Event:
push
-
Statement type: