Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

293 lines
8.4 KiB
Go

package search
import (
"math"
"sort"
"sync"
)
// BM25Backend is a custom in-memory inverted index with BM25 scoring.
// Optimal for repos up to ~50k symbols. Zero external dependencies.
type BM25Backend struct {
mu sync.RWMutex
docs map[string]*doc // docID -> document
inverted map[string][]posting // term -> postings list
totalLen int // sum of all doc lengths (for avgLen)
bigrams *bigramIndex // side index for typo-tolerant fallback recall
// ngrams is the optional learned sub-word boundary source consulted
// by the sparse-ngram emission stage. nil until SetNgramBoundaries
// wires one in; the stage degrades to fixed character n-grams while
// it is nil. Read under mu alongside the postings so Add and Search
// always see the same source — the symmetry the sparse-ngram gate
// depends on. The whole stage is a no-op unless GORTEX_SPARSE_NGRAM
// is set.
ngrams NgramBoundaries
}
// SetNgramBoundaries installs the learned sub-word boundary source the
// sparse-ngram stage consults. Passing nil (or an empty source) reverts
// the stage to fixed character n-grams. Safe to call while the backend
// is live; it takes the write lock so an in-flight Search never sees a
// half-swapped source. Callers that rebuild the table on every index
// pass (mirroring auto-concept mining) re-install it here.
//
// NOTE: when the gate is on, changing the boundary source changes which
// sub-word grams a token emits. To keep the index and query paths in
// lockstep the source should be installed before the backend is
// populated and then left stable for the backend's lifetime — exactly
// how the per-repo table is built once per RunAnalysis pass and handed
// to a freshly (re)built backend.
func (b *BM25Backend) SetNgramBoundaries(src NgramBoundaries) {
b.mu.Lock()
defer b.mu.Unlock()
b.ngrams = src
}
// boundarySource returns the currently installed sub-word boundary
// source under the read lock, so the sparse-ngram stage on the index
// and query paths reads a consistent value even if SetNgramBoundaries
// races with an in-flight Add / Search.
func (b *BM25Backend) boundarySource() NgramBoundaries {
b.mu.RLock()
defer b.mu.RUnlock()
return b.ngrams
}
type doc struct {
id string
len int
terms map[string]int // term -> frequency in this doc
}
type posting struct {
docID string
freq int
}
// BM25 parameters.
const (
bm25K1 = 1.2
bm25B = 0.75
)
// SizeBytes is a rough memory estimate for the BM25 in-memory index:
// every document stores an ID + term-frequency map, and every term in
// the inverted index carries a postings list. The per-doc and per-term
// constants are calibrated against live indexes and land within ~25%
// of actual heap delta.
func (b *BM25Backend) SizeBytes() uint64 {
b.mu.RLock()
defer b.mu.RUnlock()
var bytes uint64
for _, d := range b.docs {
// doc struct + id string + terms map header
bytes += 96 + uint64(len(d.id)) + 48
// Each term entry: key string header + ~8 bytes for the int frequency.
for term := range d.terms {
bytes += uint64(len(term)) + 24
}
}
for term, postings := range b.inverted {
// term string + slice header + postings
bytes += uint64(len(term)) + 24
bytes += uint64(len(postings)) * 32 // docID string hdr + freq int + ptr
}
return bytes
}
// NewBM25 creates a new BM25 search backend. The bigram side index for
// typo-tolerant rescue is built only when GORTEX_BIGRAM_TYPOS is set;
// leaving it nil is cheap — every bigram method is nil-safe and returns
// the zero-cost branch, so the engine's typo-rescue tier becomes a no-op
// automatically.
func NewBM25() *BM25Backend {
b := &BM25Backend{
docs: make(map[string]*doc),
inverted: make(map[string][]posting),
}
if bigramIndexEnabled() {
b.bigrams = newBigramIndex()
}
return b
}
func (b *BM25Backend) Add(id string, fields ...string) {
// Tokenize all fields together.
var allTokens []string
for _, f := range fields {
allTokens = append(allTokens, Tokenize(f)...)
}
// Stopword-filter + Porter-stem the posting tokens. Search()
// runs the same normalization, so stemmed postings are always probed
// with stemmed query terms. The bigram side index keeps the raw
// tokens — its typo rescue bigramizes the raw query string, so
// raw-against-raw stays consistent there.
ftsTokens := NormalizeFTSTokens(allTokens)
// Optional sub-word n-gram expansion. Search() runs the identical
// stage on the same normalized tokens with the same boundary source,
// so n-grammed postings are always probed with n-grammed query
// terms. A no-op unless GORTEX_SPARSE_NGRAM is set; the original
// word tokens are preserved, so an exact match still scores.
ftsTokens = ExpandSparseNgrams(ftsTokens, b.boundarySource())
termFreq := make(map[string]int)
for _, t := range ftsTokens {
termFreq[t]++
}
b.mu.Lock()
defer b.mu.Unlock()
// Remove old version if exists.
b.removeLocked(id)
d := &doc{
id: id,
len: len(ftsTokens),
terms: termFreq,
}
b.docs[id] = d
b.totalLen += d.len
for term, freq := range termFreq {
b.inverted[term] = append(b.inverted[term], posting{id, freq})
}
// Keep the bigram side index in lockstep — same token set, same doc ID.
b.bigrams.Add(id, allTokens)
}
func (b *BM25Backend) Remove(id string) {
b.mu.Lock()
defer b.mu.Unlock()
b.removeLocked(id)
}
func (b *BM25Backend) removeLocked(id string) {
d, ok := b.docs[id]
if !ok {
return
}
b.totalLen -= d.len
// Remove from inverted index.
for term := range d.terms {
postings := b.inverted[term]
for i, p := range postings {
if p.docID == id {
b.inverted[term] = append(postings[:i], postings[i+1:]...)
break
}
}
if len(b.inverted[term]) == 0 {
delete(b.inverted, term)
}
}
delete(b.docs, id)
b.bigrams.Remove(id)
}
func (b *BM25Backend) Search(query string, limit int) []SearchResult {
queryTokens := NormalizeFTSTokens(TokenizeQuery(query))
// Mirror the index path's sub-word n-gram expansion exactly — same
// stage, same normalized tokens, same boundary source — so a query
// probes the same n-grammed terms that Add wrote into the postings.
// A no-op unless GORTEX_SPARSE_NGRAM is set.
queryTokens = ExpandSparseNgrams(queryTokens, b.boundarySource())
if len(queryTokens) == 0 {
return nil
}
b.mu.RLock()
defer b.mu.RUnlock()
docCount := len(b.docs)
if docCount == 0 {
return nil
}
avgLen := float64(b.totalLen) / float64(docCount)
scores := make(map[string]float64)
for _, term := range queryTokens {
postings, ok := b.inverted[term]
if !ok {
continue
}
df := float64(len(postings))
idf := math.Log((float64(docCount)-df+0.5)/(df+0.5) + 1)
for _, p := range postings {
d := b.docs[p.docID]
if d == nil {
continue
}
tf := float64(p.freq)
dl := float64(d.len)
score := idf * (tf * (bm25K1 + 1)) / (tf + bm25K1*(1-bm25B+bm25B*dl/avgLen))
scores[p.docID] += score
}
}
if len(scores) == 0 {
// The engine layer has its own fallback chain — exact-name match
// then substring contains — that handles queries like "NewServer"
// which the backend's camelCase-split tokenization misses. We stay
// strict here so those higher-precision fallbacks can run; typo
// rescue via bigram overlap belongs one level up, after those.
return nil
}
// Sort by score descending.
type scored struct {
id string
score float64
}
results := make([]scored, 0, len(scores))
for id, score := range scores {
results = append(results, scored{id, score})
}
sort.Slice(results, func(i, j int) bool {
if results[i].score != results[j].score {
return results[i].score > results[j].score
}
// Tie-break on doc ID so an equal-score run ships in a stable
// order across calls — Go's map iteration is otherwise random.
return results[i].id < results[j].id
})
if len(results) > limit {
results = results[:limit]
}
out := make([]SearchResult, len(results))
for i, r := range results {
out[i] = SearchResult{ID: r.id, Score: r.score}
}
return out
}
// BigramCandidates exposes the bigram-overlap list for explicit typo-mode
// callers. minOverlap gates how similar a doc must be — the caller picks
// the strictness.
func (b *BM25Backend) BigramCandidates(query string, minOverlap int) []string {
if b.bigrams == nil {
return nil
}
return b.bigrams.Candidates(query, minOverlap)
}
func (b *BM25Backend) Count() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.docs)
}
func (b *BM25Backend) Close() {
// No-op for in-memory backend.
}