Skip to content

Commit 3e1a659

Browse files
SimplyLizclaude
andauthored
feat(query): gate LIP semantic rerank on !MixedModels (#208)
When the LIP index contains vectors from more than one embedding model (e.g. during a partial re-index after a model upgrade), cosine similarity across those vector spaces is mathematically meaningless. The MixedModels flag was already surfaced in `ckb doctor` but no query path consumed it — so RerankWithLIP and SemanticSearchWithLIP silently ranked on garbage. Adds a cached health check on Engine (60 s TTL) that short-circuits both semantic call sites when the daemon reports MixedModels, falling back to pure lexical ranking. Surfaces the state via a `lip_mixed_models` DegradationWarning so users learn why semantic ranking is inactive. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3ce4a02 commit 3e1a659

File tree

5 files changed

+219
-3
lines changed

5 files changed

+219
-3
lines changed

internal/query/degradation.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,23 @@ func (e *Engine) GetDegradationWarnings() []DegradationWarning {
5959
}
6060
}
6161

62-
return GenerateDegradationWarnings(scipAvailable, gitAvailable, scipStale, commitsBehind)
62+
warnings := GenerateDegradationWarnings(scipAvailable, gitAvailable, scipStale, commitsBehind)
63+
64+
// LIP mixed-models warning: cosine similarity across different vector spaces
65+
// is meaningless, so semantic rerank/search is gated off when this is set.
66+
// Only emitted once lipSemanticAvailable has probed the daemon — otherwise
67+
// we would falsely claim a mixed-model state before any query has run.
68+
e.lipHealthMu.RLock()
69+
lipMixed := e.cachedLipMixed
70+
lipChecked := !e.lipHealthCheckedAt.IsZero()
71+
e.lipHealthMu.RUnlock()
72+
if lipChecked && lipMixed {
73+
warnings = append(warnings, DegradationWarning{
74+
Code: "lip_mixed_models",
75+
Message: "LIP index contains vectors from multiple embedding models — semantic ranking disabled until re-index.",
76+
CapabilityPercent: 70,
77+
})
78+
}
79+
80+
return warnings
6381
}

