Skip to content

Commit 2dde39e

Browse files
authored
fix: make index weights configurable in SearchService (#1107)
## Summary Fixes the known issue "Index Weights Not Configurable" - index weights for reranking can now be configured per-index instead of using hardcoded defaults. ## Changes - **`src/Core/Search/SearchService.cs`**: Added `indexWeights` constructor parameter, updated `BuildRerankingConfig` to use configured weights - **`src/Main/CLI/Commands/SearchCommand.cs`**: Extract weights from `NodeConfig.SearchIndexes` and pass to `SearchService` - **`tests/Core.Tests/Search/SearchServiceIndexWeightsTests.cs`**: 6 new unit tests - **`KNOWN-ISSUES.md`**: Removed the resolved issue ## Configuration Example Users can now configure index weights in `~/.km/config.json`: ```json { "nodes": { "personal": { "searchIndexes": [ { "id": "sqlite-fts", "type": "sqliteFTS", "weight": 0.7 }, { "id": "vector-main", "type": "sqliteVector", "weight": 0.3 } ] } } } ``` ## Test plan - [x] All 530 tests pass (316 Core + 214 Main) - [x] Zero skipped tests - [x] Code coverage at 83.93% (above 80% threshold) - [x] `build.sh` passes with 0 warnings - [x] `format.sh` passes - [x] `coverage.sh` passes ## Backward Compatibility Fully backward compatible - missing weights default to `SearchConstants.DefaultIndexWeight` (1.0).
1 parent 0d5256d commit 2dde39e

File tree

4 files changed

+312
-23
lines changed

4 files changed

+312
-23
lines changed

KNOWN-ISSUES.md

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -84,22 +84,6 @@ km search 'content:"user:password"'
8484

8585
---
8686

87-
## Configuration Limitations
88-
89-
### 4. Index Weights Not Configurable
90-
91-
**Status:** Known limitation
92-
93-
**Issue:** Index weights for reranking use hardcoded defaults instead of configuration.
94-
95-
**Location:** `src/Core/Search/SearchService.cs:223`
96-
97-
**Impact:** Cannot tune relevance scoring per index.
98-
99-
**Fix Required:** Load index weights from configuration file.
100-
101-
---
102-
10387
## Testing Gaps
10488

10589
These bugs were discovered through comprehensive E2E testing. Previous tests only verified:

src/Core/Search/SearchService.cs

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,26 @@ namespace KernelMemory.Core.Search;
1414
public sealed class SearchService : ISearchService
1515
{
1616
private readonly Dictionary<string, NodeSearchService> _nodeServices;
17+
private readonly Dictionary<string, Dictionary<string, float>>? _indexWeights;
1718
private readonly ISearchReranker _reranker;
1819

1920
/// <summary>
2021
/// Initialize a new SearchService.
2122
/// </summary>
2223
/// <param name="nodeServices">Per-node search services.</param>
24+
/// <param name="indexWeights">
25+
/// Per-node, per-index weights for relevance scoring.
26+
/// Outer key = node ID, Inner key = index ID, Value = weight multiplier.
27+
/// If null or missing entries, defaults to SearchConstants.DefaultIndexWeight (1.0).
28+
/// </param>
2329
/// <param name="reranker">Reranking implementation (default: WeightedDiminishingReranker).</param>
2430
public SearchService(
2531
Dictionary<string, NodeSearchService> nodeServices,
32+
Dictionary<string, Dictionary<string, float>>? indexWeights = null,
2633
ISearchReranker? reranker = null)
2734
{
2835
this._nodeServices = nodeServices;
36+
this._indexWeights = indexWeights;
2937
this._reranker = reranker ?? new WeightedDiminishingReranker();
3038
}
3139

@@ -201,7 +209,7 @@ private void ValidateNodes(string[] nodeIds)
201209
}
202210

203211
/// <summary>
204-
/// Build reranking configuration from request and defaults.
212+
/// Build reranking configuration from request, configured index weights, and defaults.
205213
/// </summary>
206214
private RerankingConfig BuildRerankingConfig(SearchRequest request, string[] nodeIds)
207215
{
@@ -219,15 +227,33 @@ private RerankingConfig BuildRerankingConfig(SearchRequest request, string[] nod
219227
}
220228
}
221229

222-
// Index weights: use defaults for now
223-
// TODO: Load from configuration
230+
// Index weights: use configured weights, fall back to defaults for missing entries
224231
var indexWeights = new Dictionary<string, Dictionary<string, float>>();
225232
foreach (var nodeId in nodeIds)
226233
{
227-
indexWeights[nodeId] = new Dictionary<string, float>
234+
// Check if we have configured index weights for this node
235+
if (this._indexWeights?.TryGetValue(nodeId, out var configuredNodeIndexWeights) == true
236+
&& configuredNodeIndexWeights.Count > 0)
228237
{
229-
["fts-main"] = SearchConstants.DefaultIndexWeight
230-
};
238+
// Use configured weights, but ensure defaults for missing indexes
239+
var nodeIndexWeights = new Dictionary<string, float>(configuredNodeIndexWeights);
240+
241+
// Ensure default FTS index has a weight (use configured or default)
242+
if (!nodeIndexWeights.ContainsKey(SearchConstants.DefaultFtsIndexId))
243+
{
244+
nodeIndexWeights[SearchConstants.DefaultFtsIndexId] = SearchConstants.DefaultIndexWeight;
245+
}
246+
247+
indexWeights[nodeId] = nodeIndexWeights;
248+
}
249+
else
250+
{
251+
// No configured weights for this node, use default
252+
indexWeights[nodeId] = new Dictionary<string, float>
253+
{
254+
[SearchConstants.DefaultFtsIndexId] = SearchConstants.DefaultIndexWeight
255+
};
256+
}
231257
}
232258

