feat(search): hybrid semantic search (RRF) (#34)
CI / backend (push) Successful in 59s
CI / frontend (push) Successful in 15s
CI / format (push) Successful in 56s
CI / db-tests (push) Successful in 1m4s
Deploy Staging / deploy (push) Successful in 30s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 1m4s
CI / backend (pull_request) Successful in 1m9s
CI / frontend (pull_request) Successful in 31s
CI / format (pull_request) Successful in 1m4s
CI / db-tests (pull_request) Successful in 1m5s
Security / secrets (pull_request) Successful in 5s
Security / dependencies (pull_request) Successful in 1m5s

This commit was merged in pull request #34.
This commit is contained in:
2026-07-02 17:36:27 +02:00
parent fb6b89b6dc
commit b7d4b87d75
2 changed files with 139 additions and 1 deletions
@@ -123,4 +123,54 @@ public class LiveDbSearchTests
}
finally { await CleanupAsync(opts, uid); }
}
private sealed class DirectionalFakeEmbeddings : IEmbeddingProvider
{
public bool IsAvailable => true;
public Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
{
// Deterministic "semantics": anything fruit-flavoured points one way, else the other.
var v = new float[768];
if (text.Contains("banana") || text.Contains("tropical")) v[0] = 1; else v[1] = 1;
return Task.FromResult(v);
}
public async Task<IReadOnlyList<float[]>> EmbedBatchAsync(IReadOnlyList<string> texts, CancellationToken ct = default)
{
var list = new List<float[]>();
foreach (var t in texts) list.Add(await EmbedAsync(t, ct));
return list;
}
}
[Fact]
public async Task Hybrid_search_surfaces_semantic_match_with_zero_keyword_overlap()
{
if (Conn is null) return;
var opts = Options();
var uid = await SeedAsync(opts);
try
{
var embeddings = new DirectionalFakeEmbeddings();
using (var prep = new AppDbContext(opts, new FakeCurrentUser()))
{
// "Weekly notes" gets a fruit-direction embedding (semantically related to the
// query); "Invoice March" points elsewhere. Neither subject contains "banana".
var near = await prep.Emails.FirstAsync(e => e.UserId == uid && e.Subject == "Weekly notes");
near.Embedding = new Vector(await embeddings.EmbedAsync("tropical"));
var far = await prep.Emails.FirstAsync(e => e.UserId == uid && e.Subject == "Invoice March");
far.Embedding = new Vector(await embeddings.EmbedAsync("finance"));
await prep.SaveChangesAsync();
}
using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = uid });
var res = await new SearchService(ctx, embeddings)
.SearchAsync(uid, GmailQueryParser.Parse("banana", 1, 10));
// Zero lexical hits for "banana" — hybrid must still surface the semantically
// nearest email, ranked first.
res.Items.Should().NotBeEmpty("semantic recall should fire with zero keyword overlap");
res.Items[0].Subject.Should().Be("Weekly notes");
}
finally { await CleanupAsync(opts, uid); }
}
}