internal/query/engine.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ type Engine struct {
6464
cachedState *RepoState
6565
stateComputedAt time.Time
6666

67+
// LIP health (cached; refreshed on a short TTL to avoid per-query RPCs).
68+
lipHealthMu sync.RWMutex
69+
cachedLipMixed bool
70+
cachedLipAvailable bool
71+
lipHealthCheckedAt time.Time
72+
6773
// Cache stats
6874
cacheStatsMu sync.RWMutex
6975
cacheHits int64

internal/query/lip_health.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package query
2+
3+
import (
4+
"time"
5+
6+
"github.com/SimplyLiz/CodeMCP/internal/lip"
7+
)
8+
9+
// lipHealthTTL caps how often we re-probe the LIP daemon for index status.
10+
// IndexStatus is a 200 ms RPC, so we do not want this per-query.
11+
const lipHealthTTL = 60 * time.Second
12+
13+
// lipSemanticAvailable reports whether LIP semantic operations (rerank, semantic
14+
// search) can be trusted. Returns false when the daemon is unavailable OR when
15+
// the index contains vectors from more than one embedding model — cosine
16+
// similarity across different vector spaces is mathematically meaningless, so a
17+
// mixed-model index silently produces garbage rankings.
18+
func (e *Engine) lipSemanticAvailable() bool {
19+
e.lipHealthMu.RLock()
20+
fresh := !e.lipHealthCheckedAt.IsZero() && time.Since(e.lipHealthCheckedAt) < lipHealthTTL
21+
avail, mixed := e.cachedLipAvailable, e.cachedLipMixed
22+
e.lipHealthMu.RUnlock()
23+
if fresh {
24+
return avail && !mixed
25+
}
26+
27+
status, _ := lip.IndexStatus()
28+
e.lipHealthMu.Lock()
29+
e.lipHealthCheckedAt = time.Now()
30+
if status == nil {
31+
e.cachedLipAvailable, e.cachedLipMixed = false, false
32+
} else {
33+
e.cachedLipAvailable, e.cachedLipMixed = true, status.MixedModels
34+
}
35+
avail, mixed = e.cachedLipAvailable, e.cachedLipMixed
36+
e.lipHealthMu.Unlock()
37+
return avail && !mixed
38+
}

internal/query/lip_health_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package query
2+
3+
import (
4+
"encoding/binary"
5+
"encoding/json"
6+
"io"
7+
"net"
8+
"os"
9+
"path/filepath"
10+
"sync/atomic"
11+
"testing"
12+
"time"
13+
)
14+
15+
// startLipHealthDaemon launches a test LIP socket that replies to every
16+
// connection with the supplied indexStatusResp-shaped payload, and returns
17+
// a counter of handled requests. Points LIP_SOCKET at itself.
18+
func startLipHealthDaemon(t *testing.T, mixedModels bool) *int64 {
19+
t.Helper()
20+
21+
payload, err := json.Marshal(map[string]any{
22+
"indexed_files": 1,
23+
"pending_embedding_files": 0,
24+
"last_updated_ms": nil,
25+
"mixed_models": mixedModels,
26+
"models_in_index": []string{"model-a"},
27+
})
28+
if err != nil {
29+
t.Fatalf("marshal: %v", err)
30+
}
31+
32+
dir, err := os.MkdirTemp("/tmp", "lip")
33+
if err != nil {
34+
t.Fatalf("mkdirtemp: %v", err)
35+
}
36+
sockPath := filepath.Join(dir, "s.sock")
37+
ln, err := net.Listen("unix", sockPath)
38+
if err != nil {
39+
os.RemoveAll(dir)
40+
t.Fatalf("listen: %v", err)
41+
}
42+
43+
prev := os.Getenv("LIP_SOCKET")
44+
os.Setenv("LIP_SOCKET", sockPath)
45+
46+
var reqs int64
47+
go func() {
48+
for {
49+
conn, err := ln.Accept()
50+
if err != nil {
51+
return
52+
}
53+
go func(c net.Conn) {
54+
defer c.Close()
55+
_ = c.SetDeadline(time.Now().Add(2 * time.Second))
56+
var lenBuf [4]byte
57+
if _, err := io.ReadFull(c, lenBuf[:]); err != nil {
58+
return
59+
}
60+
reqLen := binary.BigEndian.Uint32(lenBuf[:])
61+
if _, err := io.CopyN(io.Discard, c, int64(reqLen)); err != nil {
62+
return
63+
}
64+
atomic.AddInt64(&reqs, 1)
65+
var out [4]byte
66+
binary.BigEndian.PutUint32(out[:], uint32(len(payload)))
67+
_, _ = c.Write(out[:])
68+
_, _ = c.Write(payload)
69+
}(conn)
70+
}
71+
}()
72+
73+
t.Cleanup(func() {
74+
ln.Close()
75+
os.RemoveAll(dir)
76+
os.Setenv("LIP_SOCKET", prev)
77+
})
78+
return &reqs
79+
}
80+
81+
func TestLipSemanticAvailable_HealthyIndex(t *testing.T) {
82+
startLipHealthDaemon(t, false)
83+
e := &Engine{}
84+
if !e.lipSemanticAvailable() {
85+
t.Fatal("lipSemanticAvailable = false for healthy single-model index, want true")
86+
}
87+
}
88+
89+
func TestLipSemanticAvailable_MixedModels(t *testing.T) {
90+
startLipHealthDaemon(t, true)
91+
e := &Engine{}
92+
if e.lipSemanticAvailable() {
93+
t.Fatal("lipSemanticAvailable = true while MixedModels is set, want false")
94+
}
95+
}
96+
97+
func TestLipSemanticAvailable_DaemonDown(t *testing.T) {
98+
// Point at a socket that doesn't exist.
99+
prev := os.Getenv("LIP_SOCKET")
100+
os.Setenv("LIP_SOCKET", "/tmp/ckb-lip-nonexistent.sock")
101+
t.Cleanup(func() { os.Setenv("LIP_SOCKET", prev) })
102+
103+
e := &Engine{}
104+
if e.lipSemanticAvailable() {
105+
t.Fatal("lipSemanticAvailable = true with no daemon, want false")
106+
}
107+
}
108+
109+
func TestLipSemanticAvailable_CacheWithinTTL(t *testing.T) {
110+
reqs := startLipHealthDaemon(t, false)
111+
e := &Engine{}
112+
113+
for i := 0; i < 5; i++ {
114+
if !e.lipSemanticAvailable() {
115+
t.Fatalf("call %d: lipSemanticAvailable = false, want true", i)
116+
}
117+
}
118+
if got := atomic.LoadInt64(reqs); got != 1 {
119+
t.Fatalf("daemon RPC count = %d, want 1 (TTL cache should suppress subsequent probes)", got)
120+
}
121+
}
122+
123+
func TestGetDegradationWarnings_LipMixedModels(t *testing.T) {
124+
startLipHealthDaemon(t, true)
125+
e := &Engine{}
126+
// Prime the cache so GetDegradationWarnings has something to read.
127+
_ = e.lipSemanticAvailable()
128+
129+
warnings := e.GetDegradationWarnings()
130+
var found bool
131+
for _, w := range warnings {
132+
if w.Code == "lip_mixed_models" {
133+
found = true
134+
break
135+
}
136+
}
137+
if !found {
138+
t.Fatalf("expected lip_mixed_models warning, got %+v", warnings)
139+
}
140+
}
141+
142+
func TestGetDegradationWarnings_NoWarningBeforeFirstProbe(t *testing.T) {
143+
// Daemon exists and is mixed, but we never call lipSemanticAvailable so
144+
// the cache has not been populated — we should not emit a warning.
145+
startLipHealthDaemon(t, true)
146+
e := &Engine{}
147+
148+
warnings := e.GetDegradationWarnings()
149+
for _, w := range warnings {
150+
if w.Code == "lip_mixed_models" {
151+
t.Fatalf("lip_mixed_models warning surfaced before first probe: %+v", w)
152+
}
153+
}
154+
}

internal/query/symbols.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ func (e *Engine) SearchSymbols(ctx context.Context, opts SearchSymbolsOptions) (
514514
// content table. The threshold of 3 mirrors the PPR/LIP re-ranking gate — below
515515
// that the lexical results aren't trustworthy enough to stand alone.
516516
const lipFallbackThreshold = 3
517-
if len(results) < lipFallbackThreshold {
517+
if len(results) < lipFallbackThreshold && e.lipSemanticAvailable() {
518518
lipSymLimit := opts.Limit * 3
519519
lipResults := SemanticSearchWithLIP(opts.Query, 20, "", 0, func(fileURIs []string) map[string][]SearchResultItem {
520520
// Convert file:// URIs back to repo-relative paths for the batch query.
@@ -639,7 +639,7 @@ func (e *Engine) SearchSymbols(ctx context.Context, opts SearchSymbolsOptions) (
639639
results = reranked
640640
}
641641
}
642-
} else if len(results) > 3 && !lipRanked {
642+
} else if len(results) > 3 && !lipRanked && e.lipSemanticAvailable() {
643643
// Fast tier: use LIP file embeddings as a semantic re-ranking signal.
644644
// Skip when results already came from LIP semantic search (lipRanked=true) —
645645
// they're already ordered by similarity, a second pass would be redundant.

0 commit comments

Comments
 (0)