diff --git a/RMuseum/Services/Implementation/SemanticSearchService.cs b/RMuseum/Services/Implementation/SemanticSearchService.cs
index 44bc9516..e99d9417 100644
--- a/RMuseum/Services/Implementation/SemanticSearchService.cs
+++ b/RMuseum/Services/Implementation/SemanticSearchService.cs
@@ -42,6 +42,8 @@ namespace RMuseum.Services.Implementation
private const int MaxTopK = 50;
private const int PreviewVerseCount = 4; // ~2 couplets - enough for a result-card preview, not the whole poem
private const int MaxVersesToScanForRelevance = 200; // bounded prefix to search for a relevant couplet within
+ private const int CandidatePoolMultiplier = 3; // how much larger than k the pre-re-rank candidate pool is
+ private const string AiGeneratedSummaryPrefix = "هوش مصنوعی:";
private readonly LazySemanticSearchResources _resources;
private readonly LazyQueryScopeIndex _queryScopeIndex;
@@ -136,13 +138,41 @@ namespace RMuseum.Services.Implementation
}
float[] queryVector = queryEmbedder.EmbedQuery(request.Query);
- List<(int PoemId, float Score)> topMatches = embeddingIndex.FindTopSimilar(queryVector, k, allowedPoemIds);
- var poemIds = topMatches.Select(m => m.PoemId).ToList();
+ // Fetch a larger candidate pool than the final k, so there's room for the
+ // AI-summary re-ranking below to actually change the outcome — re-ranking a pool
+ // that's already exactly k results wide can only reorder them, never let a
+ // human-reviewed poem that was originally ranked k+1 overtake one that made the
+ // cut only because its (unreviewed) summary happened to score marginally higher.
+ int candidatePoolSize = Math.Min(k * CandidatePoolMultiplier, MaxTopK * CandidatePoolMultiplier);
+ List<(int PoemId, float Score)> candidates = embeddingIndex.FindTopSimilar(queryVector, candidatePoolSize, allowedPoemIds);
+
+ var candidateIds = candidates.Select(c => c.PoemId).ToList();
var poemsById = await context.GanjoorPoems.AsNoTracking()
- .Where(p => poemIds.Contains(p.Id))
+ .Where(p => candidateIds.Contains(p.Id))
.ToDictionaryAsync(p => p.Id);
+ // Gentle re-rank: a poem whose summary is still AI-generated and un-reviewed
+ // (still carries ganjoor-data's own "هوش مصنوعی:" prefix - removing it is part of
+ // that project's human-review/edit workflow) gets a small score penalty before
+ // final sorting. The DISPLAYED score stays the true, unpenalized cosine
+ // similarity (Score below uses c.Score, not AdjustedScore) - the penalty is
+ // purely an internal ordering nudge, not something that should make the shown
+ // similarity number stop meaning what it says.
+ float aiPenalty = _resources.AiSummaryScorePenalty;
+ List<(int PoemId, float Score)> topMatches = candidates
+ .Where(c => poemsById.ContainsKey(c.PoemId))
+ .Select(c => new
+ {
+ c.PoemId,
+ c.Score,
+ AdjustedScore = IsAiGeneratedSummary(poemsById[c.PoemId].PoemSummary) ? c.Score * aiPenalty : c.Score,
+ })
+ .OrderByDescending(x => x.AdjustedScore)
+ .Take(k)
+ .Select(x => (x.PoemId, x.Score))
+ .ToList();
+
// computed once for the whole request, not per result - the query doesn't change
// between results
var queryKeywords = ExtractKeywords(request.Query);
@@ -191,6 +221,18 @@ namespace RMuseum.Services.Implementation
return response;
}
+ ///
+ /// ganjoor-data's own editing workflow requires this exact prefix be removed once a
+ /// human has reviewed/edited a poem's summary — its continued presence is a direct,
+ /// already-existing signal for "not yet human-reviewed," not something this project
+ /// invented or has to infer.
+ ///
+ private static bool IsAiGeneratedSummary(string poemSummary)
+ {
+ return !string.IsNullOrEmpty(poemSummary) &&
+ poemSummary.StartsWith(AiGeneratedSummaryPrefix, StringComparison.Ordinal);
+ }
+
///
/// Common Persian function words stripped out before keyword-matching a query against
/// verse text (see SelectPreviewVerses) — the kind of words that appear in nearly every
diff --git a/RMuseum/Utils/SemanticSearch/LazySemanticSearchResources.cs b/RMuseum/Utils/SemanticSearch/LazySemanticSearchResources.cs
index 37ff7d4f..28160418 100644
--- a/RMuseum/Utils/SemanticSearch/LazySemanticSearchResources.cs
+++ b/RMuseum/Utils/SemanticSearch/LazySemanticSearchResources.cs
@@ -48,6 +48,20 @@ namespace RMuseum.Utils.SemanticSearch
private readonly int _dimension;
private readonly ILogger _logger;
+ ///
+ /// A gentle re-ranking multiplier applied to results whose PoemSummary is still
+ /// AI-generated and un-reviewed (detected by the "هوش مصنوعی:" prefix ganjoor-data's own
+ /// editing workflow requires removing once a human has reviewed/edited a summary — see
+ /// SemanticSearchService for how this is actually applied). A soft nudge, not a filter:
+ /// ~95% of summaries currently carry this prefix, so excluding them outright would gut
+ /// coverage for most queries. Configurable (SemanticSearch:AiSummaryScorePenalty) rather
+ /// than hardcoded, since the right effect size here is a judgment call worth tuning
+ /// without a redeploy. Read here (not directly in SemanticSearchService) purely to reuse
+ /// the config-reading this class already does — this value has nothing to do with the
+ /// lazy-loaded embeddings/model themselves and is available even when Enabled is false.
+ ///
+ public float AiSummaryScorePenalty { get; }
+
public LazySemanticSearchResources(IConfiguration configuration, ILogger logger)
{
_enabled = string.Equals(configuration["SemanticSearch:Enabled"], "true", StringComparison.OrdinalIgnoreCase);
@@ -56,6 +70,7 @@ namespace RMuseum.Utils.SemanticSearch
_vocabPath = configuration["SemanticSearch:VocabPath"];
_mergesPath = configuration["SemanticSearch:MergesPath"];
_dimension = int.TryParse(configuration["SemanticSearch:Dimension"], out var d) ? d : 1024;
+ AiSummaryScorePenalty = float.TryParse(configuration["SemanticSearch:AiSummaryScorePenalty"], out var p) ? p : 0.97f;
_logger = logger;
if (!_enabled)
diff --git a/RMuseum/appsettings.json b/RMuseum/appsettings.json
index 92b553e8..6b20f344 100644
--- a/RMuseum/appsettings.json
+++ b/RMuseum/appsettings.json
@@ -120,7 +120,8 @@
"ModelPath": "C:\\ganjoor-model\\onnx\\model_quantized.onnx",
"VocabPath": "C:\\ganjoor-model\\vocab.json",
"MergesPath": "C:\\ganjoor-model\\merges.txt",
- "Dimension": "1024"
+ "Dimension": "1024",
+ "AiSummaryScorePenalty": "0.97"
},
"WebServiceUrl": "http://localhost:3439",
"ReadOnlyMode": "False",