Skip to content

Commit d10374f

Browse files
authored
feat(router): make KNN a first-class classifier with a persisted, curated corpus (#10652)
* feat(router): make KNN a first-class classifier with a persisted, curated corpus Add `classifier: knn` — similarity-weighted voting over labelled example prompts. Unlike score/colbert it needs no classifier model: label knowledge lives in a corpus seeded and curated through the admin API, so routing decisions are deterministic, auditable, and grounded in graded experience rather than a model's opinion. Epistemic gate: corpus entries below knn.similarity_threshold cannot vote; when none clears it the classifier activates no labels and the router uses the fallback — a prompt unlike all labelled experience is treated as undecidable, not guessed. Decisions record nearest_similarity (also on fallback rows) so admins can see how far the nearest labelled experience was; the Routing tab explains out-of-corpus fallbacks and shows per-label corpus counts. Persistence: one JSONL file per router under <data path>/router-corpus (text, labels, vector, embedder fingerprint). The file is the source of truth; the local-store index is rebuilt from it at classifier build time and stays a pure in-memory index. Entries recorded under a different embedding model re-embed on load. Also corrects the docs' false claim that local-store collections persist — the embedding cache never survived restarts (and still doesn't); the corpus does. Corpus input is API-only by design (entries may contain example user content): POST /api/router/{name}/corpus seeds (labels validated against declared policies, embedded server-side, indexed immediately), GET .../corpus/stats inspects — label counts only, entry texts are never returned by any surface — DELETE .../corpus wipes. Admin-gated like the sibling router endpoints, and exposed as MCP tools (seed_router_corpus / get_router_corpus_stats / clear_router_corpus) in both the httpapi and inproc clients with coverage-test route mappings. Plumbing: VectorStore gains SearchK (top-K was hardcoded to 1); local-store gets InsertBatch/Delete as optional fast paths; RouterConfig gains a knn block (embedding_model, k, similarity_threshold, vote_threshold, store_name) with meta-registry fields; the classifier dropdown now offers knn and the previously-missing colbert; embedding_cache is ignored (with a warning) for knn — it IS an embedding-KNN lookup; the stale /api/instructions intelligent-routing entry is rewritten (it described a classifier that no longer exists); swagger regenerated. Tests: KNN vote/gate specs with hand-computed vote shares, corpus manager suite (restart reload without re-embedding, fingerprint re-embed, dedupe, hostile store names), middleware specs (corpus routing, gate fallback, config validation, cache-wrap refusal), corpus endpoint specs pinning the texts-never-returned contract, MCP catalog + route-mapping gates, and a Playwright spec for corpus stats and the out-of-corpus decision detail. Assisted-by: Claude:claude-fable-5 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(router): name consulted corpus neighbours in knn decisions Every knn decision (decision log rows and the /api/router/decide response) now carries neighbors: the K retrieved corpus entries by descending similarity - including ones below the epistemic gate, which is what makes fallback decisions diagnosable - each as {id, similarity, labels}. The id is the entry's content hash (first 8 bytes of the SHA-256 of its text, hex): stable across reseeds and re-embeds, and text-free, so an external platform that seeded the corpus can recompute text->id on its own copy and bucket decisions by corpus region (per- region reliability accounting) without corpus text ever leaving the server. A corrupt index payload surfaces as an id-less neighbour at a real similarity instead of disappearing. Assisted-by: Claude:claude-fable-5 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * refactor(router): deduplicate knn plumbing and cut corpus hot-path waste Post-review cleanup of the knn-first-class-router branch; no behaviour changes on the API surface. Reuse/altitude: - RouterKNNConfig.ResolvedStoreName is now the single source of the router-corpus-<name> default (was hand-derived in four files). - corpus.ResolveKNNRouter + corpus.Seed carry the shared model resolution and seed validation; the REST endpoints and the assistant MCP client are thin transport adapters over them, with sentinel errors mapped to HTTP statuses at the echo boundary. - middleware.NewClassifierDeps assembles the classifier dependency set once for all five entry points (OpenAI, Anthropic, realtime, decide, corpus) instead of five hand-copied literals. - router.AllClassifiers feeds both the status endpoint and the unknown-classifier error, ending the classifier-list drift. - Per-classifier requirements moved out of validateRouterPolicies into their buildClassifier arms; the knn arm owns its embedding_cache opt-out instead of a name-check in the shared wrap tail. - adminOnly replaces four inline copies of the admin gate in the middleware routes. - localVectorStore.Search delegates to SearchK (identical traces). Efficiency: - Manager.Add embeds outside the manager mutex and appends to the JSONL file (O(new) instead of O(corpus) rewrite); a torn tail from a crash mid-append is tolerated on read and repaired on next write. - Stats memoises per store keyed on the file's stat fingerprint and no longer takes the manager mutex, so the 5s status poll stops parsing vector-laden JSONL and stops blocking behind seeds. - KNN Classify decodes each neighbour payload once (was twice) and builds refs and votes in a single pass with one fallback return. - Corpus file writes fsync before rename/close. - The corpus manager is built eagerly in newApplication (sync.Once dropped); test helper dead branch removed. Assisted-by: Claude:claude-fable-5 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(router): bind knn corpus vectors to an embedder fingerprint and fail closed on mismatch Assisted-by: Claude:claude-fable-5 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * chore(mcp): align corpus tool prompts and the mutating-tool safety list Assisted-by: Claude:claude-fable-5 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(proto,backend): report embedding shape from the llama-cpp backend Assisted-by: Claude:claude-fable-5 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(embeddings): Go-side pooling — mean/last/decayed_mean with half-life Assisted-by: Claude:claude-fable-5 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(embeddings): accept chat messages[] and per-request pooling on /v1/embeddings Assisted-by: Claude:claude-fable-5 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * chore(middleware): name the failing fields when post-merge validation 400s An intermittent post-merge validation failure surfaced as an opaque 400 during integration (pooling scheme mismatch that no client had sent). Log the model, the request's pooling override, and the merged config's pooling fields at the failure point so the next occurrence identifies whether the request or the stored config carried the bad value. Assisted-by: Claude:claude-fable-5 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(embeddings): scheme override must not inherit the config's half-life A model config defaulting to decayed_mean pooling carries pooling_half_life_tokens; a request overriding the scheme to mean/last without its own half-life inherited that value, and post-merge validation rejected the pair the server itself had assembled. Zero the inherited half-life when the overridden scheme is not decayed_mean; a request that explicitly pairs a half-life with a non-decayed scheme still 400s. Assisted-by: Claude:claude-fable-5 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix embedding pooling validation and router bounds Declare backend embedding layouts and reject incompatible pooling modes. Reset local-store dimensions after a full clear, validate KNN thresholds, and add real backend and store integration coverage. Assisted-by: Codex:gpt-5 Signed-off-by: Richard Palethorpe <io@richiejp.com> * ci: run local-store integration tests Build and install the local-store backend in the Linux test job, then run the existing store integration suite so new specs are discovered automatically. Assisted-by: Codex:gpt-5 Signed-off-by: Richard Palethorpe <io@richiejp.com> --------- Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 10b4a8a commit d10374f

80 files changed

Lines changed: 6166 additions & 320 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-extra.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,7 @@ jobs:
526526
- name: Build llama-cpp backend image and run gRPC e2e tests
527527
run: |
528528
make test-extra-backend-llama-cpp
529+
make test-extra-backend-llama-cpp-embeddings
529530
tests-llama-cpp-grpc-transcription:
530531
needs: detect-changes
531532
if: needs.detect-changes.outputs.llama-cpp == 'true' || needs.detect-changes.outputs.run-all == 'true'

.github/workflows/test.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ jobs:
6565
- name: Test (with coverage gate)
6666
run: |
6767
PATH="$PATH:/root/go/bin" make --jobs 5 --output-sync=target test-coverage-check
68+
# tests/integration is outside the coverage roots because its store specs
69+
# need a live backend. test-stores builds and installs local-store before
70+
# running the complete suite, so new local-store specs are collected
71+
# automatically without adding another workflow entry.
72+
- name: Test local-store integration
73+
run: PATH="$PATH:$HOME/go/bin" make test-stores
6874
- name: Upload coverage report
6975
if: ${{ always() }}
7076
uses: actions/upload-artifact@v4

Makefile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,7 @@ test-extra: prepare-test-extra
676676
## BACKEND_TEST_PROMPT Override the prompt used in predict/stream specs.
677677
## BACKEND_TEST_OPTIONS Comma-separated Options[] entries forwarded to LoadModel,
678678
## e.g. "tool_parser:hermes,reasoning_parser:qwen3".
679+
## BACKEND_TEST_EMBEDDING_LAYOUT Expected EmbeddingResult layout: "final" or "per_token".
679680
##
680681
## Direct usage (image already built, no docker-build-* dependency):
681682
##
@@ -705,6 +706,7 @@ test-extra-backend: protogen-go
705706
BACKEND_TEST_CAPS="$$BACKEND_TEST_CAPS" \
706707
BACKEND_TEST_PROMPT="$$BACKEND_TEST_PROMPT" \
707708
BACKEND_TEST_OPTIONS="$$BACKEND_TEST_OPTIONS" \
709+
BACKEND_TEST_EMBEDDING_LAYOUT="$$BACKEND_TEST_EMBEDDING_LAYOUT" \
708710
BACKEND_TEST_TOOL_PROMPT="$$BACKEND_TEST_TOOL_PROMPT" \
709711
BACKEND_TEST_TOOL_NAME="$$BACKEND_TEST_TOOL_NAME" \
710712
BACKEND_TEST_CACHE_TYPE_K="$$BACKEND_TEST_CACHE_TYPE_K" \
@@ -724,6 +726,15 @@ test-extra-backend-llama-cpp: docker-build-llama-cpp
724726
BACKEND_TEST_CAPS=health,load,predict,stream,logprobs,logit_bias \
725727
$(MAKE) test-extra-backend
726728

729+
## Raw llama.cpp embeddings are required by Go-side pooling. This exercises the
730+
## real C++ backend and verifies that it marks the flattened matrix per-token.
731+
test-extra-backend-llama-cpp-embeddings: docker-build-llama-cpp
732+
BACKEND_IMAGE=local-ai-backend:llama-cpp \
733+
BACKEND_TEST_CAPS=health,load,embeddings \
734+
BACKEND_TEST_OPTIONS=pooling:none \
735+
BACKEND_TEST_EMBEDDING_LAYOUT=per_token \
736+
$(MAKE) test-extra-backend
737+
727738
test-extra-backend-ik-llama-cpp: docker-build-ik-llama-cpp
728739
BACKEND_IMAGE=local-ai-backend:ik-llama-cpp $(MAKE) test-extra-backend
729740

@@ -813,6 +824,7 @@ test-extra-backend-tinygrad-embeddings: docker-build-tinygrad
813824
BACKEND_IMAGE=local-ai-backend:tinygrad \
814825
BACKEND_TEST_MODEL_NAME=Qwen/Qwen3-0.6B \
815826
BACKEND_TEST_CAPS=health,load,embeddings \
827+
BACKEND_TEST_EMBEDDING_LAYOUT=final \
816828
$(MAKE) test-extra-backend
817829

818830
## tinygrad — Stable Diffusion 1.5. The original CompVis/runwayml repos have

backend/backend.proto

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,8 +536,28 @@ message Result {
536536
bool success = 2;
537537
}
538538

539+
// EmbeddingLayout describes whether embeddings contains one final vector or
540+
// a matrix of per-token vectors. Go-side pooling must never infer this from
541+
// tokens/dim alone: a one-token raw matrix and a final vector have the same
542+
// shape.
543+
enum EmbeddingLayout {
544+
EMBEDDING_LAYOUT_UNSPECIFIED = 0;
545+
EMBEDDING_LAYOUT_FINAL = 1;
546+
EMBEDDING_LAYOUT_PER_TOKEN = 2;
547+
}
548+
539549
message EmbeddingResult {
540550
repeated float embeddings = 1;
551+
// Shape of the payload above: dim is the embedding width, tokens is the
552+
// number of vectors packed into `embeddings` (1 when the backend pooled
553+
// server-side, N with pooling:none; total across prompts if a request
554+
// carried several). tokens=0/dim=0 means the backend predates shape
555+
// reporting. prompt_tokens is the number of prompt tokens evaluated, for
556+
// usage accounting.
557+
int32 tokens = 2;
558+
int32 dim = 3;
559+
int32 prompt_tokens = 4;
560+
EmbeddingLayout layout = 5;
541561
}
542562

543563
message TranscriptRequest {

backend/cpp/ik-llama-cpp/grpc-server.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2565,6 +2565,7 @@ class BackendServiceImpl final : public backend::Backend::Service {
25652565
grpc::Status Embedding(ServerContext* context, const backend::PredictOptions* request, backend::EmbeddingResult* embeddingResult) {
25662566
auto identity = checkModelIdentity(request);
25672567
if (!identity.ok()) return identity;
2568+
embeddingResult->set_layout(backend::EMBEDDING_LAYOUT_FINAL);
25682569
json data = parse_options(false, request, llama);
25692570
const int task_id = llama.queue_tasks.get_new_id();
25702571
llama.queue_results.add_waiting_task_id(task_id);

backend/cpp/llama-cpp/grpc-server.cpp

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2911,42 +2911,40 @@ class BackendServiceImpl final : public backend::Backend::Service {
29112911
return grpc::Status(grpc::StatusCode::INTERNAL, all_results.error->to_json().value("message", "Error in receiving results"));
29122912
}
29132913

2914-
// Collect responses
2915-
json responses = json::array();
2914+
// Extract the embeddings typed, straight from the task results (no
2915+
// JSON round-trip), and report the payload shape alongside the same
2916+
// flat float array as before: dim is the embedding width, tokens the
2917+
// number of vectors packed into `embeddings` (1 per prompt when the
2918+
// server pooled, one per token with pooling:none; summed across
2919+
// prompts if the request carried several), prompt_tokens the prompt
2920+
// tokens evaluated, for usage accounting. Consumers seeing 0/0 know
2921+
// the backend predates shape reporting.
2922+
int32_t n_vectors = 0;
2923+
int32_t dim = 0;
2924+
int32_t prompt_tokens = 0;
29162925
for (auto & res : all_results.results) {
2917-
GGML_ASSERT(dynamic_cast<server_task_result_embd*>(res.get()) != nullptr);
2918-
responses.push_back(res->to_json());
2919-
}
2920-
2921-
std::cout << "[DEBUG] Responses size: " << responses.size() << std::endl;
2922-
2923-
// Process the responses and extract embeddings
2924-
for (const auto & response_elem : responses) {
2925-
// Check if the response has an "embedding" field
2926-
if (response_elem.contains("embedding")) {
2927-
json embedding_data = json_value(response_elem, "embedding", json::array());
2928-
2929-
if (embedding_data.is_array() && !embedding_data.empty()) {
2930-
for (const auto & embedding_vector : embedding_data) {
2931-
if (embedding_vector.is_array()) {
2932-
for (const auto & embedding_value : embedding_vector) {
2933-
embeddingResult->add_embeddings(embedding_value.get<float>());
2934-
}
2935-
}
2936-
}
2926+
auto * embd_res = dynamic_cast<server_task_result_embd*>(res.get());
2927+
GGML_ASSERT(embd_res != nullptr);
2928+
prompt_tokens += embd_res->n_tokens;
2929+
for (const auto & vec : embd_res->embedding) {
2930+
for (const float value : vec) {
2931+
embeddingResult->add_embeddings(value);
29372932
}
2938-
} else {
2939-
// Check if the response itself contains the embedding data directly
2940-
if (response_elem.is_array()) {
2941-
for (const auto & embedding_value : response_elem) {
2942-
embeddingResult->add_embeddings(embedding_value.get<float>());
2943-
}
2933+
if (!vec.empty()) {
2934+
n_vectors++;
2935+
dim = (int32_t) vec.size();
29442936
}
29452937
}
29462938
}
2939+
embeddingResult->set_tokens(n_vectors);
2940+
embeddingResult->set_dim(dim);
2941+
embeddingResult->set_prompt_tokens(prompt_tokens);
2942+
embeddingResult->set_layout(
2943+
llama_pooling_type(ctx_server.get_llama_context()) == LLAMA_POOLING_TYPE_NONE
2944+
? backend::EMBEDDING_LAYOUT_PER_TOKEN
2945+
: backend::EMBEDDING_LAYOUT_FINAL);
29472946

2948-
2949-
2947+
std::cout << "[DEBUG] Embedding vectors: " << n_vectors << " x " << dim << std::endl;
29502948

29512949
return grpc::Status::OK;
29522950
}

backend/go/local-store/store.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ type Store struct {
3838
// keysAreNormalized stays true until any non-unit-magnitude key
3939
// is added; once false, the magnitude-aware fallback path is
4040
// used by Find. Re-evaluated only at Set time, never again on
41-
// its own — a deletion of the offending key does NOT flip it
42-
// back to true (the bookkeeping cost would dominate the gain).
41+
// its own — a partial deletion of the offending key does NOT flip
42+
// it back to true (the bookkeeping cost would dominate the gain).
43+
// An empty store returns to its initial state.
4344
keysAreNormalized bool
4445

4546
// keyLen is the dimension of every stored key. -1 means "no
@@ -142,6 +143,10 @@ func (s *Store) StoresDelete(opts *pb.StoresDeleteOptions) error {
142143
mergedV = append(mergedV, tailV...)
143144
s.keys = mergedK
144145
s.values = mergedV
146+
if len(s.keys) == 0 {
147+
s.keyLen = -1
148+
s.keysAreNormalized = true
149+
}
145150
assert(slices.IsSortedFunc(s.keys, slices.Compare[[]float32]), "Delete: s.keys not sorted post-merge")
146151
assert(len(s.keys) == len(s.values), "Delete: keys/values length skew")
147152
return nil

backend/go/local-store/store_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,46 @@ var _ = Describe("StoresDelete", func() {
105105
})).To(Succeed(), "delete of missing key should succeed")
106106
Expect(s.keys).To(HaveLen(1))
107107
})
108+
109+
It("reopens the dimension after deleting every key", func() {
110+
s := NewStore()
111+
oldKey := []float32{2, 0, 0}
112+
mustSet(s, [][]float32{oldKey}, [][]byte{[]byte("3d")})
113+
Expect(s.keysAreNormalized).To(BeFalse())
114+
115+
Expect(s.StoresDelete(&pb.StoresDeleteOptions{
116+
Keys: wrapKeys([][]float32{oldKey}),
117+
})).To(Succeed())
118+
Expect(s.keys).To(BeEmpty())
119+
Expect(s.keyLen).To(Equal(-1))
120+
Expect(s.keysAreNormalized).To(BeTrue())
121+
122+
newKey := normalizeVec([]float32{1, 1})
123+
mustSet(s, [][]float32{newKey}, [][]byte{[]byte("2d")})
124+
res, err := s.StoresFind(&pb.StoresFindOptions{
125+
Key: &pb.StoresKey{Floats: newKey},
126+
TopK: 1,
127+
})
128+
Expect(err).NotTo(HaveOccurred())
129+
Expect(res.Values).To(HaveLen(1))
130+
Expect(string(res.Values[0].Bytes)).To(Equal("2d"))
131+
})
132+
133+
It("retains the dimension after a partial delete", func() {
134+
s := NewStore()
135+
mustSet(s,
136+
[][]float32{{1, 0, 0}, {0, 1, 0}},
137+
[][]byte{[]byte("x"), []byte("y")},
138+
)
139+
Expect(s.StoresDelete(&pb.StoresDeleteOptions{
140+
Keys: wrapKeys([][]float32{{1, 0, 0}}),
141+
})).To(Succeed())
142+
Expect(s.keyLen).To(Equal(3))
143+
Expect(s.StoresSet(&pb.StoresSetOptions{
144+
Keys: wrapKeys([][]float32{{1, 0}}),
145+
Values: wrapValues([][]byte{[]byte("2d")}),
146+
})).NotTo(Succeed())
147+
})
108148
})
109149

110150
var _ = Describe("StoresFind", func() {

backend/python/insightface/backend.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@ def Embedding(self, request, context):
127127
context.set_code(grpc.StatusCode.NOT_FOUND)
128128
context.set_details("no face detected")
129129
return backend_pb2.EmbeddingResult()
130-
return backend_pb2.EmbeddingResult(embeddings=[float(x) for x in vec])
130+
return backend_pb2.EmbeddingResult(
131+
embeddings=[float(x) for x in vec],
132+
layout=backend_pb2.EMBEDDING_LAYOUT_FINAL,
133+
)
131134

132135
def Detect(self, request, context):
133136
if self.engine is None:

backend/python/tinygrad/backend.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -638,7 +638,10 @@ async def Embedding(self, request, context):
638638
normalized = (pooled / (norm + 1e-12))
639639
vec = normalized.cast(dtypes.float32).tolist()
640640

641-
return backend_pb2.EmbeddingResult(embeddings=[float(x) for x in vec])
641+
return backend_pb2.EmbeddingResult(
642+
embeddings=[float(x) for x in vec],
643+
layout=backend_pb2.EMBEDDING_LAYOUT_FINAL,
644+
)
642645
except Exception as exc:
643646
import traceback
644647
traceback.print_exc()

0 commit comments

Comments
 (0)