// heavily inspired by gitea's model (basically copy-pasted) package repos_indexer import ( "context" "errors" "log" "os" "time" "github.com/blevesearch/bleve/v2" "github.com/blevesearch/bleve/v2/analysis/analyzer/custom" "github.com/blevesearch/bleve/v2/analysis/token/camelcase" "github.com/blevesearch/bleve/v2/analysis/token/lowercase" "github.com/blevesearch/bleve/v2/analysis/token/ngram" "github.com/blevesearch/bleve/v2/analysis/token/unicodenorm" "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode" "github.com/blevesearch/bleve/v2/index/upsidedown" "github.com/blevesearch/bleve/v2/mapping" "github.com/blevesearch/bleve/v2/search" "github.com/blevesearch/bleve/v2/search/query" "tangled.org/core/appview/db" "tangled.org/core/appview/indexer/base36" bleveutil "tangled.org/core/appview/indexer/bleve" "tangled.org/core/appview/models" "tangled.org/core/appview/pagination" tlog "tangled.org/core/log" ) const ( repoIndexerAnalyzer = "repoIndexer" repoIndexerDocType = "repoIndexerDocType" unicodeNormalizeName = "unicodeNormalize" // Bump this when the index mapping changes to trigger a rebuild. repoIndexerVersion = 7 ) type Indexer struct { indexer bleve.Index path string } func NewIndexer(indexDir string) *Indexer { return &Indexer{ path: indexDir, } } // Init initializes the indexer func (ix *Indexer) Init(ctx context.Context, e db.Execer) { l := tlog.FromContext(ctx) existed, err := ix.initialize(ctx) if err != nil { log.Fatalln("failed to initialize repo indexer", err) } if !existed { l.Debug("Populating the repo indexer") err := PopulateIndexer(ctx, ix, e) if err != nil { log.Fatalln("failed to populate repo indexer", err) } } count, _ := ix.indexer.DocCount() l.Info("Initialized the repo indexer", "docCount", count) } func generateRepoIndexMapping() (mapping.IndexMapping, error) { mapping := bleve.NewIndexMapping() docMapping := bleve.NewDocumentMapping() textFieldMapping := bleve.NewTextFieldMapping() textFieldMapping.Store = false textFieldMapping.IncludeInAll = false keywordFieldMapping := bleve.NewKeywordFieldMapping() keywordFieldMapping.Store = false keywordFieldMapping.IncludeInAll = false // case-insensitive keyword field for language and topics caseInsensitiveKeywordMapping := bleve.NewTextFieldMapping() caseInsensitiveKeywordMapping.Store = false caseInsensitiveKeywordMapping.IncludeInAll = false caseInsensitiveKeywordMapping.Analyzer = "keyword_lowercase" // trigram field for partial repo name matching trigramFieldMapping := bleve.NewTextFieldMapping() trigramFieldMapping.Store = false trigramFieldMapping.IncludeInAll = false trigramFieldMapping.Analyzer = "trigram" // numeric field mapping for sorting by counts numericFieldMapping := bleve.NewNumericFieldMapping() numericFieldMapping.Store = false numericFieldMapping.IncludeInAll = false numericFieldMapping.DocValues = true // required for sorting // datetime field mapping for sorting by creation date dateFieldMapping := bleve.NewDateTimeFieldMapping() dateFieldMapping.Store = false dateFieldMapping.IncludeInAll = false dateFieldMapping.DocValues = true // required for sorting // boolean field mapping for fork detection booleanFieldMapping := bleve.NewBooleanFieldMapping() booleanFieldMapping.Store = false booleanFieldMapping.IncludeInAll = false // text fields docMapping.AddFieldMappingsAt("name", textFieldMapping) docMapping.AddFieldMappingsAt("name_trigram", trigramFieldMapping) docMapping.AddFieldMappingsAt("description", textFieldMapping) docMapping.AddFieldMappingsAt("website", textFieldMapping) docMapping.AddFieldMappingsAt("topics", textFieldMapping) // keyword fields docMapping.AddFieldMappingsAt("language", caseInsensitiveKeywordMapping) docMapping.AddFieldMappingsAt("topics_exact", caseInsensitiveKeywordMapping) docMapping.AddFieldMappingsAt("did", keywordFieldMapping) docMapping.AddFieldMappingsAt("knot", keywordFieldMapping) docMapping.AddFieldMappingsAt("repo_did", keywordFieldMapping) // fork indicator for down-ranking docMapping.AddFieldMappingsAt("is_fork", booleanFieldMapping) // sortable numeric fields docMapping.AddFieldMappingsAt("star_count", numericFieldMapping) docMapping.AddFieldMappingsAt("issue_count", numericFieldMapping) docMapping.AddFieldMappingsAt("pull_count", numericFieldMapping) // sortable date field docMapping.AddFieldMappingsAt("created", dateFieldMapping) err := mapping.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ "type": unicodenorm.Name, "form": unicodenorm.NFC, }) if err != nil { return nil, err } err = mapping.AddCustomTokenFilter("edgeNgram3", map[string]any{ "type": ngram.Name, "min": 2.0, "max": 3.0, }) if err != nil { return nil, err } err = mapping.AddCustomAnalyzer(repoIndexerAnalyzer, map[string]any{ "type": custom.Name, "char_filters": []string{}, "tokenizer": unicode.Name, "token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name}, }) if err != nil { return nil, err } err = mapping.AddCustomAnalyzer("keyword_lowercase", map[string]any{ "type": custom.Name, "char_filters": []string{}, "tokenizer": "single", "token_filters": []string{lowercase.Name}, }) if err != nil { return nil, err } err = mapping.AddCustomAnalyzer("trigram", map[string]any{ "type": custom.Name, "char_filters": []string{}, "tokenizer": "single", "token_filters": []string{lowercase.Name, "edgeNgram3"}, }) if err != nil { return nil, err } mapping.DefaultAnalyzer = repoIndexerAnalyzer mapping.AddDocumentMapping(repoIndexerDocType, docMapping) mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping()) mapping.DefaultMapping = bleve.NewDocumentDisabledMapping() return mapping, nil } func (ix *Indexer) initialize(ctx context.Context) (bool, error) { if ix.indexer != nil { return false, errors.New("indexer is already initialized") } indexer, err := openIndexer(ctx, ix.path, repoIndexerVersion) if err != nil { return false, err } if indexer != nil { ix.indexer = indexer return true, nil } mapping, err := generateRepoIndexMapping() if err != nil { return false, err } indexer, err = bleve.New(ix.path, mapping) if err != nil { return false, err } indexer.SetInternal([]byte("mapping_version"), []byte{byte(repoIndexerVersion)}) ix.indexer = indexer return false, nil } func openIndexer(ctx context.Context, path string, version int) (bleve.Index, error) { l := tlog.FromContext(ctx) indexer, err := bleve.Open(path) if err != nil { if errors.Is(err, upsidedown.IncompatibleVersion) { l.Info("Indexer was built with a previous version of bleve, deleting and rebuilding") return nil, os.RemoveAll(path) } return nil, nil } storedVersion, _ := indexer.GetInternal([]byte("mapping_version")) if storedVersion == nil || int(storedVersion[0]) != version { l.Info("Indexer mapping version changed, deleting and rebuilding") indexer.Close() return nil, os.RemoveAll(path) } return indexer, nil } func PopulateIndexer(ctx context.Context, ix *Indexer, e db.Execer) error { l := tlog.FromContext(ctx) count := 0 err := pagination.IterateAll( func(page pagination.Page) ([]models.Repo, error) { return db.GetReposPaginated(e, page) }, func(repos []models.Repo) error { count += len(repos) return ix.Index(ctx, repos...) }, ) l.Info("repos indexed", "count", count) return err } type repoData struct { ID int64 `json:"id"` RepoDid string `json:"repo_did"` Did string `json:"did"` Name string `json:"name"` NameTrigram string `json:"name_trigram"` Description string `json:"description"` Website string `json:"website"` Topics []string `json:"topics"` TopicsExact []string `json:"topics_exact"` Knot string `json:"knot"` Language string `json:"language"` IsFork bool `json:"is_fork"` // sortable fields StarCount int `json:"star_count"` IssueCount int `json:"issue_count"` PullCount int `json:"pull_count"` Created time.Time `json:"created"` } func makeRepoData(repo *models.Repo) *repoData { var language string var starCount, issueCount, pullCount int if repo.RepoStats != nil { language = repo.RepoStats.Language starCount = repo.RepoStats.StarCount issueCount = repo.RepoStats.IssueCount.Open + repo.RepoStats.IssueCount.Closed pullCount = repo.RepoStats.PullCount.Open + repo.RepoStats.PullCount.Merged + repo.RepoStats.PullCount.Closed } isFork := repo.Source != "" return &repoData{ ID: repo.Id, RepoDid: repo.RepoDid, Did: repo.Did, Name: repo.Name, NameTrigram: repo.Name, Description: repo.Description, Website: repo.Website, Topics: repo.Topics, TopicsExact: repo.Topics, Knot: repo.Knot, Language: language, IsFork: isFork, StarCount: starCount, IssueCount: issueCount, PullCount: pullCount, Created: repo.Created, } } // Type returns the document type, for bleve's mapping.Classifier interface. func (r *repoData) Type() string { return repoIndexerDocType } type SearchResult struct { Hits []int64 Total uint64 Duration time.Duration } const maxBatchSize = 20 func (ix *Indexer) Index(ctx context.Context, repos ...models.Repo) error { batch := bleveutil.NewFlushingBatch(ix.indexer, maxBatchSize) for _, repo := range repos { repoData := makeRepoData(&repo) if err := batch.Index(base36.Encode(repo.Id), repoData); err != nil { return err } } return batch.Flush() } func (ix *Indexer) Delete(ctx context.Context, repoID int64) error { return ix.indexer.Delete(base36.Encode(repoID)) } func (ix *Indexer) TotalDocCount() (uint64, error) { return ix.indexer.DocCount() } func (ix *Indexer) Search(ctx context.Context, opts models.RepoSearchOptions) (*SearchResult, error) { var musts []query.Query var mustNots []query.Query for _, keyword := range opts.Keywords { musts = append(musts, bleve.NewDisjunctionQuery( bleveutil.MatchAndQuery("name", keyword, repoIndexerAnalyzer, 0), bleveutil.MatchAndQuery("name_trigram", keyword, "trigram", 0), bleveutil.MatchAndQuery("description", keyword, repoIndexerAnalyzer, 0), bleveutil.MatchAndQuery("website", keyword, repoIndexerAnalyzer, 0), bleveutil.MatchAndQuery("topics", keyword, repoIndexerAnalyzer, 0), )) } for _, phrase := range opts.Phrases { musts = append(musts, bleve.NewDisjunctionQuery( bleveutil.MatchPhraseQuery("name", phrase, repoIndexerAnalyzer), bleveutil.MatchPhraseQuery("description", phrase, repoIndexerAnalyzer), bleveutil.MatchPhraseQuery("website", phrase, repoIndexerAnalyzer), bleveutil.MatchPhraseQuery("topics", phrase, repoIndexerAnalyzer), )) } for _, keyword := range opts.NegatedKeywords { mustNots = append(mustNots, bleve.NewDisjunctionQuery( bleveutil.MatchAndQuery("name", keyword, repoIndexerAnalyzer, 0), bleveutil.MatchAndQuery("description", keyword, repoIndexerAnalyzer, 0), bleveutil.MatchAndQuery("website", keyword, repoIndexerAnalyzer, 0), bleveutil.MatchAndQuery("topics", keyword, repoIndexerAnalyzer, 0), )) } for _, phrase := range opts.NegatedPhrases { mustNots = append(mustNots, bleve.NewDisjunctionQuery( bleveutil.MatchPhraseQuery("name", phrase, repoIndexerAnalyzer), bleveutil.MatchPhraseQuery("description", phrase, repoIndexerAnalyzer), bleveutil.MatchPhraseQuery("website", phrase, repoIndexerAnalyzer), bleveutil.MatchPhraseQuery("topics", phrase, repoIndexerAnalyzer), )) } // keyword filters if opts.Language != "" { musts = append(musts, bleveutil.MatchAndQuery("language", opts.Language, "keyword_lowercase", 0)) } if opts.Knot != "" { musts = append(musts, bleveutil.KeywordFieldQuery("knot", opts.Knot)) } if opts.Did != "" { musts = append(musts, bleveutil.KeywordFieldQuery("did", opts.Did)) } for _, topic := range opts.Topics { musts = append(musts, bleveutil.MatchAndQuery("topics_exact", topic, "keyword_lowercase", 0)) } for _, topic := range opts.NegatedTopics { mustNots = append(mustNots, bleveutil.MatchAndQuery("topics_exact", topic, "keyword_lowercase", 0)) } indexerQuery := bleve.NewBooleanQuery() if len(musts) == 0 { musts = append(musts, bleve.NewMatchAllQuery()) } indexerQuery.AddMust(musts...) indexerQuery.AddMustNot(mustNots...) // use a disjunction where: // - repos with more stars get higher boost // - non-forks get a boost // - boosts stack finalQuery := bleve.NewDisjunctionQuery() // add the main query finalQuery.AddQuery(indexerQuery) // add a boosted query for non-forks notForkQuery := bleve.NewBooleanQuery() notForkQuery.AddMust(indexerQuery) isForkQuery := bleve.NewBoolFieldQuery(true) isForkQuery.SetField("is_fork") notForkQuery.AddMustNot(isForkQuery) 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) searchReq := bleve.NewSearchRequestOptions(finalQuery, opts.Page.Limit, opts.Page.Offset, false) if opts.SortField != "" && opts.SortField != "relevance" { var sortField string switch opts.SortField { case "created": sortField = "created" case "stars": sortField = "star_count" case "issues": sortField = "issue_count" case "pulls": sortField = "pull_count" default: // invalid field, fall back to relevance sortField = "" } if sortField != "" { searchReq.SortByCustom(search.SortOrder{ &search.SortField{ Field: sortField, Desc: opts.SortDesc, }, }) } } res, err := ix.indexer.SearchInContext(ctx, searchReq) if err != nil { return nil, err } ret := &SearchResult{ Total: res.Total, Duration: res.Took, Hits: make([]int64, len(res.Hits)), } for i, hit := range res.Hits { id, err := base36.Decode(hit.ID) if err != nil { return nil, err } ret.Hits[i] = id } return ret, nil }