naco-api/internal/clustering/k-means.go
Nacorid d3845671b4 ollama: fix EmbedTexts signature, add model config, use clustering package
- ollama/client.go: EmbedTexts accepts []clustering.Item, returns [][]float64
- backends.go: ollamaBackend interface uses clustering.Item
- utils/styleProfiles.go: remove duplicate KMeans, normalize, cosineSimilarity
- clustering/: new package with Item, Result types and KMeans
- config.go: add EmbeddingModel, StyleModel env vars
- main.go: pass model config to ollama.New()
- go.mod: add github.com/ethereum/go-ethereum for test client
2026-09-01 23:40:21 +02:00

228 lines
4.3 KiB
Go

package clustering
import (
"errors"
"math"
"math/rand"
)
func initializeKMeansPlusPlus(
vectors [][]float64,
k int,
rng *rand.Rand,
) [][]float64 {
centroids := make([][]float64, 0, k)
first := rng.Intn(len(vectors))
centroids = append(
centroids,
append([]float64(nil), vectors[first]...),
)
distances := make([]float64, len(vectors))
for len(centroids) < k {
var total float64
for i, vector := range vectors {
bestSimilarity := -math.MaxFloat64
for _, centroid := range centroids {
similarity := cosineSimilarity(vector, centroid)
if similarity > bestSimilarity {
bestSimilarity = similarity
}
}
// cosine distance
distance := 1 - bestSimilarity
if distance < 0 {
distance = 0
}
// k-means++ uses squared distance.
distances[i] = distance * distance
total += distances[i]
}
if total == 0 {
break
}
target := rng.Float64() * total
var cumulative float64
selected := len(vectors) - 1
for i, distance := range distances {
cumulative += distance
if cumulative >= target {
selected = i
break
}
}
centroids = append(
centroids,
append([]float64(nil), vectors[selected]...),
)
}
// This can theoretically happen if all vectors are identical.
for len(centroids) < k {
index := rng.Intn(len(vectors))
centroids = append(
centroids,
append([]float64(nil), vectors[index]...),
)
}
return centroids
}
func KMeans(items []Item, k int, maxIterations int, seed int64) ([]Result, error) {
if len(items) == 0 {
return nil, errors.New("no items")
}
if k <= 0 {
return nil, errors.New("k must be positive")
}
if k > len(items) {
k = len(items)
}
if maxIterations <= 0 {
maxIterations = 50
}
dimension := len(items[0].Embedding)
if dimension == 0 {
return nil, errors.New("empty embedding")
}
// Normalize embeddings once.
normalized := make([][]float64, len(items))
for i, item := range items {
if len(item.Embedding) != dimension {
return nil, errors.New("embedding dimensions do not match")
}
normalized[i] = normalize(item.Embedding)
}
rng := rand.New(rand.NewSource(seed))
centroids := initializeKMeansPlusPlus(normalized, k, rng)
assignments := make([]int, len(items))
for iteration := 0; iteration < maxIterations; iteration++ {
changed := false
// Assignment step.
for i, vector := range normalized {
bestCluster := 0
bestSimilarity := -math.MaxFloat64
for cluster, centroid := range centroids {
similarity := cosineSimilarity(vector, centroid)
if similarity > bestSimilarity {
bestSimilarity = similarity
bestCluster = cluster
}
}
if iteration == 0 || assignments[i] != bestCluster {
assignments[i] = bestCluster
changed = true
}
}
// Stop if nothing changed.
if !changed && iteration > 0 {
break
}
// Update step.
newCentroids := make([][]float64, k)
counts := make([]int, k)
for cluster := 0; cluster < k; cluster++ {
newCentroids[cluster] = make([]float64, dimension)
}
for i, vector := range normalized {
cluster := assignments[i]
counts[cluster]++
for d, value := range vector {
newCentroids[cluster][d] += value
}
}
for cluster := 0; cluster < k; cluster++ {
if counts[cluster] == 0 {
// Empty cluster: reinitialize with a random item.
index := rng.Intn(len(normalized))
newCentroids[cluster] = append(
[]float64(nil),
normalized[index]...,
)
continue
}
newCentroids[cluster] = normalize(newCentroids[cluster])
}
centroids = newCentroids
}
results := make([]Result, k)
for cluster := 0; cluster < k; cluster++ {
results[cluster] = Result{
Cluster: cluster,
Centroid: centroids[cluster],
}
}
for i, item := range items {
cluster := assignments[i]
item.Embedding = normalized[i]
results[cluster].Items = append(
results[cluster].Items,
item,
)
}
// Find the item closest to each centroid.
for cluster := range results {
if len(results[cluster].Items) == 0 {
continue
}
bestSimilarity := -math.MaxFloat64
for _, item := range results[cluster].Items {
similarity := cosineSimilarity(
item.Embedding,
results[cluster].Centroid,
)
if similarity > bestSimilarity {
bestSimilarity = similarity
results[cluster].Representative = item
}
}
}
return results, nil
}