diff --git a/CHANGELOG.md b/CHANGELOG.md index 44f3a420..195c9ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Changed - **Chat TUI streams markdown incrementally.** Assistant messages now use Textual's `MarkdownStream` (`Markdown.get_stream`) and write per-token deltas instead of re-parsing the entire accumulated message on every token. Removes the O(n²) re-parse that visibly stuttered long responses. Bumps `textual` floor to `>=8.2.4` so `Markdown.get_stream` is reachable via the public API. +- **Embedding compatibility check only raises on `vector_dim` mismatch.** `provider` and `name` drift (legitimate when the same model is served by a different stack, e.g. Ollama → vLLM-via-openai) now logs a one-time warning and updates the stored settings to match the current config. Subsequent opens are silent. Run `rebuild --embed-only` if you also want to re-embed under the new stack. ### Fixed diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 56995b44..2ce3f5df 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -3,7 +3,9 @@ Configuration is done through YAML configuration files. !!! note - If you create a db with certain settings and later change them, `haiku.rag` will detect incompatibilities (for example, if you change embedding provider) and will exit. You can **rebuild** the database to apply the new settings, see [Rebuild Database](../cli.md#rebuild-database). + haiku.rag enforces one hard rule on existing databases: the embedding `vector_dim` in your config must match the value stored in the db. A mismatch exits with `ConfigMismatchError` and you must **rebuild** to apply the change (see [Rebuild Database](../cli.md#rebuild-database)). + + Changing only `provider` or `name` (e.g. switching from Ollama to vLLM serving the same model) is treated as soft drift: haiku.rag logs a one-time warning and updates the stored settings to match your config. Subsequent opens are silent. If the change was unintentional, revert your config to match the previously stored settings before running anything that depends on retrieval quality. ## Getting Started diff --git a/haiku_rag_slim/haiku/rag/store/repositories/settings.py b/haiku_rag_slim/haiku/rag/store/repositories/settings.py index 40e206f4..ee4020d2 100644 --- a/haiku_rag_slim/haiku/rag/store/repositories/settings.py +++ b/haiku_rag_slim/haiku/rag/store/repositories/settings.py @@ -1,7 +1,10 @@ import json +import logging from haiku.rag.store.engine import SettingsRecord, Store, query_to_pydantic +logger = logging.getLogger(__name__) + class ConfigMismatchError(Exception): """Raised when stored config doesn't match current config.""" @@ -99,7 +102,15 @@ class SettingsRepository: await self.store.settings_table.add([settings_record]) async def validate_config_compatibility(self) -> None: - """Validate that the current configuration is compatible with stored settings.""" + """Validate that the current configuration is compatible with stored settings. + + ``vector_dim`` mismatches raise — corpus and query vectors must live in + the same dimensional space. ``provider`` and ``name`` mismatches are + treated as soft drift: legitimate when the same model is served by a + different stack (Ollama vs vLLM-via-openai, etc.). Surface the change + once via a warning and overwrite stored settings so the warning does + not fire on every subsequent open. + """ stored_settings = await self.get_current_settings() # If no stored settings, this is a new database - save current config and return @@ -109,13 +120,9 @@ class SettingsRepository: current_config = self.store._config.model_dump(mode="json") - # Check if embedding provider or model has changed # Both stored and current use nested structure: embeddings.model.{provider,name,vector_dim} - stored_embeddings = stored_settings.get("embeddings", {}) - current_embeddings = current_config.get("embeddings", {}) - - stored_model_obj = stored_embeddings.get("model", {}) - current_model_obj = current_embeddings.get("model", {}) + stored_model_obj = stored_settings.get("embeddings", {}).get("model", {}) + current_model_obj = current_config.get("embeddings", {}).get("model", {}) stored_provider = stored_model_obj.get("provider") current_provider = current_model_obj.get("provider") @@ -126,28 +133,31 @@ class SettingsRepository: stored_vector_dim = stored_model_obj.get("vector_dim") current_vector_dim = current_model_obj.get("vector_dim") - # Check for incompatible changes - incompatible_changes = [] - - if stored_provider and stored_provider != current_provider: - incompatible_changes.append( - f"Stored (db) embedding provider: '{stored_provider}' -> Environment (current) embedding provider: '{current_provider}'" - ) - - if stored_model and stored_model != current_model: - incompatible_changes.append( - f"Stored (db) embedding model '{stored_model}' -> Environment (current) embedding model '{current_model}'" - ) - - if stored_vector_dim and stored_vector_dim != current_vector_dim: - incompatible_changes.append( - f"Stored (db) embedding vector dimension {stored_vector_dim} -> Environment (current) embedding vector dimension {current_vector_dim}" - ) - - if incompatible_changes: - error_msg = ( + if ( + stored_vector_dim + and current_vector_dim + and stored_vector_dim != current_vector_dim + ): + raise ConfigMismatchError( "Database configuration is incompatible with current settings:\n" - + "\n".join(f" - {change}" for change in incompatible_changes) + f" - Stored (db) embedding vector dimension {stored_vector_dim} -> " + f"Environment (current) embedding vector dimension {current_vector_dim}\n" + "\nPlease rebuild the database using: haiku-rag rebuild" ) - error_msg += "\n\nPlease rebuild the database using: haiku-rag rebuild" - raise ConfigMismatchError(error_msg) + + soft_changes: list[str] = [] + if stored_provider and stored_provider != current_provider: + soft_changes.append( + f"provider: '{stored_provider}' -> '{current_provider}'" + ) + if stored_model and stored_model != current_model: + soft_changes.append(f"model: '{stored_model}' -> '{current_model}'") + + if soft_changes: + logger.warning( + "Embedding identity changed (vector_dim matches, stored settings " + "will be updated to match current config): %s. If this is not " + "intentional, revert your config to match the stored settings.", + "; ".join(soft_changes), + ) + await self.save_current_settings() diff --git a/tests/test_settings.py b/tests/test_settings.py index 00aef484..ed6f5dd4 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,3 +1,5 @@ +import logging + import pytest from haiku.rag.config import AppConfig, Config @@ -90,16 +92,27 @@ class TestValidateConfigCompatibility: await settings_repo.validate_config_compatibility() @pytest.mark.asyncio - async def test_provider_mismatch_raises_error(self, temp_db_path): - """Different embedding provider raises ConfigMismatchError.""" + async def test_provider_mismatch_warns_and_syncs( + self, temp_db_path, caplog, monkeypatch + ): + """Provider mismatch with matching vector_dim warns and overwrites stored. + + Same model served by a different stack (Ollama vs vLLM via openai-compat) + legitimately differs in `provider`. Validation should surface the change + once, then trust the user's config as the new source of truth so the + warning doesn't fire on every subsequent open. + """ from haiku.rag.store.engine import Store from haiku.rag.store.repositories.settings import SettingsRepository - # Create store with default config (ollama) + # haiku.rag.logging.get_logger() sets propagate=False on the + # `haiku.rag` logger. caplog's handler attaches to root by default, + # so without restoring propagation the records never reach it. + monkeypatch.setattr(logging.getLogger("haiku.rag"), "propagate", True) + async with Store(temp_db_path, create=True): pass - # Create new config with different provider new_config = AppConfig() new_config.embeddings.model.provider = "openai" @@ -108,24 +121,41 @@ class TestValidateConfigCompatibility: ) as store2: settings_repo = SettingsRepository(store2) - with pytest.raises(ConfigMismatchError) as exc_info: + with caplog.at_level(logging.WARNING): await settings_repo.validate_config_compatibility() - assert "embedding provider" in str(exc_info.value) - assert "ollama" in str(exc_info.value) - assert "openai" in str(exc_info.value) + # Warning surfaced the change + assert any( + "provider" in r.getMessage() + and "ollama" in r.getMessage() + and "openai" in r.getMessage() + for r in caplog.records + ) + + # Stored settings now match current + saved = await settings_repo.get_current_settings() + assert saved["embeddings"]["model"]["provider"] == "openai" + + # Second open is silent — stored matches current. + caplog.clear() + with caplog.at_level(logging.WARNING): + await settings_repo.validate_config_compatibility() + assert not any("provider" in r.getMessage() for r in caplog.records) @pytest.mark.asyncio - async def test_model_mismatch_raises_error(self, temp_db_path): - """Different embedding model raises ConfigMismatchError.""" + async def test_model_mismatch_warns_and_syncs( + self, temp_db_path, caplog, monkeypatch + ): + """Model name mismatch with matching vector_dim warns and overwrites stored.""" from haiku.rag.store.engine import Store from haiku.rag.store.repositories.settings import SettingsRepository - # Create store with default config + # See test_provider_mismatch_warns_and_syncs for the propagate=True rationale. + monkeypatch.setattr(logging.getLogger("haiku.rag"), "propagate", True) + async with Store(temp_db_path, create=True): pass - # Create new config with different model new_config = AppConfig() new_config.embeddings.model.name = "different-model" @@ -134,10 +164,16 @@ class TestValidateConfigCompatibility: ) as store2: settings_repo = SettingsRepository(store2) - with pytest.raises(ConfigMismatchError) as exc_info: + with caplog.at_level(logging.WARNING): await settings_repo.validate_config_compatibility() - assert "embedding model" in str(exc_info.value) + assert any( + "model" in r.getMessage() and "different-model" in r.getMessage() + for r in caplog.records + ) + + saved = await settings_repo.get_current_settings() + assert saved["embeddings"]["model"]["name"] == "different-model" @pytest.mark.asyncio async def test_vector_dim_mismatch_raises_error(self, temp_db_path):