benroshan commited on
Commit
bc4ee5d
Β·
1 Parent(s): 62fa672

docs: add metadata filtering design spec (Stage 17)

Browse files
docs/superpowers/specs/2026-06-27-metadata-filtering-design.md ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Metadata Filtering β€” Design Spec
2
+ **Date:** 2026-06-27
3
+ **Status:** Approved
4
+ **Scope:** Prism β€” Stage 17
5
+
6
+ ---
7
+
8
+ ## Problem
9
+
10
+ Within a workspace, all uploaded documents are searched together. A user with 10 docs spanning 5 years cannot scope a query to a specific doc or subset. All retrieval is corpus-wide.
11
+
12
+ ## Goal
13
+
14
+ Let users select specific documents in the sidebar to scope retrieval. Clicking a doc chip restricts dense + sparse retrieval to only that doc's chunks. Zero selection = current behavior (all docs searched).
15
+
16
+ ---
17
+
18
+ ## Decisions
19
+
20
+ | Decision | Choice | Rationale |
21
+ |----------|--------|-----------|
22
+ | Metadata fields | `source_type` added at ingest; filter on existing `source` field | `source` already stored per chunk; `source_type` useful for citation display |
23
+ | Filter fields exposed | `doc_name` (= `source`) only | Simplest; highest value per user action |
24
+ | Filter UX | Sidebar doc chips (toggle) | Already shows doc list; natural extension |
25
+ | Retriever strategy | New lightweight instance per filtered request, reusing cached vectorstore | Thread-safe; vectorstore (heavy) stays cached; retriever (cheap) created fresh |
26
+
27
+ ---
28
+
29
+ ## Architecture
30
+
31
+ ### Ingest changes (`ingest.py`)
32
+
33
+ Add `source_type` to all chunk metadata at load time:
34
+
35
+ ```python
36
+ SOURCE_TYPE_MAP = {".pdf": "pdf", ".txt": "txt", ".csv": "csv"}
37
+ doc.metadata["source_type"] = SOURCE_TYPE_MAP.get(file_path.suffix.lower(), "file")
38
+ ```
39
+
40
+ For URL ingestion (`url_loader.py`): set `source_type = "url"` on loaded docs.
41
+
42
+ `doc_name` = `source` (filename) β€” already stored. No new field.
43
+
44
+ ### BM25 changes (`bm25_index.py`)
45
+
46
+ Add `filter_sources: set[str] | None = None` to `BM25Index.search()`:
47
+
48
+ ```python
49
+ def search(self, query: str, k: int = 20, filter_sources: set[str] | None = None) -> list[dict]:
50
+ corpus = self._corpus
51
+ if filter_sources:
52
+ corpus = [d for d in corpus if d["source"] in filter_sources]
53
+ if not corpus:
54
+ return []
55
+ tokenized = [doc["content"].lower().split() for doc in corpus]
56
+ bm25 = BM25Okapi(tokenized)
57
+ scores = bm25.get_scores(query.lower().split())
58
+ ...
59
+ ```
60
+
61
+ Note: when filter reduces corpus, BM25 must be re-scored against the filtered subset (not the full index), so scores stay relative. Build a temporary `BM25Okapi` over the filtered corpus.
62
+
63
+ ### Retriever changes (`retriever.py`)
64
+
65
+ 1. Add field to `HybridRetriever`:
66
+ ```python
67
+ filter_docs: list[str] | None = None
68
+ ```
69
+
70
+ 2. Wire into `_dense_retrieve`:
71
+ ```python
72
+ filter_arg = {"source": {"$in": self.filter_docs}} if self.filter_docs else None
73
+ results = self.vectorstore.similarity_search_with_relevance_scores(query, k=k, filter=filter_arg)
74
+ ```
75
+
76
+ 3. Wire into `_get_relevant_documents` (BM25 call):
77
+ ```python
78
+ filter_sources = set(self.filter_docs) if self.filter_docs else None
79
+ s_results = get_index(self.workspace_id).search(q, k=self.retrieve_k, filter_sources=filter_sources)
80
+ ```
81
+
82
+ 4. New helper function (does NOT replace or invalidate the singleton cache):
83
+ ```python
84
+ def get_retriever_filtered(workspace_id: str, filter_docs: list[str]) -> HybridRetriever:
85
+ """One-off retriever with doc filter. Reuses cached vectorstore."""
86
+ config = load_config()
87
+ retrieval_cfg = config.get("retrieval", {})
88
+ return HybridRetriever(
89
+ vectorstore=get_vectorstore(workspace_id),
90
+ dense_weight=retrieval_cfg.get("dense_weight", 0.7),
91
+ sparse_weight=retrieval_cfg.get("sparse_weight", 0.3),
92
+ retrieve_k=retrieval_cfg.get("retrieve_k", 10),
93
+ rerank_k=retrieval_cfg.get("rerank_k", 5),
94
+ workspace_id=workspace_id,
95
+ use_hyde=retrieval_cfg.get("hyde_enabled", False),
96
+ use_multi_query=retrieval_cfg.get("multi_query_enabled", False),
97
+ filter_docs=filter_docs,
98
+ )
99
+ ```
100
+
101
+ ### Chat route changes (`routes/chat.py`)
102
+
103
+ ```python
104
+ class ChatRequest(BaseModel):
105
+ question: str
106
+ filter_docs: list[str] | None = None
107
+
108
+ # in handler, before stream:
109
+ retriever = (
110
+ get_retriever_filtered(workspace, body.filter_docs)
111
+ if body.filter_docs
112
+ else get_retriever(workspace)
113
+ )
114
+ ```
115
+
116
+ Log filter state: `logger.info("QUERY | workspace=%s | filter=%s | %s", workspace, body.filter_docs, body.question[:80])`
117
+
118
+ ### Frontend changes
119
+
120
+ **`Sidebar.jsx`** β€” doc list items become toggleable chips:
121
+ - Selected: indigo ring + solid background
122
+ - Unselected (when others selected): dimmed opacity
123
+ - No selection: all neutral (current look)
124
+ - "Clear" Γ— button appears when β‰₯1 selected
125
+
126
+ **`App.jsx`** β€” `filterDocs: string[]` state, reset on workspace switch:
127
+ ```jsx
128
+ const [filterDocs, setFilterDocs] = useState([])
129
+ // reset on workspace change
130
+ useEffect(() => setFilterDocs([]), [activeWorkspace])
131
+ ```
132
+
133
+ **`ChatArea.jsx`** β€” filter indicator above input + pass `filterDocs` to API call:
134
+ ```jsx
135
+ // badge when filter active
136
+ {filterDocs.length > 0 && (
137
+ <div>Scoped to: {filterDocs.join(", ")} <button onClick={() => setFilterDocs([])}>Γ—</button></div>
138
+ )}
139
+ ```
140
+
141
+ **`api.js`** β€” include `filter_docs` in chat request body:
142
+ ```js
143
+ filter_docs: filterDocs.length ? filterDocs : null
144
+ ```
145
+
146
+ ---
147
+
148
+ ## Data flow
149
+
150
+ ```
151
+ User clicks doc chip in sidebar
152
+ β†’ filterDocs state updated in App.jsx
153
+ β†’ passed to ChatArea as prop
154
+ β†’ badge shown above input
155
+ User sends message
156
+ β†’ api.js sends { question, filter_docs: ["rbi_2024.pdf"] }
157
+ β†’ routes/chat.py: filter_docs present β†’ get_retriever_filtered(workspace, filter_docs)
158
+ β†’ HybridRetriever._dense_retrieve: ChromaDB where={"source": {"$in": ["rbi_2024.pdf"]}}
159
+ β†’ BM25Index.search: corpus pre-filtered to rbi_2024.pdf chunks β†’ re-scored
160
+ β†’ RRF fusion β†’ rerank β†’ stream answer
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Error handling
166
+
167
+ | Case | Behavior |
168
+ |------|----------|
169
+ | Filter doc not in workspace | ChromaDB returns 0 results β†’ stream returns "no relevant documents" (existing handler) |
170
+ | All selected docs deleted mid-session | Same as above |
171
+ | BM25 not built (cold start) | `search()` returns `[]` β€” unchanged |
172
+ | Workspace switch | `filterDocs` reset to `[]` via `useEffect` on `activeWorkspace` change |
173
+ | `filter_docs: []` sent | Backend treats as `None` β€” guard: `body.filter_docs if body.filter_docs else None` |
174
+ | Single doc with 2 chunks, retrieve_k=10 | ChromaDB returns ≀2, reranker handles gracefully |
175
+
176
+ ---
177
+
178
+ ## Out of scope
179
+
180
+ - Year filtering (auto-extracted at ingest but not exposed as filter)
181
+ - source_type filtering
182
+ - Saved filter presets
183
+ - Filter persistence across sessions
184
+
185
+ ---
186
+
187
+ ## Files changed
188
+
189
+ | File | Change |
190
+ |------|--------|
191
+ | `server/ingest.py` | Add `source_type` to chunk metadata in both loaders |
192
+ | `server/url_loader.py` | Add `source_type = "url"` to loaded doc metadata |
193
+ | `server/bm25_index.py` | `search()` gets `filter_sources` param; re-score against filtered corpus |
194
+ | `server/retriever.py` | `filter_docs` field on `HybridRetriever`; wire into dense + sparse; `get_retriever_filtered()` |
195
+ | `server/routes/chat.py` | `filter_docs` on `ChatRequest`; conditional retriever selection |
196
+ | `frontend/src/App.jsx` | `filterDocs` state + reset on workspace switch |
197
+ | `frontend/src/components/Sidebar.jsx` | Doc chips toggleable; emit `onFilterChange` |
198
+ | `frontend/src/components/ChatArea.jsx` | Filter badge above input; pass `filterDocs` to API |
199
+ | `frontend/src/api.js` | Include `filter_docs` in chat request body |