Tighten unified search ranking and optional telemetry
This commit is contained in:
@@ -24,8 +24,8 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
private const int MaxSuggestionViabilityQueries = 6;
|
||||
private const int MaxOverflowCards = 4;
|
||||
private const int CoverageCandidateWindow = 24;
|
||||
private const double OverflowScoreBandRatio = 0.15d;
|
||||
private const double BlendedAnswerScoreBandRatio = 0.12d;
|
||||
private const double OverflowScoreBandRatio = 0.04d;
|
||||
private const double BlendedAnswerScoreBandRatio = 0.025d;
|
||||
private readonly KnowledgeSearchOptions _options;
|
||||
private readonly UnifiedSearchOptions _unifiedOptions;
|
||||
private readonly IKnowledgeSearchStore _store;
|
||||
@@ -2269,7 +2269,7 @@ internal sealed class UnifiedSearchService : IUnifiedSearchService
|
||||
|
||||
private void EmitTelemetry(QueryPlan plan, UnifiedSearchResponse response, string tenant)
|
||||
{
|
||||
if (_telemetrySink is null)
|
||||
if (_telemetrySink is null || !_options.SearchTelemetryEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
|
||||
|
||||
public sealed class SearchAnalyticsServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RecordEventsAndPopularityRemainDisabledWhileHistoryStillWorks()
|
||||
{
|
||||
var options = Options.Create(new KnowledgeSearchOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = string.Empty,
|
||||
SearchTelemetryEnabled = false
|
||||
});
|
||||
var service = new SearchAnalyticsService(options, NullLogger<SearchAnalyticsService>.Instance);
|
||||
|
||||
await service.RecordEventsAsync(
|
||||
[
|
||||
new SearchAnalyticsEvent(
|
||||
TenantId: "tenant-disabled",
|
||||
EventType: "click",
|
||||
Query: "database connectivity",
|
||||
UserId: "user-1",
|
||||
EntityKey: "knowledge:doctor:db",
|
||||
Domain: "knowledge",
|
||||
Position: 0)
|
||||
]);
|
||||
|
||||
await service.RecordHistoryAsync(
|
||||
"tenant-disabled",
|
||||
"user-1",
|
||||
"database connectivity",
|
||||
2);
|
||||
|
||||
var history = await service.GetHistoryAsync("tenant-disabled", "user-1");
|
||||
var popularity = await service.GetPopularityMapAsync("tenant-disabled", 30);
|
||||
var events = service.GetFallbackEventsSnapshot("tenant-disabled", TimeSpan.FromMinutes(1));
|
||||
|
||||
events.Should().BeEmpty();
|
||||
popularity.Should().BeEmpty();
|
||||
history.Should().ContainSingle();
|
||||
history[0].Query.Should().Be("database connectivity");
|
||||
history[0].ResultCount.Should().Be(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.KnowledgeSearch;
|
||||
using StellaOps.AdvisoryAI.UnifiedSearch.Analytics;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.UnifiedSearch;
|
||||
|
||||
public sealed class SearchQualityMonitorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StoreFeedbackDoesNotPersistWhenTelemetryIsDisabled()
|
||||
{
|
||||
var options = Options.Create(new KnowledgeSearchOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = string.Empty,
|
||||
SearchTelemetryEnabled = false
|
||||
});
|
||||
var analytics = new SearchAnalyticsService(options, NullLogger<SearchAnalyticsService>.Instance);
|
||||
var monitor = new SearchQualityMonitor(options, NullLogger<SearchQualityMonitor>.Instance, analytics);
|
||||
|
||||
await monitor.StoreFeedbackAsync(new SearchFeedbackEntry
|
||||
{
|
||||
TenantId = "tenant-disabled",
|
||||
UserId = "user-1",
|
||||
Query = "database connectivity",
|
||||
EntityKey = "knowledge:doctor:db",
|
||||
Domain = "knowledge",
|
||||
Position = 0,
|
||||
Signal = "not_helpful"
|
||||
});
|
||||
|
||||
monitor.GetFallbackFeedbackSnapshot("tenant-disabled", TimeSpan.FromMinutes(1))
|
||||
.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,58 @@ public sealed class UnifiedSearchServiceTests
|
||||
result.ContextAnswer!.Code.Should().Be("retrieved_scope_weighted_evidence");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_suppresses_overflow_when_current_scope_result_has_clear_lead()
|
||||
{
|
||||
var doctorEmbedding = new float[64];
|
||||
doctorEmbedding[0] = 0.7f;
|
||||
doctorEmbedding[1] = 0.2f;
|
||||
var doctorRow = MakeRow(
|
||||
"chunk-doctor-dominant-scope",
|
||||
"doctor_check",
|
||||
"PostgreSQL connectivity",
|
||||
JsonDocument.Parse("{\"domain\":\"knowledge\",\"checkCode\":\"check.core.db.connectivity\"}"),
|
||||
embedding: doctorEmbedding,
|
||||
snippet: "Doctor knowledge confirms the database endpoint is down.");
|
||||
var findingRow = MakeRow(
|
||||
"chunk-finding-weak-overflow",
|
||||
"finding",
|
||||
"CVE-2026-9001 in postgres sidecar",
|
||||
JsonDocument.Parse("{\"domain\":\"findings\",\"cveId\":\"CVE-2026-9001\",\"severity\":\"high\"}"),
|
||||
snippet: "A related finding exists, but it is secondary to the current page problem.");
|
||||
|
||||
var storeMock = new Mock<IKnowledgeSearchStore>();
|
||||
storeMock.Setup(s => s.SearchFtsAsync(
|
||||
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new List<KnowledgeChunkRow> { doctorRow, findingRow });
|
||||
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
|
||||
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<KnowledgeChunkRow> { doctorRow });
|
||||
|
||||
var service = CreateService(storeMock: storeMock);
|
||||
|
||||
var result = await service.SearchAsync(
|
||||
new UnifiedSearchRequest(
|
||||
"database connectivity",
|
||||
Ambient: new AmbientContext
|
||||
{
|
||||
CurrentRoute = "/ops/operations/doctor"
|
||||
}),
|
||||
CancellationToken.None);
|
||||
|
||||
result.Cards.Should().ContainSingle();
|
||||
result.Cards[0].Domain.Should().Be("knowledge");
|
||||
result.Overflow.Should().BeNull();
|
||||
result.Coverage.Should().NotBeNull();
|
||||
result.Coverage!.Domains.Should().Contain(domain =>
|
||||
domain.Domain == "findings" && !domain.HasVisibleResults);
|
||||
result.ContextAnswer.Should().NotBeNull();
|
||||
result.ContextAnswer!.Code.Should().Be("retrieved_evidence");
|
||||
result.ContextAnswer.Citations.Should().ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_blends_close_top_results_into_one_answer()
|
||||
{
|
||||
@@ -177,6 +229,49 @@ public sealed class UnifiedSearchServiceTests
|
||||
result.ContextAnswer.Citations.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_keeps_single_dominant_answer_when_top_result_has_clear_lead()
|
||||
{
|
||||
var doctorEmbedding = new float[64];
|
||||
doctorEmbedding[0] = 0.6f;
|
||||
doctorEmbedding[1] = 0.3f;
|
||||
var doctorRow = MakeRow(
|
||||
"chunk-doctor-dominant-answer",
|
||||
"doctor_check",
|
||||
"PostgreSQL connectivity",
|
||||
JsonDocument.Parse("{\"domain\":\"knowledge\",\"checkCode\":\"check.core.db.connectivity\"}"),
|
||||
embedding: doctorEmbedding,
|
||||
snippet: "Database connectivity is degraded.");
|
||||
var guideRow = MakeRow(
|
||||
"chunk-guide-secondary-answer",
|
||||
"md_section",
|
||||
"Database failover playbook",
|
||||
JsonDocument.Parse("{\"domain\":\"knowledge\",\"path\":\"docs/database-failover.md\"}"),
|
||||
snippet: "Failover guidance covers the same connectivity symptoms.");
|
||||
|
||||
var storeMock = new Mock<IKnowledgeSearchStore>();
|
||||
storeMock.Setup(s => s.SearchFtsAsync(
|
||||
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new List<KnowledgeChunkRow> { doctorRow, guideRow });
|
||||
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
|
||||
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<KnowledgeChunkRow> { doctorRow });
|
||||
|
||||
var service = CreateService(storeMock: storeMock);
|
||||
|
||||
var result = await service.SearchAsync(
|
||||
new UnifiedSearchRequest("database connectivity", IncludeSynthesis: false),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ContextAnswer.Should().NotBeNull();
|
||||
result.ContextAnswer!.Code.Should().Be("retrieved_evidence");
|
||||
result.ContextAnswer.Summary.Should().NotContain("Database failover playbook");
|
||||
result.ContextAnswer.Citations.Should().ContainSingle();
|
||||
result.ContextAnswer.Citations![0].Title.Should().Be("PostgreSQL connectivity");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_returns_clarify_context_answer_for_broad_query_without_matches()
|
||||
{
|
||||
@@ -224,7 +319,7 @@ public sealed class UnifiedSearchServiceTests
|
||||
var analyticsOptions = Options.Create(new KnowledgeSearchOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = "Host=localhost;Database=test"
|
||||
ConnectionString = "Host=localhost;Database=test;Timeout=1;Command Timeout=1"
|
||||
});
|
||||
var analyticsService = new SearchAnalyticsService(analyticsOptions, NullLogger<SearchAnalyticsService>.Instance);
|
||||
var qualityMonitor = new SearchQualityMonitor(analyticsOptions, NullLogger<SearchQualityMonitor>.Instance, analyticsService);
|
||||
@@ -263,7 +358,7 @@ public sealed class UnifiedSearchServiceTests
|
||||
var analyticsOptions = Options.Create(new KnowledgeSearchOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = "Host=localhost;Database=test"
|
||||
ConnectionString = "Host=localhost;Database=test;Timeout=1;Command Timeout=1"
|
||||
});
|
||||
var analyticsService = new SearchAnalyticsService(analyticsOptions, NullLogger<SearchAnalyticsService>.Instance);
|
||||
var qualityMonitor = new SearchQualityMonitor(analyticsOptions, NullLogger<SearchQualityMonitor>.Instance, analyticsService);
|
||||
@@ -313,13 +408,67 @@ public sealed class UnifiedSearchServiceTests
|
||||
answerFrame.SessionId.Should().Be(SearchAnalyticsPrivacy.HashSessionId("tenant-telemetry", "session-456"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_does_not_record_answer_frame_analytics_or_emit_sink_when_telemetry_disabled()
|
||||
{
|
||||
var analyticsOptions = Options.Create(new KnowledgeSearchOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = "Host=localhost;Database=test;Timeout=1;Command Timeout=1",
|
||||
SearchTelemetryEnabled = false
|
||||
});
|
||||
var analyticsService = new SearchAnalyticsService(analyticsOptions, NullLogger<SearchAnalyticsService>.Instance);
|
||||
var qualityMonitor = new SearchQualityMonitor(analyticsOptions, NullLogger<SearchQualityMonitor>.Instance, analyticsService);
|
||||
var telemetrySink = new RecordingTelemetrySink();
|
||||
|
||||
var doctorRow = MakeRow(
|
||||
"chunk-doctor-telemetry-off",
|
||||
"doctor_check",
|
||||
"PostgreSQL connectivity",
|
||||
JsonDocument.Parse("{\"domain\":\"knowledge\",\"checkCode\":\"check.core.db.connectivity\"}"));
|
||||
|
||||
var storeMock = new Mock<IKnowledgeSearchStore>();
|
||||
storeMock.Setup(s => s.SearchFtsAsync(
|
||||
It.IsAny<string>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new List<KnowledgeChunkRow> { doctorRow });
|
||||
storeMock.Setup(s => s.LoadVectorCandidatesAsync(
|
||||
It.IsAny<float[]>(), It.IsAny<KnowledgeSearchFilter?>(), It.IsAny<int>(),
|
||||
It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]);
|
||||
|
||||
var service = CreateService(
|
||||
storeMock: storeMock,
|
||||
analyticsService: analyticsService,
|
||||
qualityMonitor: qualityMonitor,
|
||||
configureOptions: options => options.SearchTelemetryEnabled = false,
|
||||
telemetrySink: telemetrySink);
|
||||
|
||||
var result = await service.SearchAsync(
|
||||
new UnifiedSearchRequest(
|
||||
"database connectivity",
|
||||
Filters: new UnifiedSearchFilter { Tenant = "tenant-telemetry", UserId = "user-1" },
|
||||
Ambient: new AmbientContext
|
||||
{
|
||||
CurrentRoute = "/ops/operations/doctor",
|
||||
SessionId = "session-off"
|
||||
}),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ContextAnswer.Should().NotBeNull();
|
||||
result.ContextAnswer!.Status.Should().Be("grounded");
|
||||
analyticsService.GetFallbackEventsSnapshot("tenant-telemetry", TimeSpan.FromMinutes(1))
|
||||
.Should().BeEmpty();
|
||||
telemetrySink.Events.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateSuggestionsAsync_returns_viability_without_recording_answer_frame_analytics()
|
||||
{
|
||||
var analyticsOptions = Options.Create(new KnowledgeSearchOptions
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = "Host=localhost;Database=test"
|
||||
ConnectionString = "Host=localhost;Database=test;Timeout=1;Command Timeout=1"
|
||||
});
|
||||
var analyticsService = new SearchAnalyticsService(analyticsOptions, NullLogger<SearchAnalyticsService>.Instance);
|
||||
var qualityMonitor = new SearchQualityMonitor(analyticsOptions, NullLogger<SearchQualityMonitor>.Instance, analyticsService);
|
||||
@@ -1086,19 +1235,23 @@ public sealed class UnifiedSearchServiceTests
|
||||
Mock<IKnowledgeSearchStore>? storeMock = null,
|
||||
UnifiedSearchOptions? unifiedOptions = null,
|
||||
SearchAnalyticsService? analyticsService = null,
|
||||
SearchQualityMonitor? qualityMonitor = null)
|
||||
SearchQualityMonitor? qualityMonitor = null,
|
||||
Action<KnowledgeSearchOptions>? configureOptions = null,
|
||||
IUnifiedSearchTelemetrySink? telemetrySink = null)
|
||||
{
|
||||
var options = Options.Create(new KnowledgeSearchOptions
|
||||
var knowledgeOptions = new KnowledgeSearchOptions
|
||||
{
|
||||
Enabled = enabled,
|
||||
ConnectionString = enabled ? "Host=localhost;Database=test" : "",
|
||||
ConnectionString = enabled ? "Host=localhost;Database=test;Timeout=1;Command Timeout=1" : "",
|
||||
DefaultTopK = 10,
|
||||
VectorDimensions = 64,
|
||||
FtsCandidateCount = 120,
|
||||
VectorScanLimit = 100,
|
||||
VectorCandidateCount = 50,
|
||||
QueryTimeoutMs = 3000
|
||||
});
|
||||
};
|
||||
configureOptions?.Invoke(knowledgeOptions);
|
||||
var options = Options.Create(knowledgeOptions);
|
||||
var wrappedUnifiedOptions = Options.Create(unifiedOptions ?? new UnifiedSearchOptions());
|
||||
|
||||
storeMock ??= new Mock<IKnowledgeSearchStore>();
|
||||
@@ -1133,10 +1286,20 @@ public sealed class UnifiedSearchServiceTests
|
||||
entityAliasService.Object,
|
||||
logger,
|
||||
timeProvider,
|
||||
telemetrySink: null,
|
||||
telemetrySink: telemetrySink,
|
||||
unifiedOptions: wrappedUnifiedOptions);
|
||||
}
|
||||
|
||||
private sealed class RecordingTelemetrySink : IUnifiedSearchTelemetrySink
|
||||
{
|
||||
public List<UnifiedSearchTelemetryEvent> Events { get; } = [];
|
||||
|
||||
public void Record(UnifiedSearchTelemetryEvent telemetryEvent)
|
||||
{
|
||||
Events.Add(telemetryEvent);
|
||||
}
|
||||
}
|
||||
|
||||
private static Mock<IKnowledgeSearchStore> CreateEmptyStoreMock()
|
||||
{
|
||||
var storeMock = new Mock<IKnowledgeSearchStore>();
|
||||
|
||||
Reference in New Issue
Block a user