diff --git a/appview/indexer/bleve/query.go b/appview/indexer/bleve/query.go index d96c9d5e..aefc4c8b 100644 --- a/appview/indexer/bleve/query.go +++ b/appview/indexer/bleve/query.go @@ -31,3 +31,9 @@ func KeywordFieldQuery(field, keyword string) query.Query { q.FieldVal = field return q } + +func NumericRangeQuery(field string, min, max *float64) query.Query { + q := bleve.NewNumericRangeQuery(min, max) + q.FieldVal = field + return q +} diff --git a/appview/indexer/repos/indexer.go b/appview/indexer/repos/indexer.go index 874c9724..d2aef059 100644 --- a/appview/indexer/repos/indexer.go +++ b/appview/indexer/repos/indexer.go @@ -414,8 +414,9 @@ func (ix *Indexer) Search(ctx context.Context, opts models.RepoSearchOptions) (* indexerQuery.AddMustNot(mustNots...) // use a disjunction where: - // - Non-forks get normal relevance score - // - Forks match but get penalized with lower boost + // - repos with more stars get higher boost + // - non-forks get a boost + // - boosts stack finalQuery := bleve.NewDisjunctionQuery() // add the main query @@ -430,6 +431,36 @@ func (ix *Indexer) Search(ctx context.Context, opts models.RepoSearchOptions) (* notForkQuery.SetBoost(2.0) finalQuery.AddQuery(notForkQuery) + // add boosted queries for repos with more stars + // 10-99 stars + tier2Query := bleve.NewBooleanQuery() + tier2Query.AddMust(indexerQuery) + min10 := float64(10) + max99 := float64(99) + starRange2 := bleveutil.NumericRangeQuery("star_count", &min10, &max99) + tier2Query.AddMust(starRange2) + tier2Query.SetBoost(1.5) + finalQuery.AddQuery(tier2Query) + + // 100-999 stars + tier3Query := bleve.NewBooleanQuery() + tier3Query.AddMust(indexerQuery) + min100 := float64(100) + max999 := float64(999) + starRange3 := bleveutil.NumericRangeQuery("star_count", &min100, &max999) + tier3Query.AddMust(starRange3) + tier3Query.SetBoost(2.5) + finalQuery.AddQuery(tier3Query) + + // 1000+ stars + tier4Query := bleve.NewBooleanQuery() + tier4Query.AddMust(indexerQuery) + min1000 := float64(1000) + starRange4 := bleveutil.NumericRangeQuery("star_count", &min1000, nil) + tier4Query.AddMust(starRange4) + tier4Query.SetBoost(4.0) + finalQuery.AddQuery(tier4Query) + // use minimum of 1 to ensure all results match at least one clause finalQuery.SetMin(1) diff --git a/appview/indexer/repos/indexer_test.go b/appview/indexer/repos/indexer_test.go index 5a8a686a..1fefaa4d 100644 --- a/appview/indexer/repos/indexer_test.go +++ b/appview/indexer/repos/indexer_test.go @@ -637,3 +637,99 @@ func TestDelete(t *testing.T) { assert.Equal(t, uint64(1), result.Total) assert.Contains(t, result.Hits, int64(2)) } + +func TestStarCountBoosting(t *testing.T) { + ix, cleanup := setupTestIndexer(t) + defer cleanup() + + ctx := context.Background() + + err := ix.Index(ctx, + models.Repo{ + Id: 1, + Did: "did:plc:alice", + Name: "repo", + Description: "testing", + RepoStats: &models.RepoStats{StarCount: 5000}, + }, + models.Repo{ + Id: 2, + Did: "did:plc:bob", + Name: "repo", + Description: "testing", + RepoStats: &models.RepoStats{StarCount: 150}, + }, + models.Repo{ + Id: 3, + Did: "did:plc:charlie", + Name: "repo", + Description: "testing", + RepoStats: &models.RepoStats{StarCount: 5}, + }, + models.Repo{ + Id: 4, + Did: "did:plc:dana", + Name: "repo", + Description: "testing", + RepoStats: &models.RepoStats{StarCount: 25}, + }, + ) + require.NoError(t, err) + + // search for "testing" - should rank by star count when all else equal + result, err := ix.Search(ctx, models.RepoSearchOptions{ + Keywords: []string{"testing"}, + Page: pagination.Page{Limit: 10}, + }) + require.NoError(t, err) + assert.Equal(t, uint64(4), result.Total) + + // verify that repos with more stars rank higher than those with fewer + popularIdx := -1 + smallIdx := -1 + for i, hit := range result.Hits { + if hit == 1 { // 5000 stars + popularIdx = i + } + if hit == 3 { // 5 stars + smallIdx = i + } + } + assert.True(t, popularIdx < smallIdx, "repo with 5000 stars should rank above repo with 5 stars") +} + +func TestStarBoostingWithForkPenalty(t *testing.T) { + ix, cleanup := setupTestIndexer(t) + defer cleanup() + + ctx := context.Background() + + err := ix.Index(ctx, + models.Repo{ + Id: 1, + Did: "did:plc:alice", + Name: "original-popular", + Description: "test project", + Source: "", + RepoStats: &models.RepoStats{StarCount: 100}, + }, + models.Repo{ + Id: 2, + Did: "did:plc:bob", + Name: "fork-very-popular", + Description: "test project", + Source: "did:plc:someone/original", + RepoStats: &models.RepoStats{StarCount: 1000}, + }, + ) + require.NoError(t, err) + + result, err := ix.Search(ctx, models.RepoSearchOptions{ + Keywords: []string{"project"}, + Page: pagination.Page{Limit: 10}, + }) + require.NoError(t, err) + + // fork with 1000 stars (4.0x) vs non-fork with 100 stars (2.0 * 2.5 = 5.0x) + assert.Equal(t, int64(1), result.Hits[0], "non-fork with fewer stars can still rank higher due to combined boost") +}