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
154 lines
6.0 KiB
Go
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
|
|
}
|