Repository: weaviate/weaviate
Pull request #7556 (2025-03-17 09:06:37)

Optimized stand-alone k-means clustering
Optimized stand-alone k-means clustering

Weaviate currently uses k-means clustering for product quantization and
the implementation of k-means clustering was tied to this use case. This
patch factors the PQ encode/decode logic into a separate package and
rewrites k-means clustering to improve performance while adding an
option of choosing between random initialization and k-means++
initialization.

The new implementation also includes the option to use a new and faster
algorithm for assigning points to clusters. This operation is the "inner loop" 
of k-means clustering, accounting for about 80% of the time spent
in typical use cases when using brute force distance comparisons between
a point and all cluster centers. The new assignment algorithm provides a
speedup of about 2-4x for low-dimensional data without any changes to
the assignments being made.

The new implementation also drastically reduces the number of
allocations and the amount of memory allocated. Benchmarking product
quantization on 1536-dimensional OpenAI embeddings with 8-dimensional
segments shows a 2x faster training time, 30x less memory allocated
during training (7853 MiB vs 229 MiB), and 200x fewer allocations.

### Review checklist

- [x] Documentation has been updated, if necessary. _No update necessary, internal performance/tooling patch._
- [x] Chaos pipeline run or not necessary. Link to pipeline: https://github.com/weaviate/weaviate-chaos-engineering/actions/runs/14175658603
- [x] E2E pipeline: https://github.com/weaviate/weaviate-e2e-tests/actions/runs/14175714610 
- [x] All new code is covered by tests where it is reasonable.
- [x] Performance tests have been run or not necessary.

<!-- Uncomment the following section if this PR requires changes in related projects (e.g., documentation, client libraries).

GitHub actions will automatically create an issue in the corresponding repository for each checked box below. (See `.github/workflows/create-cross-functional-issues.yml`)

### Cross-functional impact

- [ ] This change requires public documentation (weaviate-io) to be updated. Check the box to automatically create a corresponding issue.
- Does it require a change in the client libraries? If yes, please check the boxes for the affected client libraries.
    - [ ] Python (weaviate-python-client)
    - [ ] JavaScript/TypeScript (typescript-client)
    - [ ] Go (weaviate-go-client)
    - [ ] Java (java-client)

-->


sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=weaviate_weaviate&pullRequest=7556) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=weaviate_weaviate&pullRequest=7556&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=weaviate_weaviate&pullRequest=7556&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=weaviate_weaviate&pullRequest=7556&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=weaviate_weaviate&pullRequest=7556&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=weaviate_weaviate&pullRequest=7556&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=weaviate_weaviate&pullRequest=7556)


sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=weaviate_weaviate&pullRequest=7556) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=weaviate_weaviate&pullRequest=7556&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=weaviate_weaviate&pullRequest=7556&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=weaviate_weaviate&pullRequest=7556&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=weaviate_weaviate&pullRequest=7556&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=weaviate_weaviate&pullRequest=7556&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=weaviate_weaviate&pullRequest=7556)


sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=weaviate_weaviate&pullRequest=7556) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=weaviate_weaviate&pullRequest=7556&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=weaviate_weaviate&pullRequest=7556&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=weaviate_weaviate&pullRequest=7556&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=weaviate_weaviate&pullRequest=7556&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=weaviate_weaviate&pullRequest=7556&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=weaviate_weaviate&pullRequest=7556)


sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=weaviate_weaviate&pullRequest=7556) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=weaviate_weaviate&pullRequest=7556&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=weaviate_weaviate&pullRequest=7556&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=weaviate_weaviate&pullRequest=7556&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=weaviate_weaviate&pullRequest=7556&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=weaviate_weaviate&pullRequest=7556&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=weaviate_weaviate&pullRequest=7556)


sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=weaviate_weaviate&pullRequest=7556) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=weaviate_weaviate&pullRequest=7556&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=weaviate_weaviate&pullRequest=7556&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=weaviate_weaviate&pullRequest=7556&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=weaviate_weaviate&pullRequest=7556&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=weaviate_weaviate&pullRequest=7556&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=weaviate_weaviate&pullRequest=7556)


tobias-weaviate: Tests on main were apparently flaky. There has been some commits that partially fix it, but I still get sporadic integration test failures.

I have rebased and started Chaos and E2E tests again, both against the tip of the main branch where this patch is applied, and on a docker image that includes the patch.

Chaos test runs: 
- main: https://github.com/weaviate/weaviate-chaos-engineering/actions/runs/14221183449
- patch: https://github.com/weaviate/weaviate-chaos-engineering/actions/runs/14221212975

E2E test runs:
- main: https://github.com/weaviate/weaviate-e2e-tests/actions/runs/14221175741
- patch: https://github.com/weaviate/weaviate-e2e-tests/actions/runs/14221218693


sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=weaviate_weaviate&pullRequest=7556) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=weaviate_weaviate&pullRequest=7556&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=weaviate_weaviate&pullRequest=7556&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=weaviate_weaviate&pullRequest=7556&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=weaviate_weaviate&pullRequest=7556&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.4% Duplication on New Code](https://sonarcloud.io/component_measures?id=weaviate_weaviate&pullRequest=7556&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=weaviate_weaviate&pullRequest=7556)


sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=weaviate_weaviate&pullRequest=7556) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=weaviate_weaviate&pullRequest=7556&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=weaviate_weaviate&pullRequest=7556&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=weaviate_weaviate&pullRequest=7556&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=weaviate_weaviate&pullRequest=7556&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.4% Duplication on New Code](https://sonarcloud.io/component_measures?id=weaviate_weaviate&pullRequest=7556&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=weaviate_weaviate&pullRequest=7556)


tobias-weaviate: Added Roberto's fix for the flaky multi-vector test to the patch. This fixed the failing acceptance test.
Re-running Chaos and E2E pipelines after some more tests have been fixed.

Chaos: https://github.com/weaviate/weaviate-chaos-engineering/actions/runs/14246577794
E2E: https://github.com/weaviate/weaviate-e2e-tests/actions/runs/14246568723


Patch:
diff --git a/adapters/handlers/grpc/v1/parse_aggregate_request.go b/adapters/handlers/grpc/v1/parse_aggregate_request.go
index fe6d46e96f..541fc2d288 100644
--- a/adapters/handlers/grpc/v1/parse_aggregate_request.go
+++ b/adapters/handlers/grpc/v1/parse_aggregate_request.go
@@ -19,8 +19,8 @@ import (
 	"github.com/weaviate/weaviate/entities/dto"
 	"github.com/weaviate/weaviate/entities/filters"
 	"github.com/weaviate/weaviate/entities/models"
+	"github.com/weaviate/weaviate/entities/modelsext"
 	"github.com/weaviate/weaviate/entities/schema"
-	schemachecks "github.com/weaviate/weaviate/entities/schema/checks"
 	"github.com/weaviate/weaviate/entities/searchparams"
 	pb "github.com/weaviate/weaviate/grpc/generated/protocol/v1"
 	"github.com/weaviate/weaviate/usecases/byteops"
@@ -510,7 +510,7 @@ func extractTargetVectorsForAggregate(req *pb.AggregateRequest, class *models.Cl
 		combination = &dto.TargetCombination{Type: dto.DefaultTargetCombinationType}
 	}
 
-	if vectorSearch && len(targetVectors) == 0 && !schemachecks.HasLegacyVectorIndex(class) {
+	if vectorSearch && len(targetVectors) == 0 && !modelsext.ClassHasLegacyVectorIndex(class) {
 		if len(class.VectorConfig) > 1 {
 			return nil, nil, false, fmt.Errorf("class %s has multiple vectors, but no target vectors were provided", class.Class)
 		} else if len(class.VectorConfig) == 1 {
diff --git a/adapters/handlers/grpc/v1/parse_search_request.go b/adapters/handlers/grpc/v1/parse_search_request.go
index b538e2cf83..8c09910301 100644
--- a/adapters/handlers/grpc/v1/parse_search_request.go
+++ b/adapters/handlers/grpc/v1/parse_search_request.go
@@ -15,7 +15,7 @@ import (
 	"fmt"
 	"slices"
 
-	schemachecks "github.com/weaviate/weaviate/entities/schema/checks"
+	"github.com/weaviate/weaviate/entities/modelsext"
 	"github.com/weaviate/weaviate/entities/schema/configvalidation"
 	"github.com/weaviate/weaviate/usecases/config"
 
@@ -486,7 +486,7 @@ func extractTargetVectors(req *pb.SearchRequest, class *models.Class) ([]string,
 		combination = &dto.TargetCombination{Type: dto.DefaultTargetCombinationType}
 	}
 
-	if vectorSearch && len(targetVectors) == 0 && !schemachecks.HasLegacyVectorIndex(class) {
+	if vectorSearch && len(targetVectors) == 0 && !modelsext.ClassHasLegacyVectorIndex(class) {
 		if len(class.VectorConfig) > 1 {
 			return nil, nil, false, fmt.Errorf("class %s has multiple vectors, but no target vectors were provided", class.Class)
 		}
diff --git a/adapters/repos/db/vector/compressionhelpers/kmeans.go b/adapters/repos/db/vector/compressionhelpers/kmeans.go
deleted file mode 100644
index df5a2747d3..0000000000
--- a/adapters/repos/db/vector/compressionhelpers/kmeans.go
+++ /dev/null
@@ -1,263 +0,0 @@
-//                           _       _
-// __      _____  __ ___   ___  __ _| |_ ___
-// \ \ /\ / / _ \/ _` \ \ / / |/ _` | __/ _ \
-//  \ V  V /  __/ (_| |\ V /| | (_| | ||  __/
-//   \_/\_/ \___|\__,_| \_/ |_|\__,_|\__\___|
-//
-//  Copyright © 2016 - 2024 Weaviate B.V. All rights reserved.
-//
-//  CONTACT: hello@weaviate.io
-//
-
-package compressionhelpers
-
-import (
-	"encoding/binary"
-	"errors"
-	"fmt"
-	"math"
-	"math/rand"
-
-	"github.com/weaviate/weaviate/adapters/repos/db/vector/hnsw/distancer"
-)
-
-type FilterFunc func([]float32) []float32
-
-type KMeans struct {
-	K                  int     // How many centroids
-	DeltaThreshold     float32 // Used to stop fitting if there are not too much changes in the centroids anymore
-	IterationThreshold int     // Used to stop fitting after a certain amount of iterations
-	Distance           distancer.Provider
-	centers            [][]float32 // The current centroids
-	dimensions         int         // Dimensions of the data
-	segment            int         // Segment where it operates
-
-	data KMeansPartitionData // Non-persistent data used only during the fitting process
-}
-
-// String prints some minimal information about the encoder. This can be
-// used for viability checks to see if the encoder was initialized
-// correctly – for example after a restart.
-func (k *KMeans) String() string {
-	maxElem := 5
-	var firstCenters []float32
-	i := 0
-	for _, center := range k.centers {
-		for _, centerVal := range center {
-			if i == maxElem {
-				break
-			}
-
-			firstCenters = append(firstCenters, centerVal)
-			i++
-		}
-		if i == maxElem {
-			break
-		}
-	}
-	return fmt.Sprintf("KMeans Encoder: K=%d, dim=%d, segment=%d first_center_truncated=%v", k.K, k.dimensions, k.segment, firstCenters)
-}
-
-type KMeansPartitionData struct {
-	changes int        // How many vectors has jumped to a new cluster
-	points  []uint64   // Cluster assigned to each point
-	cc      [][]uint64 // Partition of the data into the clusters
-}
-
-func NewKMeans(k int, dimensions int, segment int) *KMeans {
-	kMeans := &KMeans{
-		K:                  k,
-		DeltaThreshold:     0.01,
-		IterationThreshold: 10,
-		Distance:           distancer.NewL2SquaredProvider(),
-		dimensions:         dimensions,
-		segment:            segment,
-	}
-	return kMeans
-}
-
-func NewKMeansWithCenters(k int, dimensions int, segment int, centers [][]float32) *KMeans {
-	kmeans := NewKMeans(k, dimensions, segment)
-	kmeans.centers = centers
-	return kmeans
-}
-
-func (m *KMeans) ExposeDataForRestore() []byte {
-	ds := len(m.centers[0])
-	len := 4 * m.K * ds
-	buffer := make([]byte, len)
-	for i := 0; i < len/4; i++ {
-		binary.LittleEndian.PutUint32(buffer[i*4:(i+1)*4], math.Float32bits(m.centers[i/ds][i%ds]))
-	}
-	return buffer
-}
-
-func (m *KMeans) Add(x []float32) {
-	// nothing to do here
-}
-
-func (m *KMeans) Centers() [][]float32 {
-	return m.centers
-}
-
-func (m *KMeans) Encode(point []float32) byte {
-	return byte(m.Nearest(point))
-}
-
-func (m *KMeans) Nearest(point []float32) uint64 {
-	return m.NNearest(point, 1)[0]
-}
-
-func (m *KMeans) nNearest(point []float32, n int) ([]uint64, []float32) {
-	mins := make([]uint64, n)
-	minD := make([]float32, n)
-	for i := range mins {
-		mins[i] = 0
-		minD[i] = math.MaxFloat32
-	}
-	filteredPoint := point[m.segment*m.dimensions : (m.segment+1)*m.dimensions]
-	for i, c := range m.centers {
-		distance, _ := m.Distance.SingleDist(filteredPoint, c)
-		j := 0
-		for (j < n) && minD[j] < distance {
-			j++
-		}
-		if j < n {
-			for l := n - 1; l >= j+1; l-- {
-				mins[l] = mins[l-1]
-				minD[l] = minD[l-1]
-			}
-			minD[j] = distance
-			mins[j] = uint64(i)
-		}
-	}
-	return mins, minD
-}
-
-func (m *KMeans) NNearest(point []float32, n int) []uint64 {
-	nearest, _ := m.nNearest(point, n)
-	return nearest
-}
-
-func (m *KMeans) initCenters(data [][]float32) {
-	if len(m.centers) == m.K {
-		return
-	}
-	m.centers = make([][]float32, 0, m.K)
-	for i := 0; i < m.K; i++ {
-		var vec []float32
-		for vec == nil {
-			vec = data[rand.Intn(len(data))]
-		}
-		vecCopy := make([]float32, m.dimensions)
-		copy(vecCopy, vec[m.segment*m.dimensions:(m.segment+1)*m.dimensions])
-		m.centers = append(m.centers, vecCopy)
-	}
-}
-
-func (m *KMeans) recluster(data [][]float32) {
-	for p := 0; p < len(data); p++ {
-		point := data[p]
-		if point == nil {
-			continue
-		}
-		cis, _ := m.nNearest(point, 1)
-		ci := cis[0]
-		m.data.cc[ci] = append(m.data.cc[ci], uint64(p))
-		if m.data.points[p] != ci {
-			m.data.points[p] = ci
-			m.data.changes++
-		}
-	}
-}
-
-func (m *KMeans) resortOnEmptySets(data [][]float32) {
-	k64 := uint64(m.K)
-	dataSize := len(data)
-	for ci := uint64(0); ci < k64; ci++ {
-		if len(m.data.cc[ci]) == 0 {
-			var ri int
-			for {
-				ri = rand.Intn(dataSize)
-				if data[ri] == nil {
-					continue
-				}
-				if len(m.data.cc[m.data.points[ri]]) > 1 {
-					break
-				}
-			}
-			m.data.cc[ci] = append(m.data.cc[ci], uint64(ri))
-			m.data.points[ri] = ci
-			m.data.changes = dataSize
-		}
-	}
-}
-
-func (m *KMeans) recalcCenters(data [][]float32) {
-	for index := 0; index < m.K; index++ {
-		for j := range m.centers[index] {
-			m.centers[index][j] = 0
-		}
-		size := len(m.data.cc[index])
-		for _, ci := range m.data.cc[index] {
-			vec := data[ci]
-			v := vec[m.segment*m.dimensions : (m.segment+1)*m.dimensions]
-			for j := 0; j < m.dimensions; j++ {
-				m.centers[index][j] += v[j]
-			}
-		}
-		for j := 0; j < m.dimensions; j++ {
-			m.centers[index][j] /= float32(size)
-		}
-	}
-}
-
-func (m *KMeans) stopCondition(iterations int, dataSize int) bool {
-	return iterations >= m.IterationThreshold ||
-		m.data.changes < int(float32(dataSize)*m.DeltaThreshold)
-}
-
-func (m *KMeans) Fit(data [][]float32) error { // init centers using min/max per dimension
-	dataSize := len(data)
-	if dataSize < m.K {
-		return errors.New("not enough data to fit kmeans")
-	}
-	m.initCenters(data)
-	m.data.points = make([]uint64, dataSize)
-	m.data.changes = 1
-
-	for i := 0; m.data.changes > 0; i++ {
-		m.data.changes = 0
-		m.data.cc = make([][]uint64, m.K)
-		for j := range m.data.cc {
-			m.data.cc[j] = make([]uint64, 0)
-		}
-
-		m.recluster(data)
-		m.resortOnEmptySets(data)
-		if m.data.changes > 0 {
-			m.recalcCenters(data)
-		}
-
-		if m.stopCondition(i, dataSize) {
-			break
-		}
-
-	}
-
-	m.clearData()
-	return nil
-}
-
-func (m *KMeans) clearData() {
-	m.data.points = nil
-	m.data.cc = nil
-}
-
-func (m *KMeans) Center(point []float32) []float32 {
-	return m.centers[m.Nearest(point)]
-}
-
-func (m *KMeans) Centroid(i byte) []float32 {
-	return m.centers[i]
-}
diff --git a/adapters/repos/db/vector/compressionhelpers/kmeans_encoder.go b/adapters/repos/db/vector/compressionhelpers/kmeans_encoder.go
new file mode 100644
index 0000000000..5fca0987d7
--- /dev/null
+++ b/adapters/repos/db/vector/compressionhelpers/kmeans_encoder.go
@@ -0,0 +1,120 @@
+//                           _       _
+// __      _____  __ ___   ___  __ _| |_ ___
+// \ \ /\ / / _ \/ _` \ \ / / |/ _` | __/ _ \
+//  \ V  V /  __/ (_| |\ V /| | (_| | ||  __/
+//   \_/\_/ \___|\__,_| \_/ |_|\__,_|\__\___|
+//
+//  Copyright © 2016 - 2024 Weaviate B.V. All rights reserved.
+//
+//  CONTACT: hello@weaviate.io
+//
+
+package compressionhelpers
+
+import (
+	"encoding/binary"
+	"fmt"
+	"math"
+
+	"github.com/weaviate/weaviate/adapters/repos/db/vector/hnsw/distancer"
+	"github.com/weaviate/weaviate/adapters/repos/db/vector/kmeans"
+)
+
+type KMeansEncoder struct {
+	k        int                // Number of centers/centroids.
+	d        int                // Dimensions of the data.
+	s        int                // Segment where it operates.
+	centers  [][]float32        // k-means centroids used for encoding data.
+	distance distancer.Provider // Distance measure used to encode to nearest center.
+}
+
+func NewKMeansEncoder(k int, dimensions int, segment int) *KMeansEncoder {
+	encoder := &KMeansEncoder{
+		k:        k,
+		d:        dimensions,
+		s:        segment,
+		distance: distancer.NewL2SquaredProvider(),
+	}
+	return encoder
+}
+
+func NewKMeansEncoderWithCenters(k int, dimensions int, segment int, centers [][]float32) *KMeansEncoder {
+	encoder := NewKMeansEncoder(k, dimensions, segment)
+	encoder.centers = centers
+	return encoder
+}
+
+// Assumes that data contains only non-nil vectors.
+func (m *KMeansEncoder) Fit(data [][]float32) error {
+	km := kmeans.New(m.k, m.d, m.s)
+	km.DeltaThreshold = 0.01
+	km.IterationThreshold = 10
+	// Experiments on ANN datasets reveal that random initialization is ~20%
+	// faster than k-means++ initialization when used for PQ and gives the same
+	// or slightly better recall.
+	km.Initialization = kmeans.RandomInitialization
+	// Experiments show that GraphPruning is always faster for short segments,
+	// typically giving a 2x speedup for d = 8. On some datasets such as SIFT
+	// and GIST it is faster also up to 32 dimensions, and it will never be much
+	// slower than brute force assignment since the additional overhead is
+	// ~k^2 distance computations and k << n. We therefore always enable it.
+	km.Assignment = kmeans.GraphPruning
+	err := km.Fit(data)
+	m.centers = km.Centers
+	return err
+}
+
+func (m *KMeansEncoder) Encode(point []float32) byte {
+	var minDist float32 = math.MaxFloat32
+	idx := 0
+	segment := point[m.s*m.d : (m.s+1)*m.d]
+	for i := range m.centers {
+		if dist, _ := m.distance.SingleDist(segment, m.centers[i]); dist < minDist {
+			minDist = dist
+			idx = i
+		}
+	}
+	return byte(idx)
+}
+
+func (m *KMeansEncoder) Centroid(i byte) []float32 {
+	return m.centers[i]
+}
+
+func (m *KMeansEncoder) Add(x []float32) {
+	// Only here to satisfy the PQEncoder interface.
+}
+
+func (m *KMeansEncoder) ExposeDataForRestore() []byte {
+	ds := len(m.centers[0])
+	len := 4 * m.k * ds
+	buffer := make([]byte, len)
+	for i := 0; i < len/4; i++ {
+		binary.LittleEndian.PutUint32(buffer[i*4:(i+1)*4], math.Float32bits(m.centers[i/ds][i%ds]))
+	}
+	return buffer
+}
+
+// String prints some minimal information about the encoder. This can be
+// used for viability checks to see if the encoder was initialized
+// correctly – for example after a restart.
+func (m *KMeansEncoder) String() string {
+	maxElem := 5
+	var firstCenters []float32
+	i := 0
+	for _, center := range m.centers {
+		for _, centerVal := range center {
+			if i == maxElem {
+				break
+			}
+
+			firstCenters = append(firstCenters, centerVal)
+			i++
+		}
+		if i == maxElem {
+			break
+		}
+	}
+	return fmt.Sprintf("KMeans Encoder: K=%d, dim=%d, segment=%d first_center_truncated=%v",
+		m.k, m.d, m.s, firstCenters)
+}
diff --git a/adapters/repos/db/vector/compressionhelpers/product_quantization.go b/adapters/repos/db/vector/compressionhelpers/product_quantization.go
index d6426a9a64..2e5df1d1e5 100644
--- a/adapters/repos/db/vector/compressionhelpers/product_quantization.go
+++ b/adapters/repos/db/vector/compressionhelpers/product_quantization.go
@@ -391,7 +391,7 @@ func (pq *ProductQuantizer) Fit(data [][]float32) error {
 				return
 			}
 			mutex.Unlock()
-			pq.kms[i] = NewKMeans(
+			pq.kms[i] = NewKMeansEncoder(
 				pq.ks,
 				pq.ds,
 				int(i),
diff --git a/adapters/repos/db/vector/hnsw/deserializer.go b/adapters/repos/db/vector/hnsw/deserializer.go
index d70aae1c3d..ebd95cdaba 100644
--- a/adapters/repos/db/vector/hnsw/deserializer.go
+++ b/adapters/repos/db/vector/hnsw/deserializer.go
@@ -529,7 +529,7 @@ func (d *Deserializer) ReadKMeansEncoder(r io.Reader, data *compressionhelpers.P
 		}
 		centers = append(centers, center)
 	}
-	kms := compressionhelpers.NewKMeansWithCenters(
+	kms := compressionhelpers.NewKMeansEncoderWithCenters(
 		int(data.Ks),
 		ds,
 		int(i),
diff --git a/adapters/repos/db/vector/kmeans/kmeans.go b/adapters/repos/db/vector/kmeans/kmeans.go
new file mode 100644
index 0000000000..2912cd34d1
--- /dev/null
+++ b/adapters/repos/db/vector/kmeans/kmeans.go
@@ -0,0 +1,505 @@
+//                           _       _
+// __      _____  __ ___   ___  __ _| |_ ___
+// \ \ /\ / / _ \/ _` \ \ / / |/ _` | __/ _ \
+//  \ V  V /  __/ (_| |\ V /| | (_| | ||  __/
+//   \_/\_/ \___|\__,_| \_/ |_|\__,_|\__\___|
+//
+//  Copyright © 2016 - 2024 Weaviate B.V. All rights reserved.
+//
+//  CONTACT: hello@weaviate.io
+//
+
+package kmeans
+
+import (
+	"cmp"
+	"errors"
+	"math"
+	"math/rand/v2"
+	"slices"
+
+	"github.com/weaviate/weaviate/adapters/repos/db/vector/hnsw/distancer"
+)
+
+type IndexAndDistance struct {
+	index    uint32
+	distance float32
+}
+
+type temporaryData struct {
+	centroids       [][]float64          // Higher-precision intermediate storage for computing centroids.
+	sizes           []uint32             // Number of points in each cluster.
+	assignment      []uint32             // For each data point the index of the cluster that it is assigned to.
+	centerNeighbors [][]IndexAndDistance // Lists of nearest centers to each center, ordered by distance.
+	rng             *rand.Rand           // RNG for initialization and tie breaking.
+}
+
+func (tmp *temporaryData) init(n int, d int, k int, seed uint64, strategy AssignmentStrategy) {
+	tmp.centroids = make([][]float64, 0, k)
+	for range k {
+		tmp.centroids = append(tmp.centroids, make([]float64, d))
+	}
+	tmp.sizes = make([]uint32, k)
+	tmp.assignment = make([]uint32, n)
+	if strategy == GraphPruning {
+		tmp.centerNeighbors = make([][]IndexAndDistance, k)
+		for c := range k {
+			tmp.centerNeighbors[c] = make([]IndexAndDistance, 0, k-1)
+		}
+	}
+	tmp.rng = rand.New(rand.NewPCG(seed, 0x385ab5285169b1ac))
+}
+
+func (tmp *temporaryData) free() {
+	tmp.centroids = nil
+	tmp.sizes = nil
+	tmp.assignment = nil
+	tmp.centerNeighbors = nil
+	tmp.rng = nil
+}
+
+type TerminationCondition string
+
+const (
+	MaxIterations    TerminationCondition = "MaxIterations"
+	ClusterStability TerminationCondition = "ClusterStability"
+)
+
+// The first entry in the slices contain statistics for the initialization.
+// By convention we set the first number of changes equal to the size of the dataset.
+type Metrics struct {
+	Iterations   int                  // Number of iterations before terminating.
+	Termination  TerminationCondition // The condition that causes k-means to terminate.
+	Changes      []int                // For each iteration the number of points that moved to a new cluster.
+	Computations []int                // Total number of distance computations.
+	WCSS         []float64            // Within-cluster sum of squares per iteration (k-means objective).
+}
+
+func (m *Metrics) TotalComputations() int {
+	var sum int
+	for _, c := range m.Computations {
+		sum += c
+	}
+	return sum
+}
+
+func (m *Metrics) TotalChanges() int {
+	var sum int
+	for _, c := range m.Changes {
+		sum += c
+	}
+	return sum
+}
+
+func (m *Metrics) update(metrics IterationMetrics) {
+	m.Computations = append(m.Computations, metrics.computations)
+	m.WCSS = append(m.WCSS, metrics.wcss)
+	m.Changes = append(m.Changes, metrics.changes)
+	m.Iterations++
+}
+
+type InitializationStrategy string
+
+const (
+	PlusPlusInitialization InitializationStrategy = "PlusPlus"
+	RandomInitialization   InitializationStrategy = "Random"
+)
+
+type AssignmentStrategy string
+
+const (
+	GraphPruning AssignmentStrategy = "GraphPruning"
+	BruteForce   AssignmentStrategy = "BruteForce"
+)
+
+type KMeans struct {
+	K                  int                    // How many centroids.
+	IterationThreshold int                    // Used to stop fitting after a certain amount of iterations.
+	DeltaThreshold     float32                // Used to stop fitting if the fraction of points that change clusters is at or below threshold.
+	Initialization     InitializationStrategy // Algorithm used to initialize cluster centers.
+	Assignment         AssignmentStrategy     // Whether to use inter-cluster distances to prune away distance computations.
+	Seed               uint64                 // The seed for the RNG using during fitting.
+	Metrics            Metrics                // Metrics for observability of the clustering algorithm.
+	Centers            [][]float32            // The centers computed by Fit()
+	distance           distancer.Provider     // The clustering algorithm is intended to work with L2 squared.
+	dimensions         int                    // Dimensions of the data.
+	segment            int                    // Segment where it operates.
+	tmp                temporaryData          // Temporary heap-allocated data used during fitting.
+}
+
+// Reasoning behind default settings
+// =================================
+// Experiments show that GraphPruning speeds up k-means compared to BruteForce
+// when d <= 16 on random data. The speedup is about 5x for d = 4, 2x for d = 8,
+// and insignificant at d = 16. We use GraphPruning by default as there is
+// almost no additional cost for high-dimensional data at typical problem sizes
+// (n = 100_000, k = 256), and a clear benefit for low-dimensional data.
+//
+// The IterationThreshold is what typically determines the number of iterations.
+// It is set to balance running time and quality. The DeltaThreshold only comes
+// into effect after e.g. 50-100 iterations on random data.
+//
+// k-means++ initialization seems to provide slightly better WCSS on random data
+// compared to random initialization. It does come at at a slight increase in
+// cost, something like 3-5% of the total k-means running time, but the
+// robustness and increased quality seems worth it.
+//
+// Notes
+// =====
+// There exists other heuristics to speed up k-means, see e.g. the paper
+// "Making k-means even faster" by Greg Hamerly. We tried implementing this
+// method, but it turned out that it mostly saves distance computations when the
+// clusters are nearly stable, which only happens after e.g. 50+ iterations.
+// Therefore the current heuristic (I don't know if it is published somewhere)
+// seems to provide better speedups in the setting that is relewvant for
+// product quantization (d = 4 or d = 8, and few iterations).
+func New(k int, dimensions int, segment int) *KMeans {
+	kMeans := &KMeans{
+		K:                  k,
+		DeltaThreshold:     0.01,
+		IterationThreshold: 10,
+		Initialization:     PlusPlusInitialization,
+		Assignment:         GraphPruning,
+		Seed:               rand.Uint64(),
+		distance:           distancer.NewL2SquaredProvider(),
+		dimensions:         dimensions,
+		segment:            segment,
+	}
+	return kMeans
+}
+
+func (m *KMeans) seg(x []float32) []float32 {
+	return x[m.segment*m.dimensions : (m.segment+1)*m.dimensions]
+}
+
+func (m *KMeans) l2squared(x []float32, y []float32) float32 {
+	dist, _ := m.distance.SingleDist(x, y)
+	return dist
+}
+
+func weightedSample(weights []float32, r *rand.Rand) int {
+	var s float32
+	for _, w := range weights {
+		s += w
+	}
+
+	var v float32
+	target := s * r.Float32()
+	for i, w := range weights {
+		v += w
+		if target < v {
+			return i
+		}
+	}
+	// Fallback, should not happen.
+	return r.IntN(len(weights))
+}
+
+// initialize performs k-means++ initialization and also performs one iteration
+// of Lloyd's algorithm (assigns points to centers and updates centroids) since
+// we get the cluster assignment "for free" from the initialization.
+func (m *KMeans) initializePlusPlus(data [][]float32) {
+	n := len(data)
+	copy(m.Centers[0], m.seg(data[m.tmp.rng.IntN(n)]))
+	distances := make([]float32, n)
+	for i := range distances {
+		distances[i] = math.MaxFloat32
+	}
+
+	for c := range m.K {
+		for i, x := range data {
+			if dist := m.l2squared(m.seg(x), m.Centers[c]); dist < distances[i] {
+				distances[i] = dist
+				m.tmp.assignment[i] = uint32(c)
+			}
+		}
+		if c < m.K-1 {
+			idx := weightedSample(distances, m.tmp.rng)
+			copy(m.Centers[c+1], m.seg(data[idx]))
+		}
+	}
+
+	if m.IterationThreshold == 0 {
+		return
+	}
+
+	m.updateCenters(data)
+	var metrics IterationMetrics
+	metrics.changes = n
+	metrics.computations = n * m.K
+	for _, dist := range distances {
+		metrics.wcss += float64(dist)
+	}
+	m.Metrics.update(metrics)
+}
+
+type IndexOrder struct{}
+
+func randomSubset(n int, k int, r *rand.Rand) []int {
+	if k > n/2 {
+		return r.Perm(n)[:k]
+	}
+
+	// We sample random integers and insert them in a map to create the random
+	// subset. To produce a deterministic ordering we associate a random float
+	// with each index and use it to order the subset.
+	m := make(map[int]float64, k)
+	for len(m) < k {
+		m[r.IntN(n)] = r.Float64()
+	}
+	i := 0
+
+	type IndexOrder struct {
+		Index int
+		Rank  float64
+	}
+
+	subset := make([]IndexOrder, k)
+	for key, value := range m {
+		subset[i].Index = key
+		subset[i].Rank = value
+		i++
+	}
+
+	slices.SortFunc(subset, func(a, b IndexOrder) int {
+		return cmp.Compare(a.Rank, b.Rank)
+	})
+
+	indices := make([]int, k)
+	for i := range k {
+		indices[i] = subset[i].Index
+	}
+
+	return indices
+}
+
+// initializeRandom picks k random data points as centers. We also run a single
+// iteration of LLoyd's algorithm in order to prepare the data structure for
+// Fit() in a similar manner to initializePlusPlus().
+func (m *KMeans) initializeRandom(data [][]float32) {
+	subset := randomSubset(len(data), m.K, m.tmp.rng)
+	for c := range m.K {
+		copy(m.Centers[c], m.seg(data[subset[c]]))
+	}
+
+	if m.IterationThreshold == 0 {
+		return
+	}
+
+	var metrics IterationMetrics
+	for i, x := range data {
+		nearest := m.nearestBruteForce(x)
+		m.tmp.assignment[i] = nearest.index
+		metrics.wcss += float64(nearest.distance)
+		metrics.computations += nearest.computations
+		metrics.changes++
+	}
+	m.Metrics.update(metrics)
+	m.updateCenters(data)
+}
+
+// updateCenters computes new centroids according to the current assignment.
+func (m *KMeans) updateCenters(data [][]float32) {
+	// We perform intermediate computations of the centroids using float64
+	// for improved precision. The overhead of doing this seems to be negligible
+	// (< 1% of running time) as measured by BenchmarkKMeansFit().
+	clear(m.tmp.sizes)
+	for c := range m.K {
+		clear(m.tmp.centroids[c])
+	}
+
+	for i, x := range data {
+		c := m.tmp.assignment[i]
+		m.tmp.sizes[c]++
+		for j, z := range m.seg(x) {
+			m.tmp.centroids[c][j] += float64(z)
+		}
+	}
+
+	for c := range m.K {
+		if m.tmp.sizes[c] == 0 {
+			// This is not supposed to happen under normal circumstances.
+			// If it happens it is likely due to duplicate data, but
+			// k-means++ initialization should never pick duplicates except
+			// as a fallback measure. We could pick another random center,
+			// but it is unlikely to improve the situtation.
+			continue // Keep the current center.
+		}
+		for j := range m.dimensions {
+			m.Centers[c][j] = float32(m.tmp.centroids[c][j] / float64(m.tmp.sizes[c]))
+		}
+	}
+}
+
+type NearestCenter struct {
+	index        uint32  // Index of nearest center.
+	distance     float32 // L2 squared distance to nearest center.
+	computations int     // Number of distance computations used to determine nearest center.
+}
+
+// nearestWithPruning returns the index of the nearest center to a point. It
+// leverages the following information in order to prune unnecessary distance
+// computations:
+//
+// 1) The center that the point was assigned to in the previous iteration, and
+//
+// 2) A list of the nearest centers to the previously assigned center, ordered
+// by distance.
+//
+// We use the observation that a point is likely to remain in the same cluster,
+// so we take the previous center as the starting point and compute the distance
+// centerDist between it and the point. We can then prune away distance
+// calculations to every center that lie at a distance greater than 2*centerDist
+// from the previous center.
+func (m *KMeans) nearestWithPruning(point []float32, prevCenterIdx uint32) NearestCenter {
+	pointSegment := m.seg(point)
+	minDist := m.l2squared(m.Centers[prevCenterIdx], pointSegment)
+	centerDist := float32(math.Sqrt(float64(minDist)))
+	idx := prevCenterIdx
+	neighbors := m.tmp.centerNeighbors[prevCenterIdx]
+	var computations int
+	for _, v := range neighbors {
+		if v.distance >= 2*centerDist {
+			break // Remaining centers can be skipped.
+		}
+		if dist := m.l2squared(m.seg(point), m.Centers[v.index]); dist < minDist {
+			minDist = dist
+			idx = v.index
+		}
+	}
+	return NearestCenter{index: idx, distance: minDist, computations: computations}
+}
+
+func (m *KMeans) nearestBruteForce(point []float32) NearestCenter {
+	var minDist float32 = math.MaxFloat32
+	var idx uint32
+	for i := range m.Centers {
+		if dist := m.l2squared(m.seg(point), m.Centers[i]); dist < minDist {
+			minDist = dist
+			idx = uint32(i)
+		}
+	}
+	return NearestCenter{index: idx, distance: minDist, computations: m.K}
+}
+
+func (m *KMeans) nearest(point []float32, prevCenterIdx uint32) NearestCenter {
+	var nearest NearestCenter
+	switch m.Assignment {
+	case GraphPruning:
+		nearest = m.nearestWithPruning(point, prevCenterIdx)
+	case BruteForce:
+		nearest = m.nearestBruteForce(point)
+	}
+	return nearest
+}
+
+// updateCenterNeighbors computes for each center the list of nearest centers in
+// ascending order by Euclidean distance.
+func (m *KMeans) updateCenterNeighbors() {
+	for c := range m.K {
+		m.tmp.centerNeighbors[c] = m.tmp.centerNeighbors[c][:0]
+	}
+
+	for c1 := range m.K {
+		for c2 := c1 + 1; c2 < m.K; c2++ {
+			dist := m.l2squared(m.Centers[c1], m.Centers[c2])
+			distEuclidean := float32(math.Sqrt(float64(dist)))
+			m.tmp.centerNeighbors[c1] = append(m.tmp.centerNeighbors[c1], IndexAndDistance{index: uint32(c2), distance: distEuclidean})
+			m.tmp.centerNeighbors[c2] = append(m.tmp.centerNeighbors[c2], IndexAndDistance{index: uint32(c1), distance: distEuclidean})
+		}
+	}
+
+	for _, neighbors := range m.tmp.centerNeighbors {
+		slices.SortFunc(neighbors, func(a, b IndexAndDistance) int {
+			return cmp.Compare(a.distance, b.distance)
+		})
+	}
+}
+
+type IterationMetrics struct {
+	changes      int
+	computations int
+	wcss         float64
+}
+
+func (m *KMeans) initMemory(n int) {
+	m.tmp.init(n, m.dimensions, m.K, m.Seed, m.Assignment)
+	m.Centers = make([][]float32, m.K)
+	for c := range m.K {
+		m.Centers[c] = make([]float32, m.dimensions)
+	}
+	m.Metrics = Metrics{}
+}
+
+func (m *KMeans) initializeCenters(data [][]float32) {
+	switch m.Initialization {
+	case RandomInitialization:
+		m.initializeRandom(data)
+	case PlusPlusInitialization:
+		m.initializePlusPlus(data)
+	}
+}
+
+func (m *KMeans) cleanupMemory() {
+	m.tmp.free()
+}
+
+func (m *KMeans) computeCentroid(data [][]float32) {
+	// We can skip Lloyd's algorithm and return the centroid directly.
+	// We leverage existing methods to compute it.
+	m.initMemory(len(data)) // Every data point is assigned to the first center.
+	m.updateCenters(data)   // Compute the centroid according to the zero-assignment.
+}
+
+// Fit runs k-means clustering on the data according to the settings on the
+// KMeans struct. After running Fit() the resulting cluster centers can be
+// accessed through Centers().
+// TODO: Consider refactoring to functions that explicitly pass around structs.
+func (m *KMeans) Fit(data [][]float32) error {
+	if len(data) < m.K {
+		return errors.New("not enough data to fit k-means")
+	}
+
+	if m.K == 1 {
+		m.computeCentroid(data)
+		return nil
+	}
+
+	n := len(data)
+	m.initMemory(n)
+	m.initializeCenters(data)
+	m.Metrics.Termination = MaxIterations
+	for m.Metrics.Iterations < m.IterationThreshold {
+		var metrics IterationMetrics
+		if m.Assignment == GraphPruning {
+			m.updateCenterNeighbors()
+			metrics.computations += m.K * m.K / 2
+		}
+		for i, x := range data {
+			prevCenterIdx := m.tmp.assignment[i]
+			nearest := m.nearest(x, prevCenterIdx)
+			if nearest.index != prevCenterIdx {
+				metrics.changes++
+				m.tmp.assignment[i] = nearest.index
+			}
+			metrics.wcss += float64(nearest.distance)
+			metrics.computations += nearest.computations
+		}
+		m.Metrics.update(metrics)
+		m.updateCenters(data)
+		if float32(metrics.changes) <= m.DeltaThreshold*float32(n) {
+			m.Metrics.Termination = ClusterStability
+			break
+		}
+	}
+	m.cleanupMemory()
+	return nil
+}
+
+func (m *KMeans) DisableDeltaThreshold() {
+	m.DeltaThreshold = -1
+}
+
+func (m *KMeans) DisableIterationThreshold() {
+	m.IterationThreshold = math.MaxInt
+}
diff --git a/entities/schema/constants.go b/entities/modelsext/class.go
similarity index 61%
rename from entities/schema/constants.go
rename to entities/modelsext/class.go
index 4017e1fb5b..7e09b4eb52 100644
--- a/entities/schema/constants.go
+++ b/entities/modelsext/class.go
@@ -9,8 +9,15 @@
 //  CONTACT: hello@weaviate.io
 //
 
-package schema
+package modelsext
+
+import "github.com/weaviate/weaviate/entities/models"
 
 // DefaultNamedVectorName is a default vector named used to create a named vector or to allow access
 // to legacy vector through named vector API.
 const DefaultNamedVectorName = "default"
+
+// ClassHasLegacyVectorIndex checks whether there is a legacy index configured on a class.
+func ClassHasLegacyVectorIndex(class *models.Class) bool {
+	return class.Vectorizer != "" || class.VectorIndexConfig != nil || class.VectorIndexType != ""
+}
diff --git a/entities/modelsext/doc.go b/entities/modelsext/doc.go
new file mode 100644
index 0000000000..48c479e269
--- /dev/null
+++ b/entities/modelsext/doc.go
@@ -0,0 +1,21 @@
+//                           _       _
+// __      _____  __ ___   ___  __ _| |_ ___
+// \ \ /\ / / _ \/ _` \ \ / / |/ _` | __/ _ \
+//  \ V  V /  __/ (_| |\ V /| | (_| | ||  __/
+//   \_/\_/ \___|\__,_| \_/ |_|\__,_|\__\___|
+//
+//  Copyright © 2016 - 2024 Weaviate B.V. All rights reserved.
+//
+//  CONTACT: hello@weaviate.io
+//
+
+// Package modelsext provides extension methods to the structures in the models package. As this package is
+// generated, we cannot put any functionality or helper methods straight into it.
+//
+// As the models package is used very widely throughout the repository, extensions package
+// should not import anything outside the models package as well to avoid any circular dependencies.
+//
+// To keep things tidy, use these conventions:
+// 1. Helpers for the struct in models/X.go should be in modelsext/X.go .
+// 2. Functions should be named <struct name><aspirational method name>.
+package modelsext
diff --git a/entities/schema/checks/class.go b/entities/schema/checks/class.go
deleted file mode 100644
index ff68643ccf..0000000000
--- a/entities/schema/checks/class.go
+++ /dev/null
@@ -1,19 +0,0 @@
-//                           _       _
-// __      _____  __ ___   ___  __ _| |_ ___
-// \ \ /\ / / _ \/ _` \ \ / / |/ _` | __/ _ \
-//  \ V  V /  __/ (_| |\ V /| | (_| | ||  __/
-//   \_/\_/ \___|\__,_| \_/ |_|\__,_|\__\___|
-//
-//  Copyright © 2016 - 2024 Weaviate B.V. All rights reserved.
-//
-//  CONTACT: hello@weaviate.io
-//
-
-package checks
-
-import "github.com/weaviate/weaviate/entities/models"
-
-// HasLegacyVectorIndex checks whether there is a configured legacy vector index on a class.
-func HasLegacyVectorIndex(class *models.Class) bool {
-	return class.Vectorizer != "" || class.VectorIndexConfig != nil || class.VectorIndexType != ""
-}
diff --git a/entities/schema/config/vector_index_config.go b/entities/schema/config/vector_index_config.go
index 5b53d0bd2e..830357a09c 100644
--- a/entities/schema/config/vector_index_config.go
+++ b/entities/schema/config/vector_index_config.go
@@ -16,7 +16,7 @@ import (
 
 	"github.com/pkg/errors"
 	"github.com/weaviate/weaviate/entities/models"
-	schemachecks "github.com/weaviate/weaviate/entities/schema/checks"
+	"github.com/weaviate/weaviate/entities/modelsext"
 )
 
 type VectorIndexConfig interface {
@@ -26,7 +26,7 @@ type VectorIndexConfig interface {
 }
 
 func TypeAssertVectorIndex(class *models.Class, targetVectors []string) ([]VectorIndexConfig, error) {
-	if len(class.VectorConfig) == 0 || (schemachecks.HasLegacyVectorIndex(class) && len(targetVectors) == 0) {
+	if len(class.VectorConfig) == 0 || (modelsext.ClassHasLegacyVectorIndex(class) && len(targetVectors) == 0) {
 		vectorIndexConfig, ok := class.VectorIndexConfig.(VectorIndexConfig)
 		if !ok {
 			return nil, fmt.Errorf("class '%s' vector index: config is not schema.VectorIndexConfig: %T",
diff --git a/usecases/modules/module_config_init_and_validate.go b/usecases/modules/module_config_init_and_validate.go
index 349ba6bbf0..7dabb02fd5 100644
--- a/usecases/modules/module_config_init_and_validate.go
+++ b/usecases/modules/module_config_init_and_validate.go
@@ -17,15 +17,15 @@ import (
 
 	"github.com/pkg/errors"
 	"github.com/weaviate/weaviate/entities/models"
+	"github.com/weaviate/weaviate/entities/modelsext"
 	"github.com/weaviate/weaviate/entities/modulecapabilities"
 	"github.com/weaviate/weaviate/entities/schema"
-	schemachecks "github.com/weaviate/weaviate/entities/schema/checks"
 )
 
 // SetClassDefaults sets the module-specific defaults for the class itself, but
 // also for each prop
 func (p *Provider) SetClassDefaults(class *models.Class) {
-	if schemachecks.HasLegacyVectorIndex(class) || len(class.VectorConfig) == 0 {
+	if modelsext.ClassHasLegacyVectorIndex(class) || len(class.VectorConfig) == 0 {
 		p.setClassDefaults(class, class.Vectorizer, "", func(vectorizerConfig map[string]interface{}) {
 			if class.ModuleConfig == nil {
 				class.ModuleConfig = map[string]interface{}{}
@@ -94,7 +94,7 @@ func (p *Provider) SetSinglePropertyDefaults(class *models.Class,
 	props ...*models.Property,
 ) {
 	for _, prop := range props {
-		if schemachecks.HasLegacyVectorIndex(class) || len(class.VectorConfig) == 0 {
+		if modelsext.ClassHasLegacyVectorIndex(class) || len(class.VectorConfig) == 0 {
 			p.setSinglePropertyDefaults(prop, class.Vectorizer)
 		}
 
diff --git a/usecases/modules/modules.go b/usecases/modules/modules.go
index 06b70b8339..09948da2d1 100644
--- a/usecases/modules/modules.go
+++ b/usecases/modules/modules.go
@@ -18,14 +18,13 @@ import (
 	"slices"
 	"sync"
 
-	"github.com/weaviate/weaviate/entities/dto"
-	schemachecks "github.com/weaviate/weaviate/entities/schema/checks"
-
 	"github.com/pkg/errors"
 	"github.com/sirupsen/logrus"
 	"github.com/tailor-inc/graphql"
 	"github.com/tailor-inc/graphql/language/ast"
+	"github.com/weaviate/weaviate/entities/dto"
 	"github.com/weaviate/weaviate/entities/models"
+	"github.com/weaviate/weaviate/entities/modelsext"
 	"github.com/weaviate/weaviate/entities/modulecapabilities"
 	"github.com/weaviate/weaviate/entities/moduletools"
 	"github.com/weaviate/weaviate/entities/schema"
@@ -927,7 +926,7 @@ func (p *Provider) getTargetVector(class *models.Class, params interface{}) ([]s
 		return nearParam.GetTargetVectors(), nil
 	}
 	if class != nil {
-		if schemachecks.HasLegacyVectorIndex(class) {
+		if modelsext.ClassHasLegacyVectorIndex(class) {
 			return []string{""}, nil
 		}
 
diff --git a/usecases/modules/vectorizer.go b/usecases/modules/vectorizer.go
index 5dedf835d6..9d2598ef03 100644
--- a/usecases/modules/vectorizer.go
+++ b/usecases/modules/vectorizer.go
@@ -19,7 +19,7 @@ import (
 
 	"github.com/weaviate/weaviate/entities/dto"
 	enterrors "github.com/weaviate/weaviate/entities/errors"
-	schemachecks "github.com/weaviate/weaviate/entities/schema/checks"
+	"github.com/weaviate/weaviate/entities/modelsext"
 
 	"github.com/sirupsen/logrus"
 	"github.com/weaviate/weaviate/entities/models"
@@ -526,7 +526,7 @@ func (p *Provider) getModuleConfigs(class *models.Class) (map[string]map[string]
 		modConfigs[name] = modConfig
 	}
 
-	if schemachecks.HasLegacyVectorIndex(class) && class.Vectorizer != config.VectorizerModuleNone {
+	if modelsext.ClassHasLegacyVectorIndex(class) && class.Vectorizer != config.VectorizerModuleNone {
 		if modConfig, ok := class.ModuleConfig.(map[string]interface{}); ok {
 			modConfigs[""] = modConfig
 		} else {
diff --git a/usecases/objects/auto_schema.go b/usecases/objects/auto_schema.go
index fe594725d0..261a9c25bc 100644
--- a/usecases/objects/auto_schema.go
+++ b/usecases/objects/auto_schema.go
@@ -21,6 +21,7 @@ import (
 
 	"github.com/google/uuid"
 	"github.com/sirupsen/logrus"
+	"github.com/weaviate/weaviate/entities/modelsext"
 	"github.com/weaviate/weaviate/entities/vectorindex"
 
 	"github.com/weaviate/weaviate/entities/additional"
@@ -143,7 +144,7 @@ func (m *autoSchemaManager) createClass(ctx context.Context, principal *models.P
 		Properties:  properties,
 		Description: "This property was generated by Weaviate's auto-schema feature on " + now.Format(time.ANSIC),
 		VectorConfig: map[string]models.VectorConfig{
-			schema.DefaultNamedVectorName: {
+			modelsext.DefaultNamedVectorName: {
 				VectorIndexType: vectorindex.DefaultVectorIndexType,
 				Vectorizer:      map[string]any{m.defaultVectorizer: map[string]any{}},
 			},
diff --git a/usecases/objects/validation/vector_validation.go b/usecases/objects/validation/vector_validation.go
index 2a681227d6..3f0ee702c1 100644
--- a/usecases/objects/validation/vector_validation.go
+++ b/usecases/objects/validation/vector_validation.go
@@ -16,8 +16,7 @@ import (
 	"fmt"
 
 	"github.com/weaviate/weaviate/entities/models"
-	"github.com/weaviate/weaviate/entities/schema"
-	schemachecks "github.com/weaviate/weaviate/entities/schema/checks"
+	"github.com/weaviate/weaviate/entities/modelsext"
 )
 
 func (v *Validator) vector(ctx context.Context, class *models.Class,
@@ -25,16 +24,16 @@ func (v *Validator) vector(ctx context.Context, class *models.Class,
 ) error {
 	// In case the schema has a legacy vector index, we need to check if there is a default named vector which
 	// is used to transport legacy vector via the named vector API.
-	if defaultVec, ok := incomingObject.Vectors[schema.DefaultNamedVectorName]; ok && schemachecks.HasLegacyVectorIndex(class) {
+	if defaultVec, ok := incomingObject.Vectors[modelsext.DefaultNamedVectorName]; ok && modelsext.ClassHasLegacyVectorIndex(class) {
 		vec, ok := defaultVec.([]float32)
 		if !ok {
-			return fmt.Errorf("vector %s has to be a float32 array", schema.DefaultNamedVectorName)
+			return fmt.Errorf("vector %s has to be a float32 array", modelsext.DefaultNamedVectorName)
 		}
 		incomingObject.Vector = vec
-		delete(incomingObject.Vectors, schema.DefaultNamedVectorName)
+		delete(incomingObject.Vectors, modelsext.DefaultNamedVectorName)
 	}
 
-	if !schemachecks.HasLegacyVectorIndex(class) && len(incomingObject.Vector) > 0 {
+	if !modelsext.ClassHasLegacyVectorIndex(class) && len(incomingObject.Vector) > 0 {
 		// if there is only one named vector we can assume that the single vector
 		if len(class.VectorConfig) == 1 {
 			namedVectorName := ""
diff --git a/usecases/schema/class.go b/usecases/schema/class.go
index 27121fba0d..29a8c0472e 100644
--- a/usecases/schema/class.go
+++ b/usecases/schema/class.go
@@ -21,7 +21,7 @@ import (
 
 	"github.com/pkg/errors"
 	"github.com/prometheus/client_golang/prometheus"
-	schemachecks "github.com/weaviate/weaviate/entities/schema/checks"
+	"github.com/weaviate/weaviate/entities/modelsext"
 
 	"github.com/weaviate/weaviate/adapters/repos/db/inverted/stopwords"
 	"github.com/weaviate/weaviate/entities/backup"
@@ -331,7 +331,7 @@ func (h *Handler) setClassDefaults(class *models.Class, globalCfg replication.Gl
 	// set legacy vector index defaults only when:
 	// 	- no target vectors are configured
 	//  - OR, there are target vectors configured AND there is a legacy vector configured
-	if !hasTargetVectors(class) || schemachecks.HasLegacyVectorIndex(class) {
+	if !hasTargetVectors(class) || modelsext.ClassHasLegacyVectorIndex(class) {
 		if class.Vectorizer == "" {
 			class.Vectorizer = h.config.DefaultVectorizerModule
 		}
@@ -639,7 +639,7 @@ func setInvertedConfigDefaults(class *models.Class) {
 func (h *Handler) validateCanAddClass(ctx context.Context, class *models.Class, classGetterWithAuth func(string) (*models.Class, error),
 	relaxCrossRefValidation bool,
 ) error {
-	if schemachecks.HasLegacyVectorIndex(class) && len(class.VectorConfig) > 0 {
+	if modelsext.ClassHasLegacyVectorIndex(class) && len(class.VectorConfig) > 0 {
 		return fmt.Errorf("creating a class with both a class level vector index and named vectors is forbidden")
 	}
 
@@ -776,7 +776,7 @@ func (h *Handler) validatePropertyIndexing(prop *models.Property) error {
 }
 
 func (h *Handler) validateVectorSettings(class *models.Class) error {
-	if schemachecks.HasLegacyVectorIndex(class) {
+	if modelsext.ClassHasLegacyVectorIndex(class) {
 		if err := h.validateVectorIndexType(class.VectorIndexType); err != nil {
 			return err
 		}
diff --git a/usecases/schema/parser.go b/usecases/schema/parser.go
index a022128cd8..fc48ad5498 100644
--- a/usecases/schema/parser.go
+++ b/usecases/schema/parser.go
@@ -20,8 +20,8 @@ import (
 
 	"github.com/pkg/errors"
 	"github.com/weaviate/weaviate/entities/models"
+	"github.com/weaviate/weaviate/entities/modelsext"
 	"github.com/weaviate/weaviate/entities/schema"
-	schemachecks "github.com/weaviate/weaviate/entities/schema/checks"
 	schemaConfig "github.com/weaviate/weaviate/entities/schema/config"
 	"github.com/weaviate/weaviate/entities/vectorindex"
 	"github.com/weaviate/weaviate/usecases/config"
@@ -506,10 +506,10 @@ func (p *Parser) validateNamedVectorConfigsParityAndImmutables(initial, updated
 		}
 	}
 
-	if schemachecks.HasLegacyVectorIndex(initial) {
+	if modelsext.ClassHasLegacyVectorIndex(initial) {
 		for targetVector := range updated.VectorConfig {
-			if targetVector == schema.DefaultNamedVectorName {
-				return fmt.Errorf("vector named %s cannot be created when collection level vector index is configured", schema.DefaultNamedVectorName)
+			if targetVector == modelsext.DefaultNamedVectorName {
+				return fmt.Errorf("vector named %s cannot be created when collection level vector index is configured", modelsext.DefaultNamedVectorName)
 			}
 		}
 	}
diff --git a/usecases/traverser/target_vector_param_helper.go b/usecases/traverser/target_vector_param_helper.go
index f4ca902c96..6d9f083590 100644
--- a/usecases/traverser/target_vector_param_helper.go
+++ b/usecases/traverser/target_vector_param_helper.go
@@ -15,9 +15,9 @@ import (
 	"fmt"
 
 	"github.com/weaviate/weaviate/entities/dto"
+	"github.com/weaviate/weaviate/entities/modelsext"
 	"github.com/weaviate/weaviate/entities/modulecapabilities"
 	"github.com/weaviate/weaviate/entities/schema"
-	schemachecks "github.com/weaviate/weaviate/entities/schema/checks"
 )
 
 type TargetVectorParamHelper struct{}
@@ -32,7 +32,7 @@ func (t *TargetVectorParamHelper) GetTargetVectorOrDefault(sch schema.Schema, cl
 
 		// If no target vectors provided, check whether legacy vector is configured.
 		// For backwards compatibility, we have to return legacy vector in case no named vectors configured.
-		if schemachecks.HasLegacyVectorIndex(class) || len(class.VectorConfig) == 0 {
+		if modelsext.ClassHasLegacyVectorIndex(class) || len(class.VectorConfig) == 0 {
 			return []string{""}, nil
 		}
 


Test patch:
diff --git a/adapters/repos/db/vector/compressionhelpers/kmeans_test.go b/adapters/repos/db/vector/compressionhelpers/kmeans_encoder_test.go
similarity index 70%
rename from adapters/repos/db/vector/compressionhelpers/kmeans_test.go
rename to adapters/repos/db/vector/compressionhelpers/kmeans_encoder_test.go
index 49a84638b0..47d3fb5b0a 100644
--- a/adapters/repos/db/vector/compressionhelpers/kmeans_test.go
+++ b/adapters/repos/db/vector/compressionhelpers/kmeans_encoder_test.go
@@ -23,8 +23,8 @@ import (
 	testinghelpers "github.com/weaviate/weaviate/adapters/repos/db/vector/testinghelpers"
 )
 
-func Test_NoRaceKMeansNNearest(t *testing.T) {
-	distanceProvider := distancer.NewL2SquaredProvider()
+func TestKMeansEncoderEncodesToNearestCentroid(t *testing.T) {
+	l2 := distancer.NewL2SquaredProvider()
 	vectors := [][]float32{
 		{0, 5},
 		{0.1, 4.9},
@@ -33,34 +33,34 @@ func Test_NoRaceKMeansNNearest(t *testing.T) {
 		{5.1, 2},
 		{5.0, 2.1},
 	}
-	kmeans := compressionhelpers.NewKMeans(
+	encoder := compressionhelpers.NewKMeansEncoder(
 		3,
 		2,
 		0,
 	)
-	kmeans.Fit(vectors)
+	encoder.Fit(vectors)
 	centers := make([]byte, 6)
 	for i := range centers {
-		centers[i] = byte(kmeans.Nearest(vectors[i]))
+		centers[i] = encoder.Encode(vectors[i])
 	}
 	for v := range vectors {
-		min, _ := distanceProvider.SingleDist(vectors[v], kmeans.Centroid(centers[v]))
+		min, _ := l2.SingleDist(vectors[v], encoder.Centroid(centers[v]))
 		for c := range centers {
-			dist, _ := distanceProvider.SingleDist(vectors[v], kmeans.Centroid(centers[c]))
+			dist, _ := l2.SingleDist(vectors[v], encoder.Centroid(centers[c]))
 			assert.True(t, dist >= min)
 		}
 	}
 }
 
-func Test_NoRaceRandomData(t *testing.T) {
+func TestKMeansEncoderTerminatesOnRandomData(t *testing.T) {
 	vectorsSize := 10000
 	vectors, _ := testinghelpers.RandomVecs(vectorsSize, 0, 128)
 	before := time.Now()
-	kmeans := compressionhelpers.NewKMeans(
+	encoder := compressionhelpers.NewKMeansEncoder(
 		256,
 		1,
 		10,
 	)
-	kmeans.Fit(vectors)
+	encoder.Fit(vectors)
 	assert.True(t, time.Since(before).Seconds() < 50)
 }
diff --git a/adapters/repos/db/vector/hnsw/condensor_integration_test.go b/adapters/repos/db/vector/hnsw/condensor_integration_test.go
index a8358f8092..21860d19fe 100644
--- a/adapters/repos/db/vector/hnsw/condensor_integration_test.go
+++ b/adapters/repos/db/vector/hnsw/condensor_integration_test.go
@@ -667,19 +667,19 @@ func TestCondensorWithPQInformation(t *testing.T) {
 	defer uncondensed.Shutdown(ctx)
 
 	encoders := []compressionhelpers.PQEncoder{
-		compressionhelpers.NewKMeansWithCenters(
+		compressionhelpers.NewKMeansEncoderWithCenters(
 			4,
 			2,
 			0,
 			[][]float32{{1, 2}, {3, 4}, {5, 6}, {7, 8}},
 		),
-		compressionhelpers.NewKMeansWithCenters(
+		compressionhelpers.NewKMeansEncoderWithCenters(
 			4,
 			2,
 			1,
 			[][]float32{{8, 7}, {6, 5}, {4, 3}, {2, 1}},
 		),
-		compressionhelpers.NewKMeansWithCenters(
+		compressionhelpers.NewKMeansEncoderWithCenters(
 			4,
 			2,
 			2,
diff --git a/adapters/repos/db/vector/hnsw/deserializer_test.go b/adapters/repos/db/vector/hnsw/deserializer_test.go
index d557cd0009..aacc2f8245 100644
--- a/adapters/repos/db/vector/hnsw/deserializer_test.go
+++ b/adapters/repos/db/vector/hnsw/deserializer_test.go
@@ -454,7 +454,7 @@ func TestDeserializerTotalReadPQ(t *testing.T) {
 		data, _ := testinghelpers.RandomVecs(20, 0, dimensions)
 		kms := make([]compressionhelpers.PQEncoder, 4)
 		for i := 0; i < 4; i++ {
-			kms[i] = compressionhelpers.NewKMeans(
+			kms[i] = compressionhelpers.NewKMeansEncoder(
 				dimensions,
 				4,
 				int(i),
diff --git a/adapters/repos/db/vector/hnsw/multivector_hnsw_test.go b/adapters/repos/db/vector/hnsw/multivector_hnsw_test.go
index e8d841d9d9..2f2bb25396 100644
--- a/adapters/repos/db/vector/hnsw/multivector_hnsw_test.go
+++ b/adapters/repos/db/vector/hnsw/multivector_hnsw_test.go
@@ -89,10 +89,11 @@ func TestMultiVectorHnsw(t *testing.T) {
 				return multiVectors[id], nil
 			},
 		}, ent.UserConfig{
-			MaxConnections: maxConnections,
-			EFConstruction: efConstruction,
-			EF:             ef,
-			Multivector:    ent.MultivectorConfig{Enabled: true},
+			VectorCacheMaxObjects: 1e12,
+			MaxConnections:        maxConnections,
+			EFConstruction:        efConstruction,
+			EF:                    ef,
+			Multivector:           ent.MultivectorConfig{Enabled: true},
 		}, cyclemanager.NewCallbackGroupNoop(), testinghelpers.NewDummyStore(t))
 		require.Nil(t, err)
 		vectorIndex = index
@@ -225,10 +226,11 @@ func TestMultiVectorCompressHnsw(t *testing.T) {
 					return multiVectors[id], nil
 				},
 			}, ent.UserConfig{
-				MaxConnections: maxConnections,
-				EFConstruction: efConstruction,
-				EF:             ef,
-				Multivector:    ent.MultivectorConfig{Enabled: true},
+				VectorCacheMaxObjects: 1e12,
+				MaxConnections:        maxConnections,
+				EFConstruction:        efConstruction,
+				EF:                    ef,
+				Multivector:           ent.MultivectorConfig{Enabled: true},
 			},
 				cyclemanager.NewCallbackGroupNoop(), testinghelpers.NewDummyStore(t))
 			require.Nil(t, err)
@@ -263,10 +265,11 @@ func TestMultiVectorBQHnsw(t *testing.T) {
 				return multiVectors[id], nil
 			},
 		}, ent.UserConfig{
-			MaxConnections: maxConnections,
-			EFConstruction: efConstruction,
-			EF:             ef,
-			Multivector:    ent.MultivectorConfig{Enabled: true},
+			VectorCacheMaxObjects: 1e12,
+			MaxConnections:        maxConnections,
+			EFConstruction:        efConstruction,
+			EF:                    ef,
+			Multivector:           ent.MultivectorConfig{Enabled: true},
 			// BQ:             ent.BQConfig{Enabled: true},
 		}, cyclemanager.NewCallbackGroupNoop(), testinghelpers.NewDummyStore(t))
 		require.Nil(t, err)
@@ -277,9 +280,10 @@ func TestMultiVectorBQHnsw(t *testing.T) {
 			require.Nil(t, err)
 		}
 		uc := ent.UserConfig{
-			MaxConnections: maxConnections,
-			EFConstruction: efConstruction,
-			EF:             ef,
+			VectorCacheMaxObjects: 1e12,
+			MaxConnections:        maxConnections,
+			EFConstruction:        efConstruction,
+			EF:                    ef,
 			Multivector: ent.MultivectorConfig{
 				Enabled: true,
 			},
@@ -351,9 +355,10 @@ func TestMultivectorPersistence(t *testing.T) {
 			return multiVectors[id], nil
 		},
 	}, ent.UserConfig{
-		MaxConnections: maxConnections,
-		EFConstruction: efConstruction,
-		EF:             ef,
+		VectorCacheMaxObjects: 1e12,
+		MaxConnections:        maxConnections,
+		EFConstruction:        efConstruction,
+		EF:                    ef,
 		Multivector: ent.MultivectorConfig{
 			Enabled: true,
 		},
@@ -392,9 +397,10 @@ func TestMultivectorPersistence(t *testing.T) {
 			return multiVectors[id], nil
 		},
 	}, ent.UserConfig{
-		MaxConnections: maxConnections,
-		EFConstruction: efConstruction,
-		EF:             ef,
+		VectorCacheMaxObjects: 1e12,
+		MaxConnections:        maxConnections,
+		EFConstruction:        efConstruction,
+		EF:                    ef,
 		Multivector: ent.MultivectorConfig{
 			Enabled: true,
 		},
diff --git a/adapters/repos/db/vector/kmeans/kmeans_test.go b/adapters/repos/db/vector/kmeans/kmeans_test.go
new file mode 100644
index 0000000000..b32316c904
--- /dev/null
+++ b/adapters/repos/db/vector/kmeans/kmeans_test.go
@@ -0,0 +1,317 @@
+//                           _       _
+// __      _____  __ ___   ___  __ _| |_ ___
+// \ \ /\ / / _ \/ _` \ \ / / |/ _` | __/ _ \
+//  \ V  V /  __/ (_| |\ V /| | (_| | ||  __/
+//   \_/\_/ \___|\__,_| \_/ |_|\__,_|\__\___|
+//
+//  Copyright © 2016 - 2024 Weaviate B.V. All rights reserved.
+//
+//  CONTACT: hello@weaviate.io
+//
+
+package kmeans
+
+import (
+	"fmt"
+	"math"
+	"math/rand/v2"
+	"slices"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/weaviate/weaviate/adapters/repos/db/vector/hnsw/distancer"
+)
+
+type Distribution string
+
+const (
+	uniform Distribution = "uniform"
+	normal  Distribution = "normal"
+)
+
+const (
+	seed uint64 = 0x6a62976fa43149c9
+)
+
+func generateData(n int, d int, dist Distribution, seed uint64) [][]float32 {
+	r := rand.New(rand.NewPCG(seed, 8523456))
+	data := make([][]float32, n)
+	for i := range n {
+		data[i] = make([]float32, d)
+		for j := range d {
+			switch dist {
+			case uniform:
+				data[i][j] = r.Float32()
+			case normal:
+				data[i][j] = float32(r.NormFloat64())
+			}
+		}
+	}
+	return data
+}
+
+type KMeansVariant struct {
+	Initialization InitializationStrategy
+	Assignment     AssignmentStrategy
+}
+
+var kMeansVariants = [...]KMeansVariant{
+	{Initialization: RandomInitialization, Assignment: BruteForce},
+	{Initialization: RandomInitialization, Assignment: GraphPruning},
+	{Initialization: PlusPlusInitialization, Assignment: BruteForce},
+	{Initialization: PlusPlusInitialization, Assignment: GraphPruning},
+}
+
+// Convenient instantiation of KMeans for testing.
+func newKMeans(k int, d int, variant KMeansVariant) *KMeans {
+	kmeans := New(k, d, 0)
+	kmeans.Initialization = variant.Initialization
+	kmeans.Assignment = variant.Assignment
+	return kmeans
+}
+
+// By default we seed KMeans with a call to the global random number generator.
+// For some tests we want deterministic behavior for stability, so we explicitly
+// set the seed.
+func newDeterministicKMeans(k int, d int, variant KMeansVariant) *KMeans {
+	kmeans := newKMeans(k, d, variant)
+	kmeans.Seed = seed
+	return kmeans
+}
+
+func TestIterationThreshold(t *testing.T) {
+	n := 100
+	d := 4
+	k := 8
+	data := generateData(n, d, normal, seed)
+	maxIterations := 5
+	for _, variant := range kMeansVariants {
+		for i := range maxIterations {
+			km := newKMeans(k, d, variant)
+			km.DisableDeltaThreshold()
+			km.IterationThreshold = i
+			km.Fit(data)
+			assert.Equal(t, km.Metrics.Iterations, i)
+			assert.Equal(t, len(km.Metrics.WCSS), i,
+				"The length of the per-iteration metric slices should match the number of iterations.")
+			assert.Equal(t, km.Metrics.Termination, MaxIterations)
+		}
+	}
+}
+
+func TestDeltaThreshold(t *testing.T) {
+	n := 100
+	d := 4
+	k := 8
+	data := generateData(n, d, normal, seed)
+
+	for _, variant := range kMeansVariants {
+		km := newDeterministicKMeans(k, d, variant)
+		km.DisableIterationThreshold()
+		// With a threshold of zero we iterate until clusters are perfectly stable.
+		km.DeltaThreshold = 0.0
+		km.Fit(data)
+		assert.Greater(t, km.Metrics.Iterations, 1)
+		assert.Less(t, km.Metrics.Iterations, 100)
+		assert.Equal(t, km.Metrics.Termination, ClusterStability)
+		assert.Equal(t, km.Metrics.Changes[len(km.Metrics.Changes)-1], 0)
+	}
+}
+
+// The k-means objective functions is the Within-Cluster Sum of Squares (WCSS).
+// With LLoyd's algorithm it is guaranteed to decrease with each iteration.
+func TestDecreasingWCSS(t *testing.T) {
+	n := 1000
+	d := 8
+	k := 16
+	data := generateData(n, d, normal, seed)
+
+	for _, variant := range kMeansVariants {
+		km := newDeterministicKMeans(k, d, variant)
+		km.Fit(data)
+		slices.Reverse(km.Metrics.WCSS)
+		assert.True(t, slices.IsSorted(km.Metrics.WCSS))
+	}
+}
+
+// The GraphPruning assignment strategy only prunes away unnecessary distance
+// computations. We should be producing the same result up to floating point
+// errors and randomness in tie breaking. With random data ties are highly
+// unlikely so any differences are likely due to a bug. Note that this test is a
+// bit too tied to the implementation in that it requires exact equality of the
+// centers, but it does the job for now.
+func TestGraphPruningAssignment(t *testing.T) {
+	n := 1000
+	d := 8
+	k := 32
+	data := generateData(n, d, normal, seed)
+
+	for _, init := range [...]InitializationStrategy{RandomInitialization, PlusPlusInitialization} {
+		bf := newDeterministicKMeans(k, d, KMeansVariant{init, BruteForce})
+		bf.Fit(data)
+
+		prune := newDeterministicKMeans(k, d, KMeansVariant{init, GraphPruning})
+		prune.Fit(data)
+
+		for i := range k {
+			assert.True(t, slices.Equal(bf.Centers[i], prune.Centers[i]))
+		}
+	}
+}
+
+func contains(data [][]float32, q []float32) bool {
+	const eps = 1e-12
+	l2 := distancer.NewL2SquaredProvider()
+	for _, x := range data {
+		dist, _ := l2.SingleDist(x, q)
+		if dist < eps {
+			return true
+		}
+	}
+	return false
+}
+
+func TestCorrectnessAcrossSegments(t *testing.T) {
+	// Create a dataset with two segments of two dimensions each.
+	// The first segment has clusters centered at (1,1) and (-1, -1).
+	// The second segment has clusters centered at (-1, 1) and (1, -1).
+	data := [][]float32{
+		{0.99, 0.99, -0.99, 0.99},
+		{1.01, 1.01, -1.01, 1.01},
+		{-0.99, -0.99, 0.99, -0.99},
+		{-1.01, -1.01, 1.01, -1.01},
+	}
+
+	k := 2
+	d := 2
+	for _, variant := range kMeansVariants {
+		// Run k-means clustering on each segment and assert that we find the true
+		km := newDeterministicKMeans(k, d, variant)
+		km.segment = 0
+		km.Fit(data)
+		assert.True(t, contains(km.Centers, []float32{1, 1}))
+		assert.True(t, contains(km.Centers, []float32{-1, -1}))
+
+		km = newDeterministicKMeans(k, d, variant)
+		km.segment = 1
+		km.Fit(data)
+		assert.True(t, contains(km.Centers, []float32{-1, 1}))
+		assert.True(t, contains(km.Centers, []float32{1, -1}))
+	}
+}
+
+// Create a new data set that duplicates each data point k times and shuffles
+// the data. Verify that with k-means++ initialization we end up with the
+// original data points as centers. With random initialization we would be
+// extremely unlikely to capture the original k centers.
+func TestPlusPlusInitialization(t *testing.T) {
+	k := 100
+	d := 64
+	centers := generateData(k, d, uniform, seed)
+
+	data := make([][]float32, k*k)
+	for i := range data {
+		data[i] = make([]float32, d)
+		copy(data[i], centers[i/k])
+	}
+
+	r := rand.New(rand.NewPCG(seed, 8523456))
+	r.Shuffle(len(data), func(i, j int) {
+		data[i], data[j] = data[j], data[i]
+	})
+
+	km := newDeterministicKMeans(k, d, KMeansVariant{PlusPlusInitialization, BruteForce})
+	km.IterationThreshold = 0 // Only perform initialization.
+	km.Fit(data)
+	for _, trueCenter := range centers {
+		var found bool
+		for _, kmCenter := range km.Centers {
+			dist := km.l2squared(trueCenter, kmCenter)
+			if dist < 1e-12 {
+				found = true
+				break
+			}
+		}
+		assert.True(t, found, "k-means++ failed to sample all true centers.")
+	}
+}
+
+func wcss(data [][]float32, centers [][]float32) float64 {
+	l2s := distancer.NewL2SquaredProvider()
+	var sum float64
+	for _, v := range data {
+		var minDist float32 = math.MaxFloat32
+		for _, c := range centers {
+			if dist, _ := l2s.SingleDist(v, c); dist < minDist {
+				minDist = dist
+			}
+		}
+		sum += float64(minDist)
+	}
+	return sum
+}
+
+func TestFewDataPoints(t *testing.T) {
+	n := 10
+	d := 8
+	k := n
+	data := generateData(n, d, uniform, seed)
+
+	// With k = n we all variants should select the original data points as
+	// cluster centers, resulting in a WCSS of zero.
+	for _, variant := range kMeansVariants {
+		km := newDeterministicKMeans(k, d, variant)
+		km.Fit(data)
+		assert.Equal(t, wcss(data, km.Centers), 0.0)
+	}
+}
+
+func TestOneCenter(t *testing.T) {
+	data := [][]float32{
+		{1, 0},
+		{0, 1},
+		{1, 1},
+		{0, 0},
+	}
+	for _, variant := range kMeansVariants {
+		km := newDeterministicKMeans(1, 2, variant)
+		km.Fit(data)
+		assert.Equal(t, len(km.Centers), 1)
+		assert.True(t, slices.Equal(km.Centers[0], []float32{0.5, 0.5}))
+	}
+}
+
+func BenchmarkKMeansFit(b *testing.B) {
+	distribution := []Distribution{normal, uniform}
+	dimensions := []int{4, 8, 16, 32}
+	k := 256
+	n := 100_000
+	var seed uint64
+	for _, dist := range distribution {
+		for _, d := range dimensions {
+			data := generateData(n, d, dist, seed)
+			seed++
+			for _, variant := range kMeansVariants {
+				b.Run(fmt.Sprintf("KMeansFit-%v-d%d-%v-%v", dist, d, variant.Initialization, variant.Assignment), func(b *testing.B) {
+					var computations int
+					var WCSS float64
+					var iterations int
+					for i := 0; i < b.N; i++ {
+						km := newDeterministicKMeans(k, d, variant)
+						km.IterationThreshold = 50
+						b.StartTimer()
+						km.Fit(data)
+						b.StopTimer()
+						iterations += km.Metrics.Iterations
+						WCSS += wcss(data, km.Centers)
+						computations += km.Metrics.TotalComputations()
+					}
+					b.ReportMetric(float64(b.Elapsed().Seconds())/float64(b.N), "sec/fit") // More readable than ns.
+					b.ReportMetric(float64(iterations)/float64(b.N), "iter")
+					b.ReportMetric(WCSS/float64(b.N), "wcss")
+					b.ReportMetric(float64(computations), "distcomps")
+				})
+			}
+		}
+	}
+}
diff --git a/entities/schema/checks/class_test.go b/entities/modelsext/class_test.go
similarity index 89%
rename from entities/schema/checks/class_test.go
rename to entities/modelsext/class_test.go
index 811e91d52a..6da0c3a836 100644
--- a/entities/schema/checks/class_test.go
+++ b/entities/modelsext/class_test.go
@@ -9,7 +9,7 @@
 //  CONTACT: hello@weaviate.io
 //
 
-package checks
+package modelsext
 
 import (
 	"testing"
@@ -18,7 +18,7 @@ import (
 	"github.com/weaviate/weaviate/entities/models"
 )
 
-func TestHasLegacyVectorIndex(t *testing.T) {
+func TestClassHasLegacyVectorIndex(t *testing.T) {
 	for _, tt := range []struct {
 		name  string
 		class *models.Class
@@ -56,7 +56,7 @@ func TestHasLegacyVectorIndex(t *testing.T) {
 		},
 	} {
 		t.Run(tt.name, func(t *testing.T) {
-			require.Equal(t, tt.want, HasLegacyVectorIndex(tt.class))
+			require.Equal(t, tt.want, ClassHasLegacyVectorIndex(tt.class))
 		})
 	}
 }
diff --git a/test/acceptance_with_go_client/named_vectors_tests/test_suits/mixed_vectors_name_forwarding.go b/test/acceptance_with_go_client/named_vectors_tests/test_suits/mixed_vectors_name_forwarding.go
index bb2b14e308..129030c78d 100644
--- a/test/acceptance_with_go_client/named_vectors_tests/test_suits/mixed_vectors_name_forwarding.go
+++ b/test/acceptance_with_go_client/named_vectors_tests/test_suits/mixed_vectors_name_forwarding.go
@@ -19,6 +19,7 @@ import (
 	"github.com/stretchr/testify/require"
 	wvt "github.com/weaviate/weaviate-go-client/v5/weaviate"
 	"github.com/weaviate/weaviate/entities/models"
+	"github.com/weaviate/weaviate/entities/modelsext"
 )
 
 func testMixedVectorsNamedForwarding(endpoint string) func(t *testing.T) {
@@ -52,7 +53,7 @@ func testMixedVectorsNamedForwarding(endpoint string) func(t *testing.T) {
 					"text": "Lorem ipsum dolor sit amet",
 				}).
 				WithVectors(map[string]models.Vector{
-					"default": []float32{1, 2, 3},
+					modelsext.DefaultNamedVectorName: []float32{1, 2, 3},
 				}).
 				Do(ctx)
 			require.NoError(t, err)
@@ -63,7 +64,7 @@ func testMixedVectorsNamedForwarding(endpoint string) func(t *testing.T) {
 				WithClassName(class.Class).
 				WithID(UUID1).
 				WithVectors(map[string]models.Vector{
-					"default": []float32{4, 5, 6},
+					modelsext.DefaultNamedVectorName: []float32{4, 5, 6},
 				}).
 				Do(ctx)
 			require.NoError(t, err)
@@ -80,7 +81,7 @@ func testMixedVectorsNamedForwarding(endpoint string) func(t *testing.T) {
 						"text": "Lorem ipsum dolor sit amet",
 					},
 					Vectors: map[string]models.Vector{
-						"default": []float32{1, 2, 3},
+						modelsext.DefaultNamedVectorName: []float32{1, 2, 3},
 					},
 				}).
 				Do(ctx)
diff --git a/test/acceptance_with_go_client/named_vectors_tests/test_suits/mixed_vectors_schema.go b/test/acceptance_with_go_client/named_vectors_tests/test_suits/mixed_vectors_schema.go
index 45113792bb..3297763835 100644
--- a/test/acceptance_with_go_client/named_vectors_tests/test_suits/mixed_vectors_schema.go
+++ b/test/acceptance_with_go_client/named_vectors_tests/test_suits/mixed_vectors_schema.go
@@ -13,12 +13,14 @@ package test_suits
 
 import (
 	"context"
+	"fmt"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	wvt "github.com/weaviate/weaviate-go-client/v5/weaviate"
 	"github.com/weaviate/weaviate/entities/models"
+	"github.com/weaviate/weaviate/entities/modelsext"
 	"github.com/weaviate/weaviate/entities/schema"
 )
 
@@ -237,7 +239,7 @@ func testMixedVectorsCreateSchema(host string) func(t *testing.T) {
 			require.NoError(t, client.Schema().ClassCreator().WithClass(class).Do(ctx))
 
 			class.VectorConfig = map[string]models.VectorConfig{
-				"default": {
+				modelsext.DefaultNamedVectorName: {
 					Vectorizer:      map[string]interface{}{text2vecContextionary: map[string]interface{}{}},
 					VectorIndexType: "hnsw",
 				},
@@ -245,7 +247,7 @@ func testMixedVectorsCreateSchema(host string) func(t *testing.T) {
 
 			err = client.Schema().ClassUpdater().WithClass(class).Do(ctx)
 			require.ErrorContains(t, err, "422")
-			require.ErrorContains(t, err, "vector named default cannot be created when collection level vector index is configured")
+			require.ErrorContains(t, err, fmt.Sprintf("vector named %s cannot be created when collection level vector index is configured", modelsext.DefaultNamedVectorName))
 		})
 	}
 }
diff --git a/test/helper/objects.go b/test/helper/objects.go
index 7247b977e4..5d52663883 100644
--- a/test/helper/objects.go
+++ b/test/helper/objects.go
@@ -19,7 +19,7 @@ import (
 	"github.com/go-openapi/runtime"
 	"github.com/go-openapi/strfmt"
 	"github.com/stretchr/testify/assert"
-	schemachecks "github.com/weaviate/weaviate/entities/schema/checks"
+	"github.com/weaviate/weaviate/entities/modelsext"
 
 	"github.com/weaviate/weaviate/client/batch"
 	"github.com/weaviate/weaviate/client/meta"
@@ -56,7 +56,7 @@ func CreateClass(t *testing.T, class *models.Class) {
 
 	// if the schema has mixed vectors, we have to create it in two steps as single step creation is forbidden
 	var capturedVectorConfig map[string]models.VectorConfig
-	if schemachecks.HasLegacyVectorIndex(class) && class.VectorConfig != nil {
+	if modelsext.ClassHasLegacyVectorIndex(class) && class.VectorConfig != nil {
 		capturedVectorConfig = class.VectorConfig
 		class.VectorConfig = nil
 	}
diff --git a/usecases/objects/auto_schema_test.go b/usecases/objects/auto_schema_test.go
index 9c10e809a8..7d85a0f5ac 100644
--- a/usecases/objects/auto_schema_test.go
+++ b/usecases/objects/auto_schema_test.go
@@ -23,6 +23,7 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
+	"github.com/weaviate/weaviate/entities/modelsext"
 
 	"github.com/weaviate/weaviate/entities/models"
 	"github.com/weaviate/weaviate/entities/schema"
@@ -676,8 +677,8 @@ func Test_autoSchemaManager_autoSchema_create(t *testing.T) {
 	assert.Equal(t, "numberArray", getProperty(class.Properties, "numberArray").Name)
 	assert.Equal(t, "number[]", getProperty(class.Properties, "numberArray").DataType[0])
 	require.Len(t, class.VectorConfig, 1)
-	require.Contains(t, class.VectorConfig, schema.DefaultNamedVectorName)
-	require.Contains(t, class.VectorConfig[schema.DefaultNamedVectorName].Vectorizer, defaultVectorizer)
+	require.Contains(t, class.VectorConfig, modelsext.DefaultNamedVectorName)
+	require.Contains(t, class.VectorConfig[modelsext.DefaultNamedVectorName].Vectorizer, defaultVectorizer)
 }
 
 func Test_autoSchemaManager_autoSchema_update(t *testing.T) {
diff --git a/usecases/objects/batch_add_test.go b/usecases/objects/batch_add_test.go
index 751a48c93d..e8c85144a9 100644
--- a/usecases/objects/batch_add_test.go
+++ b/usecases/objects/batch_add_test.go
@@ -22,6 +22,7 @@ import (
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
 	"github.com/weaviate/weaviate/entities/models"
+	"github.com/weaviate/weaviate/entities/modelsext"
 	"github.com/weaviate/weaviate/entities/schema"
 	"github.com/weaviate/weaviate/entities/vectorindex/hnsw"
 	"github.com/weaviate/weaviate/usecases/auth/authorization/mocks"
@@ -184,9 +185,9 @@ func Test_BatchManager_AddObjects_WithNoVectorizerModule(t *testing.T) {
 			"a uuid was set for the second object")
 		assert.Nil(t, repoCalledWithObjects[0].Err)
 		assert.Nil(t, repoCalledWithObjects[1].Err)
-		assert.Equal(t, []float32{0.1, 0.1, 0.1111}, repoCalledWithObjects[0].Object.Vectors[schema.DefaultNamedVectorName],
+		assert.Equal(t, []float32{0.1, 0.1, 0.1111}, repoCalledWithObjects[0].Object.Vectors[modelsext.DefaultNamedVectorName],
 			"the correct vector was used")
-		assert.Equal(t, []float32{0.2, 0.2, 0.2222}, repoCalledWithObjects[1].Object.Vectors[schema.DefaultNamedVectorName],
+		assert.Equal(t, []float32{0.2, 0.2, 0.2222}, repoCalledWithObjects[1].Object.Vectors[modelsext.DefaultNamedVectorName],
 			"the correct vector was used")
 	})
 
diff --git a/usecases/objects/validation/vector_validation_test.go b/usecases/objects/validation/vector_validation_test.go
index d6d4f2df37..54de789396 100644
--- a/usecases/objects/validation/vector_validation_test.go
+++ b/usecases/objects/validation/vector_validation_test.go
@@ -19,7 +19,7 @@ import (
 	"github.com/stretchr/testify/require"
 	"github.com/weaviate/weaviate/entities/additional"
 	"github.com/weaviate/weaviate/entities/models"
-	"github.com/weaviate/weaviate/entities/schema"
+	"github.com/weaviate/weaviate/entities/modelsext"
 )
 
 func TestVectors(t *testing.T) {
@@ -92,7 +92,7 @@ func TestVectors(t *testing.T) {
 				VectorConfig:    map[string]models.VectorConfig{"first": {}, "second": {}},
 			},
 			obj: &models.Object{
-				Vectors: models.Vectors{"first": []float32{1, 2, 3}, "second": []float32{4, 5, 6}, schema.DefaultNamedVectorName: []float32{7, 8, 9}},
+				Vectors: models.Vectors{"first": []float32{1, 2, 3}, "second": []float32{4, 5, 6}, modelsext.DefaultNamedVectorName: []float32{7, 8, 9}},
 			},
 			objNew: &models.Object{
 				Vectors: models.Vectors{"first": []float32{1, 2, 3}, "second": []float32{4, 5, 6}},
@@ -101,13 +101,13 @@ func TestVectors(t *testing.T) {
 		},
 		"default vector not touched in named vector class": {
 			class: &models.Class{
-				VectorConfig: map[string]models.VectorConfig{schema.DefaultNamedVectorName: {}},
+				VectorConfig: map[string]models.VectorConfig{modelsext.DefaultNamedVectorName: {}},
 			},
 			obj: &models.Object{
-				Vectors: models.Vectors{schema.DefaultNamedVectorName: []float32{1, 2, 3}},
+				Vectors: models.Vectors{modelsext.DefaultNamedVectorName: []float32{1, 2, 3}},
 			},
 			objNew: &models.Object{
-				Vectors: models.Vectors{schema.DefaultNamedVectorName: []float32{1, 2, 3}},
+				Vectors: models.Vectors{modelsext.DefaultNamedVectorName: []float32{1, 2, 3}},
 			},
 		},
 	}
diff --git a/usecases/schema/class_test.go b/usecases/schema/class_test.go
index 0944b42277..591c42b6a1 100644
--- a/usecases/schema/class_test.go
+++ b/usecases/schema/class_test.go
@@ -22,6 +22,7 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
 	"github.com/stretchr/testify/require"
+	"github.com/weaviate/weaviate/entities/modelsext"
 
 	"github.com/weaviate/weaviate/adapters/repos/db/helpers"
 	"github.com/weaviate/weaviate/adapters/repos/db/inverted/stopwords"
@@ -1830,7 +1831,7 @@ func Test_UpdateClass(t *testing.T) {
 					Vectorizer:      "text2vec-contextionary",
 					VectorIndexType: hnswT,
 					VectorConfig: map[string]models.VectorConfig{
-						schema.DefaultNamedVectorName: {
+						modelsext.DefaultNamedVectorName: {
 							VectorIndexType: hnswT,
 							Vectorizer: map[string]interface{}{
 								"text2vec-contextionary": map[string]interface{}{},
@@ -1838,7 +1839,7 @@ func Test_UpdateClass(t *testing.T) {
 						},
 					},
 				},
-				expectedError: fmt.Errorf("vector named %s cannot be created when collection level vector index is configured", schema.DefaultNamedVectorName),
+				expectedError: fmt.Errorf("vector named %s cannot be created when collection level vector index is configured", modelsext.DefaultNamedVectorName),
 			},
 		}