diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md index 9e976bb68..d322d638c 100644 --- a/KNOWN-ISSUES.md +++ b/KNOWN-ISSUES.md @@ -84,22 +84,6 @@ km search 'content:"user:password"' --- -## Configuration Limitations - -### 4. Index Weights Not Configurable - -**Status:** Known limitation - -**Issue:** Index weights for reranking use hardcoded defaults instead of configuration. - -**Location:** `src/Core/Search/SearchService.cs:223` - -**Impact:** Cannot tune relevance scoring per index. - -**Fix Required:** Load index weights from configuration file. - ---- - ## Testing Gaps These bugs were discovered through comprehensive E2E testing. Previous tests only verified: diff --git a/src/Core/Search/SearchService.cs b/src/Core/Search/SearchService.cs index 8417b3abd..621c3c03e 100644 --- a/src/Core/Search/SearchService.cs +++ b/src/Core/Search/SearchService.cs @@ -14,18 +14,26 @@ namespace KernelMemory.Core.Search; public sealed class SearchService : ISearchService { private readonly Dictionary _nodeServices; + private readonly Dictionary>? _indexWeights; private readonly ISearchReranker _reranker; /// /// Initialize a new SearchService. /// /// Per-node search services. + /// + /// Per-node, per-index weights for relevance scoring. + /// Outer key = node ID, Inner key = index ID, Value = weight multiplier. + /// If null or missing entries, defaults to SearchConstants.DefaultIndexWeight (1.0). + /// /// Reranking implementation (default: WeightedDiminishingReranker). public SearchService( Dictionary nodeServices, + Dictionary>? indexWeights = null, ISearchReranker? reranker = null) { this._nodeServices = nodeServices; + this._indexWeights = indexWeights; this._reranker = reranker ?? new WeightedDiminishingReranker(); } @@ -201,7 +209,7 @@ private void ValidateNodes(string[] nodeIds) } /// - /// Build reranking configuration from request and defaults. + /// Build reranking configuration from request, configured index weights, and defaults. /// private RerankingConfig BuildRerankingConfig(SearchRequest request, string[] nodeIds) { @@ -219,15 +227,33 @@ private RerankingConfig BuildRerankingConfig(SearchRequest request, string[] nod } } - // Index weights: use defaults for now - // TODO: Load from configuration + // Index weights: use configured weights, fall back to defaults for missing entries var indexWeights = new Dictionary>(); foreach (var nodeId in nodeIds) { - indexWeights[nodeId] = new Dictionary + // Check if we have configured index weights for this node + if (this._indexWeights?.TryGetValue(nodeId, out var configuredNodeIndexWeights) == true + && configuredNodeIndexWeights.Count > 0) { - ["fts-main"] = SearchConstants.DefaultIndexWeight - }; + // Use configured weights, but ensure defaults for missing indexes + var nodeIndexWeights = new Dictionary(configuredNodeIndexWeights); + + // Ensure default FTS index has a weight (use configured or default) + if (!nodeIndexWeights.ContainsKey(SearchConstants.DefaultFtsIndexId)) + { + nodeIndexWeights[SearchConstants.DefaultFtsIndexId] = SearchConstants.DefaultIndexWeight; + } + + indexWeights[nodeId] = nodeIndexWeights; + } + else + { + // No configured weights for this node, use default + indexWeights[nodeId] = new Dictionary + { + [SearchConstants.DefaultFtsIndexId] = SearchConstants.DefaultIndexWeight + }; + } } return new RerankingConfig diff --git a/src/Main/CLI/Commands/SearchCommand.cs b/src/Main/CLI/Commands/SearchCommand.cs index 748c18d80..999ef7034 100644 --- a/src/Main/CLI/Commands/SearchCommand.cs +++ b/src/Main/CLI/Commands/SearchCommand.cs @@ -455,6 +455,7 @@ private void FormatSearchResultsHuman(SearchResponse response, SearchCommandSett private SearchService CreateSearchService() { var nodeServices = new Dictionary(); + var indexWeights = new Dictionary>(); foreach (var (nodeId, nodeConfig) in this.Config.Nodes) { @@ -478,9 +479,21 @@ private SearchService CreateSearchService() ); nodeServices[nodeId] = nodeSearchService; + + // Extract index weights from configuration + if (nodeConfig.SearchIndexes.Count > 0) + { + var nodeIndexWeights = new Dictionary(); + foreach (var searchIndex in nodeConfig.SearchIndexes) + { + // Use the configured weight for each search index + nodeIndexWeights[searchIndex.Id] = searchIndex.Weight; + } + indexWeights[nodeId] = nodeIndexWeights; + } } - return new SearchService(nodeServices); + return new SearchService(nodeServices, indexWeights); } /// diff --git a/tests/Core.Tests/Search/SearchServiceIndexWeightsTests.cs b/tests/Core.Tests/Search/SearchServiceIndexWeightsTests.cs new file mode 100644 index 000000000..768298eaa --- /dev/null +++ b/tests/Core.Tests/Search/SearchServiceIndexWeightsTests.cs @@ -0,0 +1,266 @@ +// Copyright (c) Microsoft. All rights reserved. + +using KernelMemory.Core.Search; +using KernelMemory.Core.Search.Models; +using KernelMemory.Core.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; + +namespace KernelMemory.Core.Tests.Search; + +/// +/// Tests for SearchService with configurable index weights. +/// Verifies that index weights from configuration are used in reranking. +/// This test class tests the fix for Known Issue 4: Index Weights Not Configurable. +/// +public sealed class SearchServiceIndexWeightsTests : IDisposable +{ + private readonly string _tempDir; + private readonly ContentStorageService _storage; + private readonly ContentStorageDbContext _context; + private readonly SqliteFtsIndex _ftsIndex; + + public SearchServiceIndexWeightsTests() + { + this._tempDir = Path.Combine(Path.GetTempPath(), $"km-index-weights-test-{Guid.NewGuid():N}"); + Directory.CreateDirectory(this._tempDir); + + var mockStorageLogger = new Mock>(); + var mockFtsLogger = new Mock>(); + var cuidGenerator = new CuidGenerator(); + + // Create storage + var contentDbPath = Path.Combine(this._tempDir, "content.db"); + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={contentDbPath}") + .Options; + this._context = new ContentStorageDbContext(options); + this._context.Database.EnsureCreated(); + + // Create FTS index + var ftsDbPath = Path.Combine(this._tempDir, "fts.db"); + this._ftsIndex = new SqliteFtsIndex(ftsDbPath, enableStemming: true, mockFtsLogger.Object); + + var searchIndexes = new Dictionary { ["fts"] = this._ftsIndex }; + this._storage = new ContentStorageService(this._context, cuidGenerator, mockStorageLogger.Object, searchIndexes); + } + + public void Dispose() + { + this._ftsIndex.Dispose(); + this._context.Dispose(); + + try + { + if (Directory.Exists(this._tempDir)) + { + Directory.Delete(this._tempDir, true); + } + } + catch (IOException) + { + // Ignore cleanup errors + } + } + + [Fact] + public async Task SearchService_WithConfiguredIndexWeights_UsesConfiguredWeights() + { + // Arrange: Insert test data + await this._storage.UpsertAsync(new KernelMemory.Core.Storage.Models.UpsertRequest + { + Content = "Test document about Docker containers", + MimeType = "text/plain" + }, CancellationToken.None).ConfigureAwait(false); + + // Configure custom index weights (0.5 instead of default 1.0) + var indexWeights = new Dictionary> + { + ["test-node"] = new Dictionary + { + [SearchConstants.DefaultFtsIndexId] = 0.5f // Custom weight + } + }; + + var nodeService = new NodeSearchService("test-node", this._ftsIndex, this._storage); + var nodeServices = new Dictionary { ["test-node"] = nodeService }; + + // Create SearchService with custom index weights + var searchService = new SearchService(nodeServices, indexWeights: indexWeights); + + var request = new SearchRequest + { + Query = "docker", + Limit = 10, + MinRelevance = 0.0f + }; + + // Act + var response = await searchService.SearchAsync(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + Assert.NotEmpty(response.Results); + // With default weight 1.0, a result with BaseRelevance ~1.0 would have Relevance ~1.0 + // With weight 0.5, the same result should have Relevance ~0.5 + // Since we set index weight to 0.5, relevance should be reduced by factor of 0.5 + var result = response.Results.First(); + Assert.True(result.Relevance <= 0.6f, + $"Expected relevance <= 0.6 (due to 0.5 index weight), got {result.Relevance}"); + } + + [Fact] + public async Task SearchService_WithoutConfiguredIndexWeights_UsesDefaultWeight() + { + // Arrange: Insert test data + await this._storage.UpsertAsync(new KernelMemory.Core.Storage.Models.UpsertRequest + { + Content = "Test document about Kubernetes orchestration", + MimeType = "text/plain" + }, CancellationToken.None).ConfigureAwait(false); + + var nodeService = new NodeSearchService("test-node", this._ftsIndex, this._storage); + var nodeServices = new Dictionary { ["test-node"] = nodeService }; + + // Create SearchService WITHOUT custom index weights (should use defaults) + var searchService = new SearchService(nodeServices); + + var request = new SearchRequest + { + Query = "kubernetes", + Limit = 10, + MinRelevance = 0.0f + }; + + // Act + var response = await searchService.SearchAsync(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + Assert.NotEmpty(response.Results); + // With default weight 1.0, result should maintain its base relevance + var result = response.Results.First(); + Assert.True(result.Relevance > 0.5f, + $"Expected relevance > 0.5 (using default 1.0 weight), got {result.Relevance}"); + } + + [Fact] + public async Task SearchService_WithIndexWeightsForMultipleIndexes_UsesCorrectWeights() + { + // This test verifies that when index weights are configured for multiple indexes, + // each index uses its correct weight for reranking. + + // Arrange: Insert test data + await this._storage.UpsertAsync(new KernelMemory.Core.Storage.Models.UpsertRequest + { + Content = "Machine learning and artificial intelligence concepts", + MimeType = "text/plain" + }, CancellationToken.None).ConfigureAwait(false); + + // Configure weights for multiple indexes + var indexWeights = new Dictionary> + { + ["test-node"] = new Dictionary + { + [SearchConstants.DefaultFtsIndexId] = 0.7f, // FTS index weight + ["vector-main"] = 0.3f // Vector index weight (not used here, but configured) + } + }; + + var nodeService = new NodeSearchService("test-node", this._ftsIndex, this._storage); + var nodeServices = new Dictionary { ["test-node"] = nodeService }; + + var searchService = new SearchService(nodeServices, indexWeights: indexWeights); + + var request = new SearchRequest + { + Query = "machine learning", + Limit = 10, + MinRelevance = 0.0f + }; + + // Act + var response = await searchService.SearchAsync(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + Assert.NotEmpty(response.Results); + // With index weight 0.7 (not default 1.0), relevance should be scaled by 0.7 + var result = response.Results.First(); + Assert.True(result.Relevance <= 0.75f, + $"Expected relevance <= 0.75 (due to 0.7 index weight), got {result.Relevance}"); + } + + [Fact] + public async Task SearchService_WithMissingIndexWeight_UsesDefaultWeight() + { + // Tests that if an index weight is not configured for a specific index, + // the default weight is used. + + // Arrange: Insert test data + await this._storage.UpsertAsync(new KernelMemory.Core.Storage.Models.UpsertRequest + { + Content = "Database optimization techniques", + MimeType = "text/plain" + }, CancellationToken.None).ConfigureAwait(false); + + // Configure weights but NOT for the FTS index used + var indexWeights = new Dictionary> + { + ["test-node"] = new Dictionary + { + ["some-other-index"] = 0.5f // Different index, not the one used + } + }; + + var nodeService = new NodeSearchService("test-node", this._ftsIndex, this._storage); + var nodeServices = new Dictionary { ["test-node"] = nodeService }; + + var searchService = new SearchService(nodeServices, indexWeights: indexWeights); + + var request = new SearchRequest + { + Query = "database", + Limit = 10, + MinRelevance = 0.0f + }; + + // Act + var response = await searchService.SearchAsync(request, CancellationToken.None).ConfigureAwait(false); + + // Assert + Assert.NotEmpty(response.Results); + // Since fts-main is not in the configured weights, default weight 1.0 should be used + var result = response.Results.First(); + Assert.True(result.Relevance > 0.5f, + $"Expected relevance > 0.5 (using default 1.0 weight for unconfigured index), got {result.Relevance}"); + } + + [Fact] + public void SearchService_Constructor_AcceptsNullIndexWeights() + { + // Verify that SearchService can be created with null index weights + var nodeService = new NodeSearchService("test-node", this._ftsIndex, this._storage); + var nodeServices = new Dictionary { ["test-node"] = nodeService }; + + // Act - should not throw + var searchService = new SearchService(nodeServices, indexWeights: null); + + // Assert - service was created successfully + Assert.NotNull(searchService); + } + + [Fact] + public void SearchService_Constructor_AcceptsEmptyIndexWeights() + { + // Verify that SearchService works with empty index weights dictionary + var nodeService = new NodeSearchService("test-node", this._ftsIndex, this._storage); + var nodeServices = new Dictionary { ["test-node"] = nodeService }; + + var emptyWeights = new Dictionary>(); + + // Act - should not throw + var searchService = new SearchService(nodeServices, indexWeights: emptyWeights); + + // Assert - service was created successfully + Assert.NotNull(searchService); + } +}