feat(knowledge-base): validate vector store bindings on create, copy, and delete#1372
Merged
Merged
Conversation
… and delete Wires KnowledgeBase.VectorStoreID and the ownership-aware retrieve factory into the user-facing knowledge-base lifecycle: - POST /knowledge-bases validates the requested vector_store_id against the caller's tenant scope and the engine registry. New error codes ErrVectorStoreBindingInvalid (2200) and ErrVectorStoreUnavailable (2201) distinguish the typed branches without echoing UUIDs to the client. - GET / POST / PUT / PUT-pin responses embed the bound store's display metadata (name, source, engine_type, status) without exposing any connection credentials. Cross-tenant shared KBs receive a suppressed payload (vector_store_id stripped, source="shared") so operator-chosen store names cannot be enumerated across tenants. - POST /knowledge-bases/copy synchronously rejects clones whose target has a different embedding model or vector store, before the async clone task is enqueued. The async clone worker re-applies the same checks for defense in depth. - DELETE /vector-stores/:id refuses to remove a store with bound KBs, inside a transaction that row-locks the store on PostgreSQL and serializes via WAL on SQLite. unregister-from-registry is wrapped in defer/recover so a panic surfaces as a structured warning instead of silently leaking a stale engine. - vector_store_id is immutable after creation. The GORM <-:create tag blocks every ORM update path; the service-layer DTO omits the field entirely; a reflection-based regression test catches any future maintainer who adds it back to either layer. - Empty-string vector_store_id is normalized to nil at both the create path and inside SharesStoreWith, so rows persisted by callers that did not run Normalize first cannot trip false same-store comparisons. Part of Tencent#993. Depends on Tencent#994 and Tencent#1310.
ce62712 to
a2474ce
Compare
ochanism
added a commit
to ochanism/WeKnora
that referenced
this pull request
May 18, 2026
Multi-KB hybrid search now groups KBs by their bound VectorStore (partition key (storeID, owner_tenant_id)), retrieves in parallel via errgroup with a SetLimit(4) cap and a per-group timeout (MULTI_STORE_RETRIEVE_TIMEOUT_SEC, default 30s), and merges results. When the collected results span more than one engine type, an EngineAwareNormalizer rescales vector scores to [0, 1]; keyword (BM25) scores pass through to the existing RRF fusion. Single-group calls take the fast path with zero fan-out overhead, preserving today's behavior for deployments where every KB has vector_store_id = NULL. Embedding-model consistency is now enforced explicitly via ResolveEmbeddingModelKeys. Multi-KB searches across KBs whose resolved model identities differ return BadRequest instead of silently producing incomparable scores. Cross-tenant Organization-shared KBs are preserved by partitioning on KB.TenantID so the factory's ownership lookup runs against the source tenant. Service-layer typed AppErrors (ErrVectorStoreBindingInvalid 2200 / ErrVectorStoreUnavailable 2201) are mapped from PR2 sentinel hierarchy and preserved end-to-end: the iterative FAQ path returns them rather than swallowing, and the HybridSearch handler routes typed AppErrors to the client unchanged instead of downgrading to 500. Part of Tencent#993 (Phase 2: Per-KB VectorStore Binding). Phase 2 roadmap item: PR 4 (Multi-store fan-out search). Depends on Tencent#994, Tencent#1310, Tencent#1372.
ochanism
added a commit
to ochanism/WeKnora
that referenced
this pull request
May 18, 2026
Multi-KB hybrid search now groups KBs by their bound VectorStore (partition key (storeID, owner_tenant_id)), retrieves in parallel via errgroup with a SetLimit(4) cap and a per-group timeout (MULTI_STORE_RETRIEVE_TIMEOUT_SEC, default 30s), and merges results. When the collected results span more than one engine type, an EngineAwareNormalizer rescales vector scores to [0, 1]; keyword (BM25) scores pass through to the existing RRF fusion. Single-group calls take the fast path with zero fan-out overhead, preserving today's behavior for deployments where every KB has vector_store_id = NULL. Embedding-model consistency is now enforced explicitly via ResolveEmbeddingModelKeys. Multi-KB searches across KBs whose resolved model identities differ return BadRequest instead of silently producing incomparable scores. Cross-tenant Organization-shared KBs are preserved by partitioning on KB.TenantID so the factory's ownership lookup runs against the source tenant. Service-layer typed AppErrors (ErrVectorStoreBindingInvalid 2200 / ErrVectorStoreUnavailable 2201) are mapped from PR2 sentinel hierarchy and preserved end-to-end: the iterative FAQ path returns them rather than swallowing, and the HybridSearch handler routes typed AppErrors to the client unchanged instead of downgrading to 500. Part of Tencent#993 (Phase 2: Per-KB VectorStore Binding). Phase 2 roadmap item: PR 4 (Multi-store fan-out search). Depends on Tencent#994, Tencent#1310, Tencent#1372.
ochanism
added a commit
to ochanism/WeKnora
that referenced
this pull request
May 18, 2026
Multi-KB hybrid search now groups KBs by their bound VectorStore (partition key (storeID, owner_tenant_id)), retrieves in parallel via errgroup with a SetLimit(4) cap and a per-group timeout (MULTI_STORE_RETRIEVE_TIMEOUT_SEC, default 30s), and merges results. When the collected results span more than one engine type, an EngineAwareNormalizer rescales vector scores to [0, 1]; keyword (BM25) scores pass through to the existing RRF fusion. Single-group calls take the fast path with zero fan-out overhead, preserving today's behavior for deployments where every KB has vector_store_id = NULL. Embedding-model consistency is now enforced explicitly via ResolveEmbeddingModelKeys. Multi-KB searches across KBs whose resolved model identities differ return BadRequest instead of silently producing incomparable scores. Cross-tenant Organization-shared KBs are preserved by partitioning on KB.TenantID so the factory's ownership lookup runs against the source tenant. Service-layer typed AppErrors (ErrVectorStoreBindingInvalid 2200 / ErrVectorStoreUnavailable 2201) are mapped from PR2 sentinel hierarchy and preserved end-to-end: the iterative FAQ path returns them rather than swallowing, and the HybridSearch handler routes typed AppErrors to the client unchanged instead of downgrading to 500. Part of Tencent#993 (Phase 2: Per-KB VectorStore Binding). Phase 2 roadmap item: PR 4 (Multi-store fan-out search). Depends on Tencent#994, Tencent#1310, Tencent#1372.
7 tasks
ochanism
added a commit
to ochanism/WeKnora
that referenced
this pull request
May 18, 2026
Multi-KB hybrid search now groups KBs by their bound VectorStore (partition key (storeID, owner_tenant_id)), retrieves in parallel via errgroup with a SetLimit(4) cap and a per-group timeout (MULTI_STORE_RETRIEVE_TIMEOUT_SEC, default 30s), and merges results. When the collected results span more than one engine type, an EngineAwareNormalizer rescales vector scores to [0, 1]; keyword (BM25) scores pass through to the existing RRF fusion. Single-group calls take the fast path with zero fan-out overhead, preserving today's behavior for deployments where every KB has vector_store_id = NULL. Embedding-model consistency is now enforced explicitly via ResolveEmbeddingModelKeys. Multi-KB searches across KBs whose resolved model identities differ return BadRequest instead of silently producing incomparable scores. Cross-tenant Organization-shared KBs are preserved by partitioning on KB.TenantID so the factory's ownership lookup runs against the source tenant. Foreign-tenant KB UUIDs injected via the request body are rejected via kbShareService.HasTenantKBPermission (Plan 3 of Tencent#1303, 3-D capped) before any retrieval; rejected scopes surface as 404 to avoid leaking foreign KB existence. Service-layer typed AppErrors (ErrVectorStoreBindingInvalid 2200 / ErrVectorStoreUnavailable 2201) are mapped from PR2 sentinel hierarchy and preserved end-to-end: the iterative FAQ path returns them rather than swallowing, and the HybridSearch handler routes typed AppErrors to the client unchanged instead of downgrading to 500. Part of Tencent#993 (Phase 2: Per-KB VectorStore Binding). Phase 2 roadmap item: PR 4 (Multi-store fan-out search). Depends on Tencent#994, Tencent#1310, Tencent#1372.
ochanism
added a commit
to ochanism/WeKnora
that referenced
this pull request
May 18, 2026
Multi-KB hybrid search now groups KBs by their bound VectorStore (partition key (storeID, owner_tenant_id)), retrieves in parallel via errgroup with a SetLimit(4) cap and a per-group timeout (MULTI_STORE_RETRIEVE_TIMEOUT_SEC, default 30s), and merges results. When the collected results span more than one engine type, an EngineAwareNormalizer rescales vector scores to [0, 1]; keyword (BM25) scores pass through to the existing RRF fusion. Single-group calls take the fast path with zero fan-out overhead, preserving today's behavior for deployments where every KB has vector_store_id = NULL. Embedding-model consistency is now enforced explicitly via ResolveEmbeddingModelKeys. Multi-KB searches across KBs whose resolved model identities differ return BadRequest instead of silently producing incomparable scores. Cross-tenant Organization-shared KBs are preserved by partitioning on KB.TenantID so the factory's ownership lookup runs against the source tenant. Foreign-tenant KB UUIDs injected via the request body are rejected via kbShareService.HasTenantKBPermission (Plan 3 of Tencent#1303, 3-D capped) before any retrieval; rejected scopes surface as 404 to avoid leaking foreign KB existence. Service-layer typed AppErrors (ErrVectorStoreBindingInvalid 2200 / ErrVectorStoreUnavailable 2201) are mapped from PR2 sentinel hierarchy and preserved end-to-end: the iterative FAQ path returns them rather than swallowing, and the HybridSearch handler routes typed AppErrors to the client unchanged instead of downgrading to 500. Part of Tencent#993 (Phase 2: Per-KB VectorStore Binding). Phase 2 roadmap item: PR 4 (Multi-store fan-out search). Depends on Tencent#994, Tencent#1310, Tencent#1372.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
Part of #993 — Phase 2 (Per-KB VectorStore Binding) of the multi-store roadmap.
Phase 2 PR sequence (this is PR 3 of 5):
vector_store_idcolumn + migrationDepends on #994 and #1310.
Changes at a glance
POST /knowledge-basesaccepts an optionalvector_store_idand validates it against the caller's tenant scope + the engine registry before persisting.ErrVectorStoreBindingInvalid— UUID is malformed, does not exist, or belongs to another tenant (single message; intentionally no enumeration oracle).ErrVectorStoreUnavailable— store exists in DB but is not registered in the engine registry; checkconnection_config.POST/GET /:id/PUT/PUT /:id/pin) gain four read-only fields describing the bound store:vector_store_name,vector_store_source,vector_store_engine_type,vector_store_status.ListKnowledgeBasesdeliberately does not include these to avoid N+1 queries.vector_store_idis stripped and metadata fields are set tosource="shared". The owner tenant's store inventory cannot be correlated or enumerated.POST /knowledge-bases/copyrejects mismatched clones synchronously, before enqueueing the async clone task:source.embedding_model_id != target.embedding_model_idsource.vector_store_id != target.vector_store_idDELETE /vector-stores/:idrefuses to remove a store that still has bound knowledge bases. The check runs inside a transaction that row-locks the store on PostgreSQL (SELECT … FOR UPDATE) and serializes via WAL on SQLite. A panic in the in-memory registry unregister is recovered into a structured warning rather than silently leaking a stale engine.vector_store_idis immutable post-creation. The GORM<-:createtag (from feat: add vector_store_id column to knowledge_bases for per-KB vector store binding #994) blocks every ORM update path; the service-layerUpdateKnowledgeBaseRequestDTO omits the field entirely; a reflection-based test catches any future regression in either layer.vector_store_idis normalized tonilat the create boundary and insideSharesStoreWith, so rows persisted by callers that did not runNormalize()first (raw-SQL writes, external migrations, ops scripts) still compare correctly.File-by-file summary
Production
internal/types/knowledgebase.goHasVectorStore(),Normalize(),SharesStoreWith()helpers (all nil-safe)internal/types/vectorstore.goStoreDisplayDTO +StoreSource*constants (env/user/shared/unavailable) + display-payload constructorsinternal/types/interfaces/knowledgebase.goCountByVectorStoreID(ctx, *gorm.DB, tenantID, storeID)interface — accepts a handle so tx and non-tx callers share one implementationinternal/types/interfaces/vectorstore.goResolveStoreView+BatchResolveStoreViewonVectorStoreServiceinternal/application/repository/knowledgebase.goCountByVectorStoreIDimplementation; uses GORM's auto soft-delete scope (no explicitdeleted_at IS NULLpredicate)internal/application/service/retriever/factory.goVerifyBinding(ctx, registry, ownership, tenantID, storeID)— exposes the existing ownership + registry sentinel hierarchy so the KB create path can reuse the same source of truthinternal/application/service/knowledgebase.govalidateVectorStoreBinding(UUID parse →VerifyBinding→ typed AppError translation); embedding-model + store defenses insideCopyKnowledgeBase; new-target clone now inheritsVectorStoreID;Updategodoc documents the immutability contractinternal/application/service/vectorstore.gokbRepo+*gorm.DB+ cachedenvStores;DeleteStoretransactional binding guard with PG row-lock;unregisterSafelypanic-recovery;ResolveStoreView+BatchResolveStoreViewimplementationsinternal/container/container.goDialector.Name()is"postgres"or"sqlite"— guards against silent SQLite fallback under a future driver swapinternal/errors/errors.gointernal/logger/logger.goWarnWithFields(ctx, fields, msg)helper +Fieldsaliasinternal/handler/knowledgebase.gobuildKBResponsemap-merge helper (avoidsKnowledgeBase.MarshalJSONswallowing the new fields);resolveKBStoreView(env / shared / DB / unavailable branches); response builder applied toCreate/Get/Update/TogglePin;Copypre-flight (same checks as the service-layer worker entry); typed-AppError unwrap onCreateso 2200 / 2201 reach the clientAPI docs
docs/api/knowledge-base.mdvector_store_idrequest field; new response fields + meanings table;POST /copysynchronous pre-flight section; new error code rows; explicit note thatGET /knowledge-bases(list) does NOT include resolved store metadatadocs/api/vector-store.mdDELETE /:idbinding-guard section with example 400 body; updated error-code table; env-store section addendumTests
internal/types/knowledgebase_test.goHasVectorStore,Normalize,SharesStoreWithmatrices including the empty-string-vs-nil normalizationinternal/application/repository/knowledgebase_sqlite_test.goTestCountByVectorStoreIDagainst in-memory sqlite — tenant scope, soft-delete auto-scope, empty-string row exclusion, shared-tx idempotencyinternal/application/service/retriever/factory_test.goTestVerifyBinding— ownership infra error, not owned, registry miss, success, cross-tenantinternal/application/service/knowledgebase_pr3_test.go(new)VectorStoreIDinternal/application/service/vectorstore_test.gointernal/handler/knowledgebase_request_test.go(new)UpdateKnowledgeBaseRequestnorKnowledgeBaseConfigexposesVectorStoreID(structural enforcement of the service-layer immutability tier)internal/handler/knowledgebase_copy_preflight_test.go(new)internal/handler/knowledgebase_pr3_response_test.go(new)vector_store_idwhile owner response retains itBackward compatibility
A pre-existing client that does not send
vector_store_idsees zero behavior change.vector_store_idkb.VectorStoreID = nil→Normalize()is a no-op →HasVectorStore()is false → validation skipped → DB row stores NULL → retrieve-engine factory falls back to the tenant's effective engines (env store), same as before this PRvector_store_id = ""niland treated identically to "not sent" (matches the factory's nil/empty pre-condition from #1310)vector_store_id; a client that round-trips a full KB object (GET → modify → PUT) is silently ignored on this field — round-trip pattern is not brokenPOST /knowledge-bases/copyDELETE /vector-stores/:idvector_store_id IS NULL(no UI yet binds a store), so the guard is effectively dormant until clients start opting inKBDeletePayload/IndexDeletePayloadin AsynqVectorStoreID *stringwas added withomitemptyin #1310; tasks enqueued before that upgrade decode it asniland transparently fall back to the pre-serialized effective engines — same hereBehavior change (intentional)
The current
CopyKnowledgeBasesilently allows cloning between KBs with different embedding models, producing vectors that are incomparable across the resulting KBs. This PR turns that case into a 400 with a clear message. Cross-store clones are 400'd for the same reason — cross-store data migration is a separate piece of work tracked in the parent issue.Known limitations
ErrVectorStoreForbidden / NotFound, and the response view surfacesvector_store_status: "unavailable"so the UI can guide recovery. Closing the window fully would requireSELECT … FOR SHAREon the store row during everyCreateKB, which is disproportionate cost for a corner case.UnregisterByStoreIDonly updates the in-memory registry of the deleting process. Sibling replicas continue to serve the engine until process restart. Documented; cluster-wide invalidation is a separate hardening item.ListKnowledgeBasesdoes not enrich store metadata — N+1 avoidance. The detail endpoint covers the upcoming UI; a batch resolver (BatchResolveStoreView) is shipped on the service interface for the follow-up UI PR to wire up.KBDeletePayload/IndexDeletePayloadare unsigned — pre-existing risk inherited from refactor(retriever): introduce factory for KB-scoped retrieve engine resolution #1310. The factory's ownership chain (payload.TenantID↔ store'stenant_id) prevents cross-tenant delete even with a tampered payload. Adding HMAC is a separate hardening PR.Test plan
go build ./...go vet ./...go test -race ./internal/application/service/retriever/...(sentinel matrix + parallel invocation)go test -race ./internal/types/...(nil-safe helpers + JSON shape regressions)go test ./internal/application/repository/...(CountByVectorStoreID+ GORM<-:createimmutability)go test ./internal/application/service/...(CreateKB validation matrix, CopyKB defenses, DeleteStore guard against sqlite, ResolveStoreView / BatchResolveStoreView)go test ./internal/handler/...(typed error code preservation, shared-KB UUID suppression, copy pre-flight, reflection-based immutability)vector_store_id