Repository: weaviate/weaviate (Go)

Issue:
[FR] Forbid updating the distance metric after schema creation
As of v1.19.9, Weaviate silently allows changing the `vectorIndexConfig.distance` via `client.schema.update_config`, even though that doesn't make sense, because the graph already built will no longer be valid.

It would improve the [developer experience](https://forum.weaviate.io/t/updating-vector-search-of-existing-class/249) to consider the distance immutable and return an explicit error when attempting to change it.

```python
import os
import json

import weaviate

client = weaviate.Client(
    'http://localhost:8080',
    additional_headers={'X-OpenAI-Api-Key': os.environ['OPENAI_APIKEY']}
)

# Create two classes using different distances, with the same one object in each, then search for the object.
# Note the negative dot distance for the corresponding object in the Article_dot class.
for distance in ['cosine', 'dot']:
    class_name = f'Article_{distance}'
    if client.schema.exists(class_name):
        client.schema.delete_class(class_name)
    client.schema.create_class({
        'class': class_name,
        'properties': [
            {'name': 'title', 'dataType': ['text']},
        ],
        'vectorizer': 'text2vec-openai',
        'vectorIndexConfig': {
            'distance': distance,
        },
    })

    # Test
    result = client.schema.get(class_name)
    assert result['vectorIndexConfig']['distance'] == distance

    client.data_object.create({
        'name': 'Dinosaurs disappeared millions of years ago'
    }, class_name)

    result = (
        client.query.get(class_name, ['name'])
        .with_near_text({'concepts': 'extinct mammals'}).with_additional('distance').do()
    )
    print(json.dumps(result['data']['Get'], indent=2))

modified_class = 'Article_cosine'
# Modify the distance for the Article_cosine class to be 'dot' and assert the modification was successful
client.schema.update_config(modified_class, {
    'vectorIndexConfig': {
        'distance': 'dot',
    },
})
result = client.schema.get(modified_class)
assert result['vectorIndexConfig']['distance'] == 'dot'  # parameter mutation accepted

# BUG? Searching for an indexed object should get the same distance as with the Article_dot class,
# but the distance is still the same as in the original Article_cosine class.
result = (
    client.query.get(modified_class, ['name'])
    .with_near_text({'concepts': 'extinct mammals'}).with_additional('distance').do()
)
print('🐛BUG, distance is ~0.17 instead of ~-0.83:', json.dumps(result['data']['Get'], indent=2))

# Maybe the new distance is returned only for objects inserted after modifying the distance? Delete all objects...
client.batch.delete_objects(modified_class, where={
    'path': ['name'],
    'operator': 'NotEqual',
    'valueText': 'NONEXISTENT'
})
result = client.query.aggregate(modified_class).with_meta_count().do()
assert result['data']['Aggregate'][modified_class] == [{'meta': {'count': 0}}]
# ... and recreate the one that triggered the bug
client.data_object.create({
    'name': 'Dinosaurs disappeared millions of years ago'
}, modified_class)
result = (
    client.query.get(modified_class, ['name'])
    .with_near_text({'concepts': 'extinct mammals'}).with_additional('distance').do()
)
print('🐛BUG, "dot" distance to recreated object is still ~0.17 instead of ~-0.83', json.dumps(result['data']['Get'], indent=2))

# Modify the distance for the Article_cosine class to be 'invalid' and assert the modification was successful
client.schema.update_config(modified_class, {
    # 'vectorIndexType': 'BOGUS',  # exception 'vector index type is immutable: attempted change from "hnsw" to "BOGUS"'
    'vectorIndexConfig': {
        'distance': 'BOGUS',
    },
})
result = client.schema.get('Article_cosine')
assert result['vectorIndexConfig']['distance'] == 'BOGUS'  # BUG? This should have returned an exception as well
```



Fix (patch):
diff --git a/adapters/repos/db/vector/hnsw/config_update.go b/adapters/repos/db/vector/hnsw/config_update.go
index 0b41836a888..5a292ad1550 100644
--- a/adapters/repos/db/vector/hnsw/config_update.go
+++ b/adapters/repos/db/vector/hnsw/config_update.go
@@ -30,14 +30,14 @@ func ValidateUserConfigUpdate(initial, updated schema.VectorIndexConfig) error {
 		return errors.Errorf("updated is not UserConfig, but %T", updated)
 	}
 
-	immutableFields := []immutableInt{
+	immutableFields := []immutableParameter{
 		{
 			name:     "efConstruction",
-			accessor: func(c ent.UserConfig) int { return c.EFConstruction },
+			accessor: func(c ent.UserConfig) interface{} { return c.EFConstruction },
 		},
 		{
 			name:     "maxConnections",
-			accessor: func(c ent.UserConfig) int { return c.MaxConnections },
+			accessor: func(c ent.UserConfig) interface{} { return c.MaxConnections },
 		},
 		{
 			// NOTE: There isn't a technical reason for this to be immutable, it
@@ -45,12 +45,16 @@ func ValidateUserConfigUpdate(initial, updated schema.VectorIndexConfig) error {
 			// current timer and start a new one. Certainly possible, but let's see
 			// if anyone actually needs this before implementing it.
 			name:     "cleanupIntervalSeconds",
-			accessor: func(c ent.UserConfig) int { return c.CleanupIntervalSeconds },
+			accessor: func(c ent.UserConfig) interface{} { return c.CleanupIntervalSeconds },
+		},
+		{
+			name:     "distance",
+			accessor: func(c ent.UserConfig) interface{} { return c.Distance },
 		},
 	}
 
 	for _, u := range immutableFields {
-		if err := validateImmutableIntField(u, initialParsed, updatedParsed); err != nil {
+		if err := validateImmutableField(u, initialParsed, updatedParsed); err != nil {
 			return err
 		}
 	}
@@ -58,18 +62,18 @@ func ValidateUserConfigUpdate(initial, updated schema.VectorIndexConfig) error {
 	return nil
 }
 
-type immutableInt struct {
-	accessor func(c ent.UserConfig) int
+type immutableParameter struct {
+	accessor func(c ent.UserConfig) interface{}
 	name     string
 }
 
-func validateImmutableIntField(u immutableInt,
+func validateImmutableField(u immutableParameter,
 	previous, next ent.UserConfig,
 ) error {
 	oldField := u.accessor(previous)
 	newField := u.accessor(next)
 	if oldField != newField {
-		return errors.Errorf("%s is immutable: attempted change from \"%d\" to \"%d\"",
+		return errors.Errorf("%s is immutable: attempted change from \"%v\" to \"%v\"",
 			u.name, oldField, newField)
 	}