Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions KNOWN-ISSUES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
38 changes: 32 additions & 6 deletions src/Core/Search/SearchService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,26 @@ namespace KernelMemory.Core.Search;
public sealed class SearchService : ISearchService
{
private readonly Dictionary<string, NodeSearchService> _nodeServices;
private readonly Dictionary<string, Dictionary<string, float>>? _indexWeights;
private readonly ISearchReranker _reranker;

/// <summary>
/// Initialize a new SearchService.
/// </summary>
/// <param name="nodeServices">Per-node search services.</param>
/// <param name="indexWeights">
/// 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).
/// </param>
/// <param name="reranker">Reranking implementation (default: WeightedDiminishingReranker).</param>
public SearchService(
Dictionary<string, NodeSearchService> nodeServices,
Dictionary<string, Dictionary<string, float>>? indexWeights = null,
ISearchReranker? reranker = null)
{
this._nodeServices = nodeServices;
this._indexWeights = indexWeights;
this._reranker = reranker ?? new WeightedDiminishingReranker();
}

Expand Down Expand Up @@ -201,7 +209,7 @@ private void ValidateNodes(string[] nodeIds)
}

/// <summary>
/// Build reranking configuration from request and defaults.
/// Build reranking configuration from request, configured index weights, and defaults.
/// </summary>
private RerankingConfig BuildRerankingConfig(SearchRequest request, string[] nodeIds)
{
Expand All @@ -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<string, Dictionary<string, float>>();
foreach (var nodeId in nodeIds)
{
indexWeights[nodeId] = new Dictionary<string, float>
// 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<string, float>(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<string, float>
{
[SearchConstants.DefaultFtsIndexId] = SearchConstants.DefaultIndexWeight
};
}
}

return new RerankingConfig
Expand Down
15 changes: 14 additions & 1 deletion src/Main/CLI/Commands/SearchCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ private void FormatSearchResultsHuman(SearchResponse response, SearchCommandSett
private SearchService CreateSearchService()
{
var nodeServices = new Dictionary<string, NodeSearchService>();
var indexWeights = new Dictionary<string, Dictionary<string, float>>();

foreach (var (nodeId, nodeConfig) in this.Config.Nodes)
{
Expand All @@ -478,9 +479,21 @@ private SearchService CreateSearchService()
);

nodeServices[nodeId] = nodeSearchService;

// Extract index weights from configuration
if (nodeConfig.SearchIndexes.Count > 0)
{
var nodeIndexWeights = new Dictionary<string, float>();
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);
}

/// <summary>
Expand Down
266 changes: 266 additions & 0 deletions tests/Core.Tests/Search/SearchServiceIndexWeightsTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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<ILogger<ContentStorageService>>();
var mockFtsLogger = new Mock<ILogger<SqliteFtsIndex>>();
var cuidGenerator = new CuidGenerator();

// Create storage
var contentDbPath = Path.Combine(this._tempDir, "content.db");
var options = new DbContextOptionsBuilder<ContentStorageDbContext>()
.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<string, ISearchIndex> { ["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<string, Dictionary<string, float>>
{
["test-node"] = new Dictionary<string, float>
{
[SearchConstants.DefaultFtsIndexId] = 0.5f // Custom weight
}
};

var nodeService = new NodeSearchService("test-node", this._ftsIndex, this._storage);
var nodeServices = new Dictionary<string, NodeSearchService> { ["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<string, NodeSearchService> { ["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<string, Dictionary<string, float>>
{
["test-node"] = new Dictionary<string, float>
{
[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<string, NodeSearchService> { ["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<string, Dictionary<string, float>>
{
["test-node"] = new Dictionary<string, float>
{
["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<string, NodeSearchService> { ["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<string, NodeSearchService> { ["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<string, NodeSearchService> { ["test-node"] = nodeService };

var emptyWeights = new Dictionary<string, Dictionary<string, float>>();

// Act - should not throw
var searchService = new SearchService(nodeServices, indexWeights: emptyWeights);

// Assert - service was created successfully
Assert.NotNull(searchService);
}
}
Loading