feat(search): relevance ranking + websearch_to_tsquery
CI / backend (pull_request) Successful in 1m14s
CI / frontend (pull_request) Successful in 29s
Security / secrets (pull_request) Successful in 5s
Security / dependencies (pull_request) Successful in 1m20s

Slice 1 of search-core (docs/discovery/05, MVP): replace date-only ordering with
ts_rank_cd relevance ranking (Npgsql RankCoverDensity) when a free-text query is
present, recency as a tiebreaker; browse-only requests (no query) keep today's
date-descending order unchanged. Upgrade plainto_tsquery -> websearch_to_tsquery
so quotes ("exact phrase"), OR, and -exclusions work as users already expect
from web search boxes.

Verified: ranking SQL semantics (plain/phrase/exclusion/ts_rank_cd score) checked
directly against a live Postgres instance, not just Npgsql's docs. Added a
regression test locking the date-order fallback path (the one behaviour testable
without a live Postgres, matching this repo's existing no-Testcontainers
convention -- see AuthEndpointsTests.TestAppFactory). All 40 tests pass; full
Release build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-01 21:03:59 +02:00
parent 81fc05299d
commit 4f415ce7bf
2 changed files with 72 additions and 9 deletions
@@ -10,7 +10,9 @@ namespace InboxIntel.Infrastructure.Search;
/// <summary>
/// Structured + full-text search. Structured filters compose as SQL WHERE
/// clauses; free text uses PostgreSQL FTS via the generated SearchVector column
/// (EF.Functions.ToTsVector/Matches translate to @@ / to_tsquery).
/// (websearch_to_tsquery / @@ / ts_rank_cd). Results are relevance-ranked when a
/// free-text query is present, date-ordered otherwise. See
/// docs/discovery/05-search-redesign.md for the full multi-layer search design.
/// </summary>
public class SearchService : ISearchService
{
@@ -58,16 +60,24 @@ public class SearchService : ISearchService
return new PagedResult<EmailSummaryDto> { Items = [], Page = r.Page, PageSize = r.PageSize, TotalCount = 0 };
}
if (!string.IsNullOrWhiteSpace(r.Query))
{
// PostgreSQL full-text match against the generated tsvector.
var term = r.Query.Trim();
q = q.Where(e => e.SearchVector!.Matches(EF.Functions.PlainToTsQuery("english", term)));
}
// websearch_to_tsquery (vs. plainto_tsquery) understands quotes ("exact phrase"),
// OR, and -exclusions — the syntax users already expect from web search boxes.
var hasFreeTextQuery = !string.IsNullOrWhiteSpace(r.Query);
var term = r.Query?.Trim() ?? string.Empty;
if (hasFreeTextQuery)
q = q.Where(e => e.SearchVector!.Matches(EF.Functions.WebSearchToTsQuery("english", term)));
var total = await q.CountAsync(ct);
var items = await q
.OrderByDescending(e => e.SentAtUtc)
// Relevance-ranked when there's a free-text query (ts_rank_cd via RankCoverDensity,
// recency as a tiebreaker); date-only otherwise — matches the existing browse
// behaviour when the user isn't searching for anything in particular.
var ranked = hasFreeTextQuery
? q.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term)))
.ThenByDescending(e => e.SentAtUtc)
: q.OrderByDescending(e => e.SentAtUtc);
var items = await ranked
.Skip((r.Page - 1) * r.PageSize)
.Take(r.PageSize)
.Select(e => new EmailSummaryDto(