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

361 lines
13 KiB
Go

package rerank
import (
"strings"
"unicode"
)
// soupStopWords is a tight set of English function words used by the
// keyword-density heuristic to tell a list of keywords ("parse encode
// decode marshal ...") apart from a genuine sentence ("how does the
// parser decode the payload"). Kept deliberately small -- the density
// check is already gated behind a boolean-operator co-signal, so a
// few misses here cannot trip a false positive on prose.
var soupStopWords = map[string]struct{}{
"the": {}, "a": {}, "an": {}, "is": {}, "are": {}, "be": {},
"in": {}, "of": {}, "to": {}, "for": {}, "with": {}, "by": {},
"from": {}, "on": {}, "at": {}, "as": {}, "do": {}, "does": {},
"how": {}, "what": {}, "where": {}, "when": {}, "why": {},
"which": {}, "and": {}, "or": {}, "not": {}, "we": {}, "it": {},
"this": {}, "that": {}, "into": {}, "get": {}, "gets": {},
}
// LooksLikeKeywordSoup reports whether a query is a degenerate
// boolean / OR-soup string rather than a usable search query -- the
// shape an agent produces when it pastes a list of disjuncts
// (`A OR B OR 'no access' OR error`) instead of describing intent.
// Such queries defeat BM25: every disjunct competes, the rare term
// drowns, and LLM expansion just amplifies the noise.
//
// The second return value is a short human-readable reason, suitable
// for a `query_advice` nudge on the search response.
//
// The detector is deliberately conservative -- a genuine multi-clause
// natural-language query ("how do we validate the auth token and
// refresh it") must NOT be flagged. Soup is identified only when
// several independent signals agree:
//
// - Two or more standalone boolean operators -- uppercase OR / AND,
// or a pipe (| / ||). A single lowercase "or" in prose does not
// count; the operator must be a separate token.
// - Three or more quoted fragments ('no access', "rate limit"),
// which is how an agent fences multi-word disjuncts.
// - A long token run (8+) that is mostly identifier-shaped tokens
// with almost no English stop-words -- list-of-keywords texture.
//
// Any single strong signal (>=2 boolean operators, or >=3 quoted
// fragments) trips the detector; the operator-co-signal keyword-
// density heuristic trips when it ALSO sees at least one boolean
// operator. A SEPARATE, stricter operator-free branch catches the
// bare space- or comma-separated keyword list ("parse decode
// unmarshal token jwt cache") that carries no operator at all -- it
// demands more tokens and a higher identifier purity than the
// operator-assisted branch precisely because it has no operator to
// lean on, so a long genuine sentence stays classified as a concept
// query.
func LooksLikeKeywordSoup(query string) (bool, string) {
q := strings.TrimSpace(query)
if q == "" {
return false, ""
}
boolOps := countBooleanOperators(q)
quoted := countQuotedFragments(q)
// Strong signal 1: an explicit boolean disjunction of several
// clauses. One operator can be a typo or a literal; two-plus is a
// deliberate (and retrieval-hostile) construction.
if boolOps >= 2 {
return true, soupReasonBoolean
}
// Strong signal 2: many fenced fragments. Three or more quoted
// substrings is an agent enumerating disjuncts, not prose.
if quoted >= 3 {
return true, "query stacks several quoted fragments; split them into separate searches or describe the target in plain words"
}
tokens := strings.Fields(q)
// Density signal: a long run of identifier-shaped tokens with a
// near-zero stop-word ratio. On its own this can be a legitimate
// path-ish or signature-ish query, so it only counts as soup when
// at least one boolean operator is also present.
if len(tokens) >= 8 && boolOps >= 1 {
stop, identShaped := soupTokenStats(tokens)
stopRatio := float64(stop) / float64(len(tokens))
identRatio := float64(identShaped) / float64(len(tokens))
if stopRatio < 0.15 && identRatio > 0.6 {
return true, "query is a long list of keywords with little connecting language; describe the target in a short phrase, or search one keyword at a time"
}
}
// Operator-free density signal: a bare space- or comma-separated
// keyword list with no boolean operator at all. Because there is
// no operator co-signal to lean on, the bar is higher than the
// operator-assisted branch -- it requires more tokens, a stricter
// near-zero stop-word ratio, and a higher identifier purity. This
// is the shape an agent produces when it pastes a bag of search
// terms ("parse decode unmarshal token jwt cache") instead of a
// phrase. The comma form is checked first since a comma list is
// an even stronger enumeration tell.
if looksLikeCommaList(q) {
return true, soupReasonCommaList
}
if len(tokens) >= soupNoOpMinTokens && boolOps == 0 && quoted == 0 {
stop, identShaped := soupTokenStats(tokens)
stopRatio := float64(stop) / float64(len(tokens))
identRatio := float64(identShaped) / float64(len(tokens))
// No coherent phrase: a stop-word "spine" (the / a / of / to /
// how / does ...) is what makes a token run read as a
// sentence. Demand essentially none of it, plus near-total
// identifier purity, before declaring an operator-free list.
if stopRatio <= soupNoOpMaxStopRatio && identRatio >= soupNoOpMinIdentRatio {
return true, soupReasonNoOpList
}
}
return false, ""
}
// Operator-free keyword-soup thresholds. Deliberately stricter than
// the operator-assisted density branch: with no boolean operator to
// corroborate the list shape, the only thing separating a keyword bag
// from a long noun phrase is token count and stop-word density, so
// both bars are raised.
const (
// soupNoOpMinTokens is the minimum token count for the
// operator-free branch. Six terms is past the point where a bag
// of code words reads as a phrase; shorter lists are too easily a
// genuine multi-word concept query ("auth token refresh cache").
soupNoOpMinTokens = 6
// soupNoOpMaxStopRatio caps how much connective tissue an
// operator-free list may carry. A single stop-word in six tokens
// (~0.16) already hints at a phrase, so the ceiling sits just
// below that -- a true keyword bag has essentially none.
soupNoOpMaxStopRatio = 0.15
// soupNoOpMinIdentRatio is the identifier-purity floor. Higher
// than the operator-assisted branch's 0.6 because the operator-
// free branch has no other corroborating signal.
soupNoOpMinIdentRatio = 0.8
)
// soupReasonNoOpList is the advice for an operator-free keyword bag.
const soupReasonNoOpList = "query is a bare list of keywords with no connecting language; describe the target in a short phrase, or search one keyword at a time"
// soupReasonCommaList is the advice for a comma-separated enumeration.
const soupReasonCommaList = "query is a comma-separated list of terms; search one term at a time, or describe the target in a short phrase"
// soupTokenStats counts, over a token slice, how many tokens are
// stop-words and how many are identifier-shaped (after stripping the
// common fencing punctuation). Shared by the density branches so they
// classify tokens identically.
func soupTokenStats(tokens []string) (stop, identShaped int) {
for _, t := range tokens {
lower := strings.ToLower(strings.Trim(t, "'\"`,;()[]{}"))
if lower == "" {
continue
}
if _, ok := soupStopWords[lower]; ok {
stop++
}
if isIdentifierShapedToken(lower) {
identShaped++
}
}
return stop, identShaped
}
// looksLikeCommaList reports whether the query is a comma-separated
// enumeration of terms -- three or more comma-delimited fragments,
// each a short identifier-shaped run, with no fragment reading as a
// sentence. This is the "parse, decode, unmarshal, token" shape an
// agent emits when it commas-up a term bag. A single comma (an aside
// in prose) or a fragment with several words is NOT a list.
func looksLikeCommaList(q string) bool {
if !strings.Contains(q, ",") {
return false
}
parts := strings.Split(q, ",")
frags := 0
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
words := strings.Fields(p)
// A genuine list fragment is one or two terms ("rate limit").
// A fragment of three-plus words is a clause, not a list item
// -- that points back to prose with an aside, not an
// enumeration.
if len(words) > 2 {
return false
}
// Every word in the fragment must be identifier-shaped; a
// stop-word inside a fragment ("the cache") marks prose.
for _, w := range words {
lw := strings.ToLower(strings.Trim(w, "'\"`;()[]{}"))
if _, ok := soupStopWords[lw]; ok {
return false
}
if !isIdentifierShapedToken(lw) {
return false
}
}
frags++
}
return frags >= 3
}
// soupReasonBoolean is the default advice string for a boolean-OR
// soup query; reused by the search handler when it pins the
// keyword_soup class without re-running the detector.
const soupReasonBoolean = "query reads as a boolean OR-list; search ranks best on a single concept or symbol name -- run one query per disjunct, or describe the intent in plain words"
// countBooleanOperators counts standalone boolean-operator tokens in
// the query. Recognised: uppercase OR / AND as whole words, and a
// pipe (| or ||) as a whole token or wedged between two word
// characters (`a|b`). A lowercase "or"/"and" is treated as prose and
// does NOT count -- that is what keeps genuine sentences out of the
// soup bucket.
func countBooleanOperators(q string) int {
n := 0
for _, tok := range strings.Fields(q) {
switch tok {
case "OR", "AND", "||", "|":
n++
continue
}
// An interior pipe (`auth|login`) is a disjunction even when
// not whitespace-separated.
if strings.Contains(tok, "|") && tok != "|" {
// Count each pipe -- `a|b|c` is two disjunctions.
n += strings.Count(tok, "|")
}
}
return n
}
// countQuotedFragments counts balanced single- or double-quoted
// substrings. An unbalanced trailing quote is ignored.
func countQuotedFragments(q string) int {
n := 0
for _, quote := range []byte{'\'', '"'} {
count := strings.Count(q, string(quote))
n += count / 2
}
return n
}
// isIdentifierShapedToken reports whether a token looks like a code
// identifier or a single content word rather than connective tissue
// -- it is non-empty and made only of letters, digits, underscores,
// and the common namespace separators. Used by the keyword-density
// heuristic; intentionally permissive because the density check is
// already gated behind a boolean-operator co-signal.
func isIdentifierShapedToken(t string) bool {
if t == "" {
return false
}
for _, r := range t {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
continue
}
switch r {
case '_', '.', '/', '-', ':':
continue
}
return false
}
return true
}
// SplitSoupFragments breaks a keyword-soup query into its disjunct
// fragments. It splits on the boolean operators LooksLikeKeywordSoup
// recognises (OR / AND / | / ||) AND on commas, unwraps surrounding
// quotes, trims each fragment, and drops empties and duplicates. When
// the query carries no operator and no comma at all -- the bare
// operator-free keyword-list shape -- each whitespace-separated token
// becomes its own fragment, since that list has no delimiter other
// than the spaces between terms. The result feeds the BM25 OR-merge
// path so each disjunct is retrieved on its own terms instead of
// competing inside one mangled query.
func SplitSoupFragments(query string) []string {
q := strings.TrimSpace(query)
if q == "" {
return nil
}
// Normalise the pipe and comma operators to a word boundary so a
// single Fields pass handles `a OR b`, `a|b`, and `a, b` alike.
q = strings.ReplaceAll(q, "||", " | ")
q = strings.ReplaceAll(q, "|", " | ")
q = strings.ReplaceAll(q, ",", " , ")
raw := strings.Fields(q)
hasDelimiter := false
for _, tok := range raw {
switch tok {
case "OR", "AND", "|", ",":
hasDelimiter = true
}
}
// Operator-free, comma-free list: the only delimiter is the
// whitespace between terms, so interleave a comma sentinel between
// every token to force a per-token flush in the shared loop below
// (which still handles quote-unwrap + dedup uniformly).
if !hasDelimiter {
interleaved := make([]string, 0, len(raw)*2)
for _, tok := range raw {
interleaved = append(interleaved, tok, ",")
}
raw = interleaved
}
var (
out []string
cur []string
seen = map[string]struct{}{}
)
flush := func() {
if len(cur) == 0 {
return
}
frag := unquoteFragment(strings.Join(cur, " "))
cur = cur[:0]
frag = strings.TrimSpace(frag)
if frag == "" {
return
}
key := strings.ToLower(frag)
if _, dup := seen[key]; dup {
return
}
seen[key] = struct{}{}
out = append(out, frag)
}
for _, tok := range raw {
switch tok {
case "OR", "AND", "|", ",":
flush()
continue
}
cur = append(cur, tok)
}
flush()
return out
}
// unquoteFragment strips a single matched pair of surrounding single
// or double quotes from a fragment. An unbalanced or absent quote
// leaves the string unchanged.
func unquoteFragment(s string) string {
s = strings.TrimSpace(s)
if len(s) >= 2 {
first, last := s[0], s[len(s)-1]
if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
return s[1 : len(s)-1]
}
}
return s
}