233259
return new RerankingConfig

src/Main/CLI/Commands/SearchCommand.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ private void FormatSearchResultsHuman(SearchResponse response, SearchCommandSett
455455
private SearchService CreateSearchService()
456456
{
457457
var nodeServices = new Dictionary<string, NodeSearchService>();
458+
var indexWeights = new Dictionary<string, Dictionary<string, float>>();
458459

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

480481
nodeServices[nodeId] = nodeSearchService;
482+
483+
// Extract index weights from configuration
484+
if (nodeConfig.SearchIndexes.Count > 0)
485+
{
486+
var nodeIndexWeights = new Dictionary<string, float>();
487+
foreach (var searchIndex in nodeConfig.SearchIndexes)
488+
{
489+
// Use the configured weight for each search index
490+
nodeIndexWeights[searchIndex.Id] = searchIndex.Weight;
491+
}
492+
indexWeights[nodeId] = nodeIndexWeights;
493+
}
481494
}
482495

483-
return new SearchService(nodeServices);
496+
return new SearchService(nodeServices, indexWeights);
484497
}
485498

486499
/// <summary>
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using KernelMemory.Core.Search;
4+
using KernelMemory.Core.Search.Models;
5+
using KernelMemory.Core.Storage;
6+
using Microsoft.EntityFrameworkCore;
7+
using Microsoft.Extensions.Logging;
8+
using Moq;
9+
10+
namespace KernelMemory.Core.Tests.Search;
11+
12+
/// <summary>
13+
/// Tests for SearchService with configurable index weights.
14+
/// Verifies that index weights from configuration are used in reranking.
15+
/// This test class tests the fix for Known Issue 4: Index Weights Not Configurable.
16+
/// </summary>
17+
public sealed class SearchServiceIndexWeightsTests : IDisposable
18+
{
19+
private readonly string _tempDir;
20+
private readonly ContentStorageService _storage;
21+
private readonly ContentStorageDbContext _context;
22+
private readonly SqliteFtsIndex _ftsIndex;
23+
24+
public SearchServiceIndexWeightsTests()
25+
{
26+
this._tempDir = Path.Combine(Path.GetTempPath(), $"km-index-weights-test-{Guid.NewGuid():N}");
27+
Directory.CreateDirectory(this._tempDir);
28+
29+
var mockStorageLogger = new Mock<ILogger<ContentStorageService>>();
30+
var mockFtsLogger = new Mock<ILogger<SqliteFtsIndex>>();
31+
var cuidGenerator = new CuidGenerator();
32+
33+
// Create storage
34+
var contentDbPath = Path.Combine(this._tempDir, "content.db");
35+
var options = new DbContextOptionsBuilder<ContentStorageDbContext>()
36+
.UseSqlite($"Data Source={contentDbPath}")
37+
.Options;
38+
this._context = new ContentStorageDbContext(options);
39+
this._context.Database.EnsureCreated();
40+
41+
// Create FTS index
42+
var ftsDbPath = Path.Combine(this._tempDir, "fts.db");
43+
this._ftsIndex = new SqliteFtsIndex(ftsDbPath, enableStemming: true, mockFtsLogger.Object);
44+
45+
var searchIndexes = new Dictionary<string, ISearchIndex> { ["fts"] = this._ftsIndex };
46+
this._storage = new ContentStorageService(this._context, cuidGenerator, mockStorageLogger.Object, searchIndexes);
47+
}
48+
49+
public void Dispose()
50+
{
51+
this._ftsIndex.Dispose();
52+
this._context.Dispose();
53+
54+
try
55+
{
56+
if (Directory.Exists(this._tempDir))
57+
{
58+
Directory.Delete(this._tempDir, true);
59+
}
60+
}
61+
catch (IOException)
62+
{
63+
// Ignore cleanup errors
64+
}
65+
}
66+
67+
[Fact]
68+
public async Task SearchService_WithConfiguredIndexWeights_UsesConfiguredWeights()
69+
{
70+
// Arrange: Insert test data
71+
await this._storage.UpsertAsync(new KernelMemory.Core.Storage.Models.UpsertRequest
72+
{
73+
Content = "Test document about Docker containers",
74+
MimeType = "text/plain"
75+
}, CancellationToken.None).ConfigureAwait(false);
76+
77+
// Configure custom index weights (0.5 instead of default 1.0)
78+
var indexWeights = new Dictionary<string, Dictionary<string, float>>
79+
{
80+
["test-node"] = new Dictionary<string, float>
81+
{
82+
[SearchConstants.DefaultFtsIndexId] = 0.5f // Custom weight
83+
}
84+
};
85+
86+
var nodeService = new NodeSearchService("test-node", this._ftsIndex, this._storage);
87+
var nodeServices = new Dictionary<string, NodeSearchService> { ["test-node"] = nodeService };
88+
89+
// Create SearchService with custom index weights
90+
var searchService = new SearchService(nodeServices, indexWeights: indexWeights);
91+
92+
var request = new SearchRequest
93+
{
94+
Query = "docker",
95+
Limit = 10,
96+
MinRelevance = 0.0f
97+
};
98+
99+
// Act
100+
var response = await searchService.SearchAsync(request, CancellationToken.None).ConfigureAwait(false);
101+
102+
// Assert
103+
Assert.NotEmpty(response.Results);
104+
// With default weight 1.0, a result with BaseRelevance ~1.0 would have Relevance ~1.0
105+
// With weight 0.5, the same result should have Relevance ~0.5
106+
// Since we set index weight to 0.5, relevance should be reduced by factor of 0.5
107+
var result = response.Results.First();
108+
Assert.True(result.Relevance <= 0.6f,
109+
$"Expected relevance <= 0.6 (due to 0.5 index weight), got {result.Relevance}");
110+
}
111+
112+
[Fact]
113+
public async Task SearchService_WithoutConfiguredIndexWeights_UsesDefaultWeight()
114+
{
115+
// Arrange: Insert test data
116+
await this._storage.UpsertAsync(new KernelMemory.Core.Storage.Models.UpsertRequest
117+
{
118+
Content = "Test document about Kubernetes orchestration",
119+
MimeType = "text/plain"
120+
}, CancellationToken.None).ConfigureAwait(false);
121+
122+
var nodeService = new NodeSearchService("test-node", this._ftsIndex, this._storage);
123+
var nodeServices = new Dictionary<string, NodeSearchService> { ["test-node"] = nodeService };
124+
125+
// Create SearchService WITHOUT custom index weights (should use defaults)
126+
var searchService = new SearchService(nodeServices);
127+
128+
var request = new SearchRequest
129+
{
130+
Query = "kubernetes",
131+
Limit = 10,
132+
MinRelevance = 0.0f
133+
};
134+
135+
// Act
136+
var response = await searchService.SearchAsync(request, CancellationToken.None).ConfigureAwait(false);
137+
138+
// Assert
139+
Assert.NotEmpty(response.Results);
140+
// With default weight 1.0, result should maintain its base relevance
141+
var result = response.Results.First();
142+
Assert.True(result.Relevance > 0.5f,
143+
$"Expected relevance > 0.5 (using default 1.0 weight), got {result.Relevance}");
144+
}
145+
146+
[Fact]
147+
public async Task SearchService_WithIndexWeightsForMultipleIndexes_UsesCorrectWeights()
148+
{
149+
// This test verifies that when index weights are configured for multiple indexes,
150+
// each index uses its correct weight for reranking.
151+
152+
// Arrange: Insert test data
153+
await this._storage.UpsertAsync(new KernelMemory.Core.Storage.Models.UpsertRequest
154+
{
155+
Content = "Machine learning and artificial intelligence concepts",
156+
MimeType = "text/plain"
157+
}, CancellationToken.None).ConfigureAwait(false);
158+
159+
// Configure weights for multiple indexes
160+
var indexWeights = new Dictionary<string, Dictionary<string, float>>
161+
{
162+
["test-node"] = new Dictionary<string, float>
163+
{
164+
[SearchConstants.DefaultFtsIndexId] = 0.7f, // FTS index weight
165+
["vector-main"] = 0.3f // Vector index weight (not used here, but configured)
166+
}
167+
};
168+
169+
var nodeService = new NodeSearchService("test-node", this._ftsIndex, this._storage);
170+
var nodeServices = new Dictionary<string, NodeSearchService> { ["test-node"] = nodeService };
171+
172+
var searchService = new SearchService(nodeServices, indexWeights: indexWeights);
173+
174+
var request = new SearchRequest
175+
{
176+
Query = "machine learning",
177+
Limit = 10,
178+
MinRelevance = 0.0f
179+
};
180+
181+
// Act
182+
var response = await searchService.SearchAsync(request, CancellationToken.None).ConfigureAwait(false);
183+
184+
// Assert
185+
Assert.NotEmpty(response.Results);
186+
// With index weight 0.7 (not default 1.0), relevance should be scaled by 0.7
187+
var result = response.Results.First();
188+
Assert.True(result.Relevance <= 0.75f,
189+
$"Expected relevance <= 0.75 (due to 0.7 index weight), got {result.Relevance}");
190+
}
191+
192+
[Fact]
193+
public async Task SearchService_WithMissingIndexWeight_UsesDefaultWeight()
194+
{
195+
// Tests that if an index weight is not configured for a specific index,
196+
// the default weight is used.
197+
198+
// Arrange: Insert test data
199+
await this._storage.UpsertAsync(new KernelMemory.Core.Storage.Models.UpsertRequest
200+
{
201+
Content = "Database optimization techniques",
202+
MimeType = "text/plain"
203+
}, CancellationToken.None).ConfigureAwait(false);
204+
205+
// Configure weights but NOT for the FTS index used
206+
var indexWeights = new Dictionary<string, Dictionary<string, float>>
207+
{
208+
["test-node"] = new Dictionary<string, float>
209+
{
210+
["some-other-index"] = 0.5f // Different index, not the one used
211+
}
212+
};
213+
214+
var nodeService = new NodeSearchService("test-node", this._ftsIndex, this._storage);
215+
var nodeServices = new Dictionary<string, NodeSearchService> { ["test-node"] = nodeService };
216+
217+
var searchService = new SearchService(nodeServices, indexWeights: indexWeights);
218+
219+
var request = new SearchRequest
220+
{
221+
Query = "database",
222+
Limit = 10,
223+
MinRelevance = 0.0f
224+
};
225+
226+
// Act
227+
var response = await searchService.SearchAsync(request, CancellationToken.None).ConfigureAwait(false);
228+
229+
// Assert
230+
Assert.NotEmpty(response.Results);
231+
// Since fts-main is not in the configured weights, default weight 1.0 should be used
232+
var result = response.Results.First();
233+
Assert.True(result.Relevance > 0.5f,
234+
$"Expected relevance > 0.5 (using default 1.0 weight for unconfigured index), got {result.Relevance}");
235+
}
236+
237+
[Fact]
238+
public void SearchService_Constructor_AcceptsNullIndexWeights()
239+
{
240+
// Verify that SearchService can be created with null index weights
241+
var nodeService = new NodeSearchService("test-node", this._ftsIndex, this._storage);
242+
var nodeServices = new Dictionary<string, NodeSearchService> { ["test-node"] = nodeService };
243+
244+
// Act - should not throw
245+
var searchService = new SearchService(nodeServices, indexWeights: null);
246+
247+
// Assert - service was created successfully
248+
Assert.NotNull(searchService);
249+
}
250+
251+
[Fact]
252+
public void SearchService_Constructor_AcceptsEmptyIndexWeights()
253+
{
254+
// Verify that SearchService works with empty index weights dictionary
255+
var nodeService = new NodeSearchService("test-node", this._ftsIndex, this._storage);
256+
var nodeServices = new Dictionary<string, NodeSearchService> { ["test-node"] = nodeService };
257+
258+
var emptyWeights = new Dictionary<string, Dictionary<string, float>>();
259+
260+
// Act - should not throw
261+
var searchService = new SearchService(nodeServices, indexWeights: emptyWeights);
262+
263+
// Assert - service was created successfully
264+
Assert.NotNull(searchService);
265+
}
266+
}

0 commit comments

Comments
 (0)