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

154 lines
6.0 KiB
Go

package search
import (
"os"
"strings"
)
// sparseNgramEnabled gates the optional sub-word n-gram emission stage
// layered over the fixed-rule word tokens that Tokenize / TokenizeQuery
// already produce. Default OFF: emitting character n-grams for every
// word token multiplies the posting set, and on identifier-heavy
// queries the extra sub-word noise can demote an exact match — a
// precision regression we are not willing to ship enabled by default
// until it proves out against the recall fixture. Opt in with
// GORTEX_SPARSE_NGRAM=1 (also true / yes / on / y).
//
// Read once at process start, exactly like the FTS-stemming and
// bigram-typo flags. The index built during a daemon's lifetime and
// every query against it share a single setting, so a mid-session
// toggle can never desynchronise the n-grammed postings from the
// n-grammed query terms: the same emission runs on the index path
// (BM25Backend.Add) and the query path (BM25Backend.Search) because
// both route their word tokens through ExpandSparseNgrams.
var sparseNgramEnabled = sparseNgramFromEnv()
func sparseNgramFromEnv() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("GORTEX_SPARSE_NGRAM"))) {
case "1", "true", "yes", "on", "y":
return true
}
return false
}
// Sub-word n-gram bounds. Character n-grams in this closed range are
// emitted over each word token when no learned boundary table is
// supplied. n=3..4 is the usual sweet spot for code identifiers: short
// enough to bridge a morphological or typo gap ("valid" reaches
// "validate" via shared trigrams), long enough to stay discriminative
// (bigrams collide across nearly every token, which is why the typo
// side index keeps them separate from the BM25 postings).
const (
sparseNgramMinN = 3
sparseNgramMaxN = 4
// sparseNgramMinTokenLen skips tokens no longer than the smallest
// n-gram — a token of length <= sparseNgramMinN has at most one
// n-gram equal to itself, so it carries no extra sub-word signal
// and only inflates the posting set.
sparseNgramMinTokenLen = sparseNgramMinN + 1
)
// NgramBoundaries is the data-driven split source the sparse-ngram
// stage consults when one is available. A learned boundary table built
// at index time (see BuildNgramBoundaries) satisfies it, but the
// tokenizer depends only on this abstraction so it compiles and runs
// with a nil source — degrading cleanly to fixed character n-grams.
//
// Empty reports whether the source carries any learned boundaries; an
// empty source is treated exactly like a nil one. Split cuts a token's
// runes at the source's boundaries and returns the resulting segments
// left-to-right; it must be deterministic for a given input.
type NgramBoundaries interface {
Empty() bool
Split(runes []rune) []string
}
// ExpandSparseNgrams returns the input word tokens unchanged when the
// sparse-ngram gate is off, and otherwise returns the input tokens
// followed by their emitted sub-word n-grams. The original word tokens
// are always preserved and always come first, so enabling the gate can
// only ADD recall paths — an exact word match still scores through the
// untouched word token, and the appended sub-word grams open additional
// fuzzy-match paths.
//
// The same function runs on both the index and query paths, so the
// postings written for a symbol and the terms a query probes them with
// are produced by identical logic and can never disagree.
//
// When a non-nil, non-empty boundary source is supplied the split
// points are data-driven: each token is cut at the source's
// high-information boundaries and the resulting segments are emitted
// alongside the original token. When the source is nil or empty the
// stage degrades to fixed character n-grams in
// [sparseNgramMinN, sparseNgramMaxN], so the tokenizer behaves
// identically whether or not a learned table has been built yet.
//
// The result is a freshly allocated slice; the input is left untouched.
// Emission is deterministic: for a given token and source the n-grams
// are produced left-to-right in a fixed order with no map iteration.
func ExpandSparseNgrams(tokens []string, table NgramBoundaries) []string {
if !sparseNgramEnabled || len(tokens) == 0 {
return tokens
}
// Preserve the original word tokens verbatim, in order, then append
// the sub-word grams. Over-allocate modestly for the common case.
out := make([]string, 0, len(tokens)*3)
out = append(out, tokens...)
for _, tok := range tokens {
out = appendSparseNgrams(out, tok, table)
}
return out
}
// appendSparseNgrams appends the sub-word n-grams of one lowercase word
// token to dst and returns the grown slice. A learned boundary source,
// when present and non-empty, drives the split; otherwise the token is
// sliced into fixed-width character n-grams. Tokens too short to carry
// any sub-word signal contribute nothing. Duplicate grams within a
// single token are collapsed so a token like "aaaa" does not emit the
// same gram twice.
func appendSparseNgrams(dst []string, tok string, table NgramBoundaries) []string {
r := []rune(tok)
if len(r) < sparseNgramMinTokenLen {
return dst
}
seen := make(map[string]struct{}, len(r))
emit := func(g string) {
if g == "" || g == tok {
return
}
if _, dup := seen[g]; dup {
return
}
seen[g] = struct{}{}
dst = append(dst, g)
}
if table != nil && !table.Empty() {
// Data-driven: split the token at the learned high-information
// boundaries, then emit each segment. Segments shorter than the
// minimum n are dropped — they collide across too many tokens to
// be useful sub-word keys.
for _, seg := range table.Split(r) {
if len([]rune(seg)) >= sparseNgramMinN {
emit(seg)
}
}
return dst
}
// Fixed character n-grams in [min, max]. Cap the upper n at the
// token length so a short token still yields its single full-length
// gram rather than nothing.
for n := sparseNgramMinN; n <= sparseNgramMaxN; n++ {
if n > len(r) {
break
}
for i := 0; i+n <= len(r); i++ {
emit(string(r[i : i+n]))
}
}
return dst
}