feat(knowledge): scope local_knowledge_search to a namespace - #9032
Conversation
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Namespace scoping is seed-only, so scoped searches still return other namespaces' items via the graph leg — the opposite of the dashboard's strict filter. Watch
Suggestions
[DESIGN-REVIEWED] 8ee357f |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of Verification done. I checked the retriever's call sites (3 in First-Principles-Verdict: CONCERNS Namespace scoping earns its place, but the dashboard's own search already namespace-filters — post-retrieval, defectively — and this PR's fix reaches neither dashboard call site. What this change shipsIntent: let an agent (and any retriever caller) restrict knowledge search to one namespace, per #8266. ADDITION.
Watch
[FIRST-PRINCIPLES-REVIEWED] 8ee357f |
|
Disposition of the First Principles CONCERNS (advisory -- no BLOCK-MERGE, so this does not gate): Accepted as a correct observation, declined for this PR as out of scope. The reviewer is right that
I have adjusted the mental model but not the code: the description line "Search was the one verb the label never reached" is about the hybrid-retrieval search path ( |
The knowledge store already models a namespace label on items (with an index), and the dashboard already lists, assigns and browse-filters by it. Search was the one verb the label never reached: HybridRetriever and local_knowledge_search scoped only by source_id. Add an optional namespace filter to the search seed legs (keyword + vector), mirroring the existing source_id scoping. namespace is a plain items.namespace column match -- a relevance/organisation filter, not a security boundary -- and composes with source_id. The graph leg stays unfiltered, as it is for source_id. Refs #8266
ddf1cbf to
8ee357f
Compare
Problem / Motivation
The knowledge store already models a
namespacelabel on every item --items.namespace TEXT DEFAULT 'default'with aCREATE INDEX idx_items_namespace, and the dashboard already lists namespaces (GET /api/knowledge/namespaces), assigns them (upload picker,PATCH /api/knowledge/items/{id}acceptsnamespace) and browse-filters the item list by them (?namespace=).Search was the one verb the label never reached.
HybridRetriever.search()and thelocal_knowledge_searchMCP tool scoped only bysource_id, so an agent (or a user driving the KB) could organise documents into namespaces and filter the browse view by them, but could not tell a search "only look in this namespace". A single flat retrieval surface is exactly what #8266 asks to fix for the per-client / per-project / per-topic separation use cases.Why it matters to the user
Someone who has bothered to file documents under namespaces wants retrieval to honour that split -- keeping a topic's reference docs from diluting an unrelated query's results, or scoping an agent's
local_knowledge_searchto the namespace it is working in. Today they can see the label everywhere except where it would actually change what a search returns.What changed
knowledge/retrieval.py:search(),_keyword_search()and_vector_search()take an optionalnamespace. It is applied to the same SEED legs assource_id(FTS5 keyword + vector similarity) as a parameterizeditems.namespace = ?match. The graph leg stays unfiltered, exactly as it is forsource_id, so cross-namespace entity connections still contribute traversal context.source_idandnamespacecompose when both are given.mcp_tools/knowledge.py:local_knowledge_searchgains an optionalnamespaceargument (schema + validation + passthrough), mirroringsource_id.validation.py: anamespaceFieldSpeconLOCAL_KNOWLEDGE_SEARCH_SCHEMA,max_len=64to match the cap the ingest handler already enforces.Design note, stated up front so it is not mistaken: knowledge namespaces are an ORGANISATIONAL LABEL, not a security boundary. There is no authorization anywhere in the tree keyed on the knowledge
namespacecolumn -- it is a plain SQL filter with a'default'fallback. This change is a relevance/organisation filter with the same trust properties as the existing?namespace=browse filter; it does not and must not be read as confidentiality enforcement. Chain from symptom to root cause: the symptom (search ignores namespaces) traces directly to the seed-leg WHERE clauses only knowingsource_id; the fix adds the parallel clause the label already earned everywhere else.Scope is deliberately the smallest coherent slice -- search scoping only. The issue's other verbs (a CLI create/delete surface, and binding a namespace to a workspace, its item 4) are separate, larger changes, so this is
Refs, notCloses.Tests
Only the touched test files, run at
-n0:test/test_knowledge.py: newTestHybridRetrieverNamespaceFilter-- keyword-leg narrowing, vector-leg narrowing, omitted-namespace regression (keeps all), unknown-namespace returns nothing, andnamespace+source_idcomposing.test/test_mcp_knowledge_search.py:namespaceoptional in the advertised schema, rejects non-string, rejects overlong (>64), and passes through to the retriever. Two existingsearch(...)call assertions were updated to carry the newnamespace=Nonekwarg.293 passed. Each new enforcement site was mutation-verified by hand and reddened a DISTINCT set on a real assertion: neutering the keyword-leg filter reddened the keyword + compose tests (not the vector test); neutering the vector-leg filter reddened only the vector test; neutering the MCP passthrough reddened only the passthrough test. flake8, isort, mypy (my files) and the diff-scoped black gate are clean; merge-tree against main is clean.
Pattern harvest
Mirrored the existing
source_idseed-leg scoping verbatim rather than inventing a new mechanism, including its "seed legs only, graph leg unfiltered" contract and its parameterized-bind discipline. The one intentional divergence from thesource_idshape:namespaceis a directitems.namespacecolumn with nosource_locationsownership-OR-location join, because a namespace lives on the item itself.Refs #8266