项目文件夹

文件
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

507 行
18 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
package rerank
import (
"strings"
"unicode"
)
// QueryClass is the detected shape of a search query. The class tunes
// the bm25 ↔ semantic blend in two places: the per-signal weight
// scaling inside Pipeline.Rerank, and the α value of the hybrid
// alpha-fusion path in internal/search/hybrid.go. Identifier and path
// queries lean on exact-token (BM25) evidence; natural-language
// queries give the semantic channel its full weight.
type QueryClass int
const (
// QueryClassUnknown is the zero value — "not yet classified". A
// caller that leaves it unset lets Pipeline.Rerank auto-detect.
QueryClassUnknown QueryClass = iota
// QueryClassSymbol is a single identifier-shaped token: a symbol
// or API name (validateToken, HTTPServer, pkg.Type). Exact-token
// BM25 evidence dominates.
QueryClassSymbol
// QueryClassConcept is a natural-language description of intent
// ("how does auth refresh", "validate user token"). The semantic
// channel earns its keep here. This is the neutral baseline class.
QueryClassConcept
// QueryClassPath is a file-path-shaped query (internal/auth/token.go,
// auth/handler). Path components are exact tokens and the semantic
// channel is near-useless, so BM25 leans hardest of all classes.
QueryClassPath
// QueryClassSignature is a type- or function-signature fragment
// ("func(ctx) error", "(string) bool"). Structural keywords carry
// the signal: BM25-leaning but less extreme than a bare path.
QueryClassSignature
// QueryClassKeywordSoup is a degenerate boolean / OR-soup query
// ("A OR B OR 'no access'") -- a list of disjuncts rather than a
// description of intent. It defeats ordinary retrieval, so the
// search handler treats it specially: LLM expansion is suppressed
// and the soup is split into per-disjunct BM25 fetches. Detected
// by LooksLikeKeywordSoup; a caller may also pin it via the
// search_symbols query_class argument.
QueryClassKeywordSoup
)
// String returns the lowercase class name used by the search_symbols
// query_class argument and surfaced back on the response.
func (q QueryClass) String() string {
switch q {
case QueryClassSymbol:
return "symbol"
case QueryClassConcept:
return "concept"
case QueryClassPath:
return "path"
case QueryClassSignature:
return "signature"
case QueryClassKeywordSoup:
return "keyword_soup"
default:
return "unknown"
}
}
// ParseQueryClass maps the search_symbols query_class argument to a
// QueryClass. "" and "auto" map to QueryClassUnknown — the signal to
// auto-detect. The bool is false for an unrecognised value so the
// caller can reject it with a clear error.
func ParseQueryClass(s string) (QueryClass, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "auto":
return QueryClassUnknown, true
case "symbol":
return QueryClassSymbol, true
case "concept":
return QueryClassConcept, true
case "path":
return QueryClassPath, true
case "signature":
return QueryClassSignature, true
case "keyword_soup", "soup":
return QueryClassKeywordSoup, true
default:
return QueryClassUnknown, false
}
}
// Hybrid retrieval blend weights for the BM25 ↔ semantic mix used by
// the alpha-fusion path in internal/search/hybrid.go. Lower α leans
// toward BM25 (identifier-style queries where exact tokens dominate);
// higher α gives semantic search more weight (natural-language
// concept queries where wording varies).
//
// The values mirror the empirical sweet spot found by hybrid-search
// benchmarks (HyDE / RAGAS papers): symbol queries benefit from a
// strong BM25 prior; NL queries benefit from a near-balanced blend.
const (
// AlphaSymbol weights BM25 vs semantic for identifier-shaped
// queries (CamelCase, snake_case, namespaced, all-caps). The
// blend favors BM25 because exact-token matches are the most
// reliable signal for code identifiers.
AlphaSymbol = 0.3
// AlphaNL weights BM25 vs semantic for natural-language queries
// ("validate user token", "auth middleware"). Both channels
// contribute roughly equally — semantic catches synonymous
// wording, BM25 catches literal keywords.
AlphaNL = 0.5
// AlphaPath weights BM25 vs semantic for file-path queries. The
// most BM25-heavy blend: path components are exact tokens and the
// semantic channel mostly contributes noise.
AlphaPath = 0.15
// AlphaSignature weights BM25 vs semantic for type/function-
// signature fragments. BM25-leaning — structural keywords are
// literal — but less extreme than a bare path.
AlphaSignature = 0.35
)
// IsSymbolQuery returns true when the query looks like a code
// identifier rather than a natural-language description. The
// classification drives:
//
// - the definition-keyword bias signal (only fires for symbol
// queries — boosting `class Foo` for the query "Foo")
// - the auto-adaptive α blend in hybrid retrieval (symbol queries
// get AlphaSymbol, NL queries get AlphaNL)
//
// Heuristic: the query is a single token (no whitespace) that carries
// at least one structural marker — CamelCase, snake_case, dotted /
// double-colon / slash namespace qualifier, or an all-uppercase
// shape. A multi-word query (containing spaces) is always treated as
// NL even if individual tokens look identifier-shaped — the user is
// describing intent, not naming a symbol. Empty or whitespace-only
// queries return false.
func IsSymbolQuery(query string) bool {
q := strings.TrimSpace(query)
if q == "" {
return false
}
// Multi-token queries are natural-language.
for _, r := range q {
if unicode.IsSpace(r) {
return false
}
}
// Namespace qualifiers: pkg.Type, Module::Symbol, dir/file::Sym,
// path/to/foo.go. Any of these is a strong symbol indicator.
for _, sep := range []string{"::", ".", "/", "\\"} {
if strings.Contains(q, sep) && hasIdentifierChar(q) {
return true
}
}
// Snake-case identifier: at least one underscore between letters
// or digits and no whitespace already established above.
if strings.Contains(q, "_") && hasIdentifierChar(q) {
return true
}
// CamelCase / PascalCase: lowercase→uppercase transition or
// uppercase→lowercase after another uppercase (e.g. HTTPServer).
var prev rune
for i, r := range q {
if i > 0 {
if unicode.IsUpper(r) && unicode.IsLower(prev) {
return true
}
if unicode.IsLower(r) && unicode.IsUpper(prev) {
return true
}
}
prev = r
}
// All-uppercase token of length >= 2 (e.g. URL, JWT, API). Single
// uppercase letter is too ambiguous to flag.
if len(q) >= 2 {
allUpper := true
hasLetter := false
for _, r := range q {
if unicode.IsLetter(r) {
hasLetter = true
if !unicode.IsUpper(r) {
allUpper = false
break
}
}
}
if hasLetter && allUpper {
return true
}
}
return false
}
// hasIdentifierChar reports whether the string contains at least one
// letter or digit. Used by IsSymbolQuery to avoid classifying punct-
// only strings ("::", ".", "/") as symbol queries.
func hasIdentifierChar(s string) bool {
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return true
}
}
return false
}
// ClassifyQuery detects the QueryClass of a query with cheap
// structural heuristics — no LLM, no graph walk. Checks run in
// precedence order: signature markers (parentheses, arrows) are the
// most distinctive, then a path-separated single token, then the
// identifier-shape rubric IsSymbolQuery uses, and finally the
// natural-language default. An empty query classifies as concept.
func ClassifyQuery(query string) QueryClass {
q := strings.TrimSpace(query)
if q == "" {
return QueryClassConcept
}
// Keyword-soup detection runs first: a degenerate boolean OR-list
// is its own class regardless of what its disjuncts look like.
if soup, _ := LooksLikeKeywordSoup(q); soup {
return QueryClassKeywordSoup
}
if looksLikeSignature(q) {
return QueryClassSignature
}
if looksLikePath(q) {
return QueryClassPath
}
if IsSymbolQuery(q) {
return QueryClassSymbol
}
return QueryClassConcept
}
// looksLikeSignature reports whether the query carries an unambiguous
// type/function-signature marker — a parenthesis or a Go/JS-style
// return or lambda arrow. Natural-language queries virtually never do.
func looksLikeSignature(q string) bool {
if strings.ContainsAny(q, "()") {
return true
}
return strings.Contains(q, "->") || strings.Contains(q, "=>")
}
// looksLikePath reports whether the query is a single whitespace-free
// token carrying a directory separator — "internal/auth/token.go",
// "auth/handler". A "::" or bare "." qualifier is NOT a path: those
// stay in the symbol class.
func looksLikePath(q string) bool {
if strings.ContainsAny(q, " \t\n") {
return false
}
if !strings.ContainsAny(q, "/\\") {
return false
}
return hasIdentifierChar(q)
}
// AlphaFor returns the recommended α blend value for the query. It
// scores the query shape on a continuous identifier↔natural-language
// axis (see AlphaForContinuous) rather than snapping to a discrete
// class bucket, so a half-identifier query like "validateToken
// handler" lands between AlphaSymbol and AlphaNL instead of jumping a
// whole tier. The discrete AlphaForClass remains for callers that
// have already classified.
func AlphaFor(query string) float64 {
return AlphaForContinuous(query)
}
// queryStopwords are the relationship / question words that mark a
// query as natural-language intent rather than an identifier list.
// Deliberately excludes code-ish verbs (get/set/find/parse) which are
// real symbol-name fragments. Kept local to rerank so the package
// stays free of an internal/search import cycle.
var queryStopwords = map[string]struct{}{
"how": {}, "does": {}, "do": {}, "the": {}, "a": {}, "an": {}, "of": {},
"to": {}, "is": {}, "are": {}, "in": {}, "on": {}, "for": {}, "and": {},
"or": {}, "with": {}, "where": {}, "what": {}, "when": {}, "why": {},
"which": {}, "this": {}, "that": {}, "from": {}, "by": {}, "as": {},
"about": {}, "into": {}, "via": {},
}
// AlphaForContinuous returns a continuous α blend in [AlphaPath,
// AlphaNL] derived from the query's shape rather than a discrete
// class. Hard structural shapes (signature, path, keyword-soup) still
// pin to their class α — they are unambiguous and the discrete value
// is already correct, and keyword-soup carries non-α side effects
// (LLM suppression, per-disjunct fetch) that must stay class-based.
// Everything else interpolates between AlphaSymbol (a single
// identifier-shaped token) and AlphaNL (multi-word prose) by how
// identifier-like the query reads.
//
// The extreme points match the discrete table — AlphaForContinuous of
// a bare CamelCase token equals AlphaSymbol and of a prose phrase
// equals AlphaNL — so it is a strict refinement, not a re-tuning.
func AlphaForContinuous(query string) float64 {
q := strings.TrimSpace(query)
if q == "" {
return AlphaNL
}
if soup, _ := LooksLikeKeywordSoup(q); soup {
return AlphaForClass(QueryClassKeywordSoup)
}
if looksLikeSignature(q) {
return AlphaSignature
}
if looksLikePath(q) {
return AlphaPath
}
// score in [0,1]: 1 == fully identifier-shaped, 0 == prose.
score := identifierScore(q)
// AlphaSymbol < AlphaNL, so a higher identifier score pulls α down
// toward the BM25-leaning end.
return AlphaNL + score*(AlphaSymbol-AlphaNL)
}
// identifierScore rates how identifier-like a (non-path, non-signature)
// query is, in [0,1]. A single CamelCase/snake/dotted token scores 1;
// a single plain word or any stopword-heavy phrase scores 0; a mixed
// multi-word query interpolates by the fraction of identifier-shaped
// tokens, discounted by stopword density and a gentle length penalty
// (longer queries read more like prose).
func identifierScore(q string) float64 {
tokens := strings.Fields(q)
n := len(tokens)
if n == 0 {
return 0
}
if n == 1 {
if IsSymbolQuery(tokens[0]) {
return 1.0
}
return 0
}
idCount, stopCount := 0, 0
for _, t := range tokens {
if IsSymbolQuery(t) {
idCount++
}
if _, ok := queryStopwords[strings.ToLower(t)]; ok {
stopCount++
}
}
idFrac := float64(idCount) / float64(n)
stopFrac := float64(stopCount) / float64(n)
lengthPenalty := 0.12 * float64(n-2)
score := idFrac - stopFrac - lengthPenalty
if score < 0 {
return 0
}
if score > 1 {
return 1
}
return score
}
// AlphaForClass returns the α blend for a known class. The α-fusion
// formula in hybrid.go is `final = (1-α)·text_rrf + α·vector_rrf`, so
// a smaller α leans toward BM25. QueryClassUnknown falls back to the
// natural-language blend.
func AlphaForClass(c QueryClass) float64 {
switch c {
case QueryClassSymbol:
return AlphaSymbol
case QueryClassPath:
return AlphaPath
case QueryClassSignature:
return AlphaSignature
case QueryClassKeywordSoup:
// Soup is split into per-disjunct BM25 fetches and the LLM
// channel is suppressed -- lean hard on exact-token evidence.
return AlphaSymbol
default: // QueryClassConcept, QueryClassUnknown
return AlphaNL
}
}
// classWeights holds the per-class scaling applied to the bm25 and
// semantic rerank signals. Every other signal scales by 1.0 — the
// per-class lever tunes only the text-vs-semantic balance and leaves
// the structural and session signals untouched.
type classWeights struct {
bm25 float64
semantic float64
// proximity scales the RWR/PPR centrality signal per class.
// Concept (natural-language) queries lean hardest on graph
// proximity — the user is describing intent, so "what is this code
// structurally about" is the strongest discriminator. Exact
// identifier / path queries dampen it so a central-but-wrong symbol
// can't unseat the literal match the user named.
proximity float64
}
// classWeightTable is the tuned per-class multiplier set. Concept is
// the neutral 1.0/1.0 baseline, so natural-language queries score
// exactly as they did before per-class weighting existed; the
// identifier, path, and signature classes push BM25 up and the
// semantic channel down by amounts that grow with how literal the
// query is.
var classWeightTable = map[QueryClass]classWeights{
QueryClassConcept: {bm25: 1.00, semantic: 1.00, proximity: 1.30},
QueryClassSymbol: {bm25: 1.20, semantic: 0.65, proximity: 0.55},
QueryClassPath: {bm25: 1.25, semantic: 0.45, proximity: 0.40},
QueryClassSignature: {bm25: 1.10, semantic: 0.80, proximity: 0.75},
QueryClassKeywordSoup: {bm25: 1.20, semantic: 0.50, proximity: 0.60},
}
// ClassWeightMultiplier returns the factor applied to a signal's
// configured weight for a given query class. Only the bm25 and
// semantic signals are class-sensitive; every other signal — and an
// unknown class — returns 1.0.
func ClassWeightMultiplier(c QueryClass, signal string) float64 {
cw, ok := classWeightTable[c]
if !ok {
return 1.0
}
switch signal {
case SignalBM25:
return cw.bm25
case SignalSemantic, SignalSemanticCosine:
return cw.semantic
case SignalProximity:
if cw.proximity == 0 {
return 1.0
}
return cw.proximity
default:
return 1.0
}
}
// proseWeightTable is the per-signal multiplier applied on top of the
// class / α weighting when Context.ProseMode is set — a documentation
// (prose-section) query. It mirrors the shape of classWeightTable /
// ClassWeightMultiplier but lives on its own lever, independent of
// Alpha, so a docs query gets BOTH its query-shape blend and this
// prose profile.
//
// The profile lifts the two signals that actually discriminate prose
// (bm25 and semantic — a prose section has body text and an embedding,
// little else) and zeroes the code-structural signals that are
// meaningless for a KindDoc node: api_signature and type_signature
// (a prose section has no signature) and definition_bias (no
// definition keyword to match). A signal absent from the table keeps
// its weight unchanged (multiplier 1.0), so the structural / session
// signals that still make sense for prose (recency, feedback,
// file_coherence) are untouched.
var proseWeightTable = map[string]float64{
SignalBM25: 1.25,
SignalSemantic: 1.30,
SignalSemanticCosine: 1.30,
SignalAPISignature: 0.0,
SignalTypeSignature: 0.0,
SignalDefinitionBias: 0.0,
}
// proseWeightMultiplier returns the prose-profile multiplier for a
// signal. Signals not in proseWeightTable return 1.0 (unchanged).
// Applied by Pipeline.Rerank only when Context.ProseMode is set, and
// composed multiplicatively with the class / α multiplier so the two
// levers stay independent.
func proseWeightMultiplier(signal string) float64 {
if m, ok := proseWeightTable[signal]; ok {
return m
}
return 1.0
}
// continuousClassMultiplier is the continuous analogue of
// ClassWeightMultiplier: it maps a continuous α (as produced by
// AlphaForContinuous) onto the bm25 / semantic weight multipliers,
// interpolating smoothly between the natural-language anchor
// (α=AlphaNL → 1.0/1.0, the neutral baseline) and the most
// BM25-leaning anchor (α=AlphaPath → the path class's 1.25/0.45).
// The endpoints reproduce the discrete classWeightTable exactly, so a
// query that snaps to a class scores as it always did and only the
// in-between queries see the interpolation. Pipeline.Rerank uses this
// in place of ClassWeightMultiplier when Context.Alpha is set.
func continuousClassMultiplier(alpha float64, signal string) float64 {
a := alpha
if a < AlphaPath {
a = AlphaPath
}
if a > AlphaNL {
a = AlphaNL
}
// frac: 0 at the NL anchor, 1 at the most BM25-leaning anchor.
frac := (AlphaNL - a) / (AlphaNL - AlphaPath)
// The BM25-leaning anchor's multipliers come from the path class,
// the most exact-token-reliant of the discrete buckets.
maxBM25 := classWeightTable[QueryClassPath].bm25
minSem := classWeightTable[QueryClassPath].semantic
switch signal {
case SignalBM25:
return 1.0 + frac*(maxBM25-1.0)
case SignalSemantic, SignalSemanticCosine:
return 1.0 - frac*(1.0-minSem)
case SignalProximity:
// Interpolate proximity from the concept (NL) anchor down to
// the path anchor, the mirror of how bm25/semantic move: a
// natural-language query leans hardest on graph centrality,
// an exact path/identifier query least.
nlProx := classWeightTable[QueryClassConcept].proximity
pathProx := classWeightTable[QueryClassPath].proximity
return nlProx + frac*(pathProx-nlProx)
default:
return 1.0
}
}