shreyask Claude Opus 4.6 commited on
Commit
6c0127c
·
verified ·
1 Parent(s): 0ecf8a9

feat: manual generation for no-KV-cache model, fix blend scoring, add coffee doc

Browse files

- Switch expansion from TextGenerationPipeline to manual autoregressive
generation (model was exported without KV cache tensors)
- Add proper nucleus sampling (temp=0.7, top-k=20, top-p=0.8) matching
Qwen3 recommended settings — greedy decoding caused degeneration
- Fix blend scoring: replace position-aware weights with uniform 80/20
RRF/reranker to prevent irrelevant docs leapfrogging from reranker noise
- Add history-of-coffee.md as a fun non-tech sample document
- UI component updates and refinements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

index.html CHANGED
@@ -5,6 +5,82 @@
5
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
  <title>QMD Web Demo — In-Browser Hybrid Search</title>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  </head>
9
  <body>
10
  <div id="root"></div>
 
5
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
  <title>QMD Web Demo — In-Browser Hybrid Search</title>
8
+ <style>
9
+ :root {
10
+ --bg: #ffffff;
11
+ --bg-page: #ffffff;
12
+ --bg-section: #f8f8f8;
13
+ --bg-card: #ffffff;
14
+ --bg-input: #ffffff;
15
+ --text: #1a1a1a;
16
+ --text-secondary: #666666;
17
+ --text-muted: #999999;
18
+ --border: #e0e0e0;
19
+ --border-light: #f0f0f0;
20
+ --shadow: rgba(0,0,0,0.07);
21
+ --col-query: #E8F0FE;
22
+ --col-expansion: #FFF8E1;
23
+ --col-search: #E0F2F1;
24
+ --col-fusion: #E8F5E9;
25
+ --input-border: #cccccc;
26
+ --indexing-bg: #FFF3E0;
27
+ --error-bg: #fce4ec;
28
+ --error-border: #ef9a9a;
29
+ --rerank-after-bg: #f1f8e9;
30
+ --rerank-after-border: #c8e6c9;
31
+ --example-bg: #f0f4ff;
32
+ --example-border: #c5d5ff;
33
+ --modal-bg: rgba(0,0,0,0.4);
34
+ --score-good-bg: #e8f5e9;
35
+ --score-mid-bg: #fff8e1;
36
+ --score-bad-bg: #eceff1;
37
+ }
38
+ [data-theme="dark"] {
39
+ --bg: #1a1a2e;
40
+ --bg-page: #1a1a2e;
41
+ --bg-section: #16213e;
42
+ --bg-card: #0f3460;
43
+ --bg-input: #16213e;
44
+ --text: #e0e0e0;
45
+ --text-secondary: #a0a0b0;
46
+ --text-muted: #707080;
47
+ --border: #2a2a4a;
48
+ --border-light: #252545;
49
+ --shadow: rgba(0,0,0,0.3);
50
+ --col-query: #1a2744;
51
+ --col-expansion: #2a2518;
52
+ --col-search: #1a2e2b;
53
+ --col-fusion: #1a2e1e;
54
+ --input-border: #3a3a5a;
55
+ --indexing-bg: #2a2518;
56
+ --error-bg: #2e1520;
57
+ --error-border: #5a2030;
58
+ --rerank-after-bg: #1a2e1e;
59
+ --rerank-after-border: #2a4a2e;
60
+ --example-bg: #1a2040;
61
+ --example-border: #2a3060;
62
+ --modal-bg: rgba(0,0,0,0.7);
63
+ --score-good-bg: #1a2e1e;
64
+ --score-mid-bg: #2a2518;
65
+ --score-bad-bg: #1e2030;
66
+ }
67
+ body {
68
+ margin: 0;
69
+ background: var(--bg-page);
70
+ color: var(--text);
71
+ transition: background 0.3s, color 0.3s;
72
+ }
73
+ *, *::before, *::after { box-sizing: border-box; }
74
+ </style>
75
+ <script>
76
+ // Restore theme preference
77
+ (function() {
78
+ var t = localStorage.getItem('qmd-theme');
79
+ if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
80
+ document.documentElement.setAttribute('data-theme', 'dark');
81
+ }
82
+ })();
83
+ </script>
84
  </head>
85
  <body>
86
  <div id="root"></div>
public/eval-docs/history-of-coffee.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The History of Coffee
2
+
3
+ ## Origins in Ethiopia
4
+
5
+ Coffee's story begins in the highlands of Ethiopia around the 9th century. Legend tells of a goat herder named Kaldi who noticed his goats became unusually energetic after eating berries from a certain tree. Curious, he tried the berries himself and experienced a similar burst of alertness.
6
+
7
+ ## Spread Through the Arab World
8
+
9
+ By the 15th century, coffee was being cultivated in Yemen. Sufi monks brewed it to stay awake during long nights of prayer. Coffeehouses, called *qahveh khaneh*, sprang up across the Middle East and became vibrant centers of conversation, music, and chess.
10
+
11
+ ## Arrival in Europe
12
+
13
+ Coffee reached Europe in the 17th century and was initially met with suspicion — some called it the "bitter invention of Satan." Pope Clement VIII reportedly tried it, enjoyed it, and gave it papal approval. Coffeehouses soon became hubs of intellectual life in London, Paris, and Vienna.
14
+
15
+ ## The Modern Coffee Industry
16
+
17
+ Today coffee is the world's second most traded commodity after oil. Over 2.25 billion cups are consumed daily across the globe. Brazil remains the largest producer, followed by Vietnam, Colombia, and Ethiopia — where the story began over a thousand years ago.
18
+
19
+ ## How Coffee Is Made
20
+
21
+ The journey from plant to cup involves harvesting ripe cherries, extracting the beans through wet or dry processing, roasting at temperatures between 180-230°C to develop flavor compounds, and finally grinding and brewing. Each step profoundly affects the taste in the cup.
src/App.tsx CHANGED
@@ -16,6 +16,7 @@ const SAMPLE_DOCS = [
16
  'api-design-principles.md',
17
  'distributed-systems-overview.md',
18
  'machine-learning-primer.md',
 
19
  ];
20
 
21
  const INITIAL_PIPELINE: PipelineState = {
@@ -131,19 +132,76 @@ function App() {
131
 
132
  const allReady = isAllModelsReady() && embeddedChunks.length > 0 && !indexing;
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  return (
135
  <div style={{ fontFamily: 'system-ui, -apple-system, sans-serif', maxWidth: 1400, margin: '0 auto', padding: '1rem' }}>
136
  <header style={{ marginBottom: '1.5rem' }}>
137
- <h1 style={{ margin: 0, fontSize: '1.5rem' }}>QMD Web Demo</h1>
138
- <p style={{ margin: '0.25rem 0 0', color: '#666', fontSize: '0.9rem' }}>
139
- In-Browser Hybrid Search Pipeline WebGPU + Transformers.js
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  </p>
141
  </header>
142
 
143
  <ModelStatus models={models} />
144
 
145
  {indexing && (
146
- <div style={{ padding: '0.5rem 1rem', background: '#FFF3E0', borderRadius: 6, marginBottom: '1rem', fontSize: '0.85rem' }}>
147
  Indexing documents (embedding chunks)...
148
  </div>
149
  )}
 
16
  'api-design-principles.md',
17
  'distributed-systems-overview.md',
18
  'machine-learning-primer.md',
19
+ 'history-of-coffee.md',
20
  ];
21
 
22
  const INITIAL_PIPELINE: PipelineState = {
 
132
 
133
  const allReady = isAllModelsReady() && embeddedChunks.length > 0 && !indexing;
134
 
135
+ const [dark, setDark] = useState(() =>
136
+ document.documentElement.getAttribute('data-theme') === 'dark'
137
+ );
138
+
139
+ const toggleTheme = useCallback(() => {
140
+ setDark(prev => {
141
+ const next = !prev;
142
+ document.documentElement.setAttribute('data-theme', next ? 'dark' : 'light');
143
+ localStorage.setItem('qmd-theme', next ? 'dark' : 'light');
144
+ return next;
145
+ });
146
+ }, []);
147
+
148
  return (
149
  <div style={{ fontFamily: 'system-ui, -apple-system, sans-serif', maxWidth: 1400, margin: '0 auto', padding: '1rem' }}>
150
  <header style={{ marginBottom: '1.5rem' }}>
151
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
152
+ <h1 style={{ margin: 0, fontSize: '1.5rem', color: 'var(--text)' }}>QMD Web Demo</h1>
153
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
154
+ <a
155
+ href="https://github.com/tobi/qmd"
156
+ target="_blank"
157
+ rel="noopener noreferrer"
158
+ style={{
159
+ fontSize: '0.78rem',
160
+ color: 'var(--text-secondary)',
161
+ textDecoration: 'none',
162
+ padding: '0.3rem 0.6rem',
163
+ border: '1px solid var(--border)',
164
+ borderRadius: '5px',
165
+ fontFamily: 'system-ui, -apple-system, sans-serif',
166
+ }}
167
+ onMouseEnter={e => { (e.currentTarget as HTMLAnchorElement).style.color = '#4285F4'; }}
168
+ onMouseLeave={e => { (e.currentTarget as HTMLAnchorElement).style.color = 'var(--text-secondary)'; }}
169
+ >
170
+ GitHub
171
+ </a>
172
+ <button
173
+ onClick={toggleTheme}
174
+ title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
175
+ style={{
176
+ background: 'none',
177
+ border: '1px solid var(--border)',
178
+ borderRadius: '5px',
179
+ padding: '0.3rem 0.55rem',
180
+ cursor: 'pointer',
181
+ fontSize: '1rem',
182
+ lineHeight: 1,
183
+ color: 'var(--text)',
184
+ }}
185
+ >
186
+ {dark ? '\u2600' : '\u263E'}
187
+ </button>
188
+ </div>
189
+ </div>
190
+ <p style={{ margin: '0.25rem 0 0', color: 'var(--text-secondary)', fontSize: '0.85rem', lineHeight: 1.5 }}>
191
+ In-browser hybrid search pipeline running entirely on WebGPU.
192
+ Three ONNX models (embedding, reranker, query expansion) power a full
193
+ search stack: query expansion, BM25 + vector search, RRF fusion, and cross-encoder reranking.
194
+ Built with{' '}
195
+ <a href="https://github.com/tobi/qmd" target="_blank" rel="noopener noreferrer" style={{ color: '#4285F4', textDecoration: 'none' }}>QMD</a>
196
+ {' '}and{' '}
197
+ <a href="https://huggingface.co/docs/transformers.js" target="_blank" rel="noopener noreferrer" style={{ color: '#4285F4', textDecoration: 'none' }}>Transformers.js</a>.
198
  </p>
199
  </header>
200
 
201
  <ModelStatus models={models} />
202
 
203
  {indexing && (
204
+ <div style={{ padding: '0.5rem 1rem', background: 'var(--indexing-bg)', borderRadius: 6, marginBottom: '1rem', fontSize: '0.85rem', color: 'var(--text)' }}>
205
  Indexing documents (embedding chunks)...
206
  </div>
207
  )}
src/components/DocumentManager.tsx CHANGED
@@ -21,7 +21,7 @@ function PasteModal({ onClose, onConfirm }: { onClose: () => void; onConfirm: (t
21
  <div style={{
22
  position: 'fixed',
23
  inset: 0,
24
- background: 'rgba(0,0,0,0.4)',
25
  display: 'flex',
26
  alignItems: 'center',
27
  justifyContent: 'center',
@@ -30,20 +30,21 @@ function PasteModal({ onClose, onConfirm }: { onClose: () => void; onConfirm: (t
30
  onClick={e => { if (e.target === e.currentTarget) onClose(); }}
31
  >
32
  <div style={{
33
- background: '#fff',
34
  borderRadius: '10px',
35
  padding: '1.5rem',
36
  width: '90%',
37
  maxWidth: '560px',
38
- boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
39
  fontFamily: 'system-ui, -apple-system, sans-serif',
 
40
  }}>
41
- <h3 style={{ margin: '0 0 1rem 0', fontSize: '1rem', color: '#1a1a1a' }}>
42
  Paste Document
43
  </h3>
44
 
45
  <div style={{ marginBottom: '0.75rem' }}>
46
- <label style={{ fontSize: '0.8rem', color: '#555', display: 'block', marginBottom: '0.3rem' }}>
47
  Filename
48
  </label>
49
  <input
@@ -55,32 +56,36 @@ function PasteModal({ onClose, onConfirm }: { onClose: () => void; onConfirm: (t
55
  padding: '0.45rem 0.65rem',
56
  fontSize: '0.85rem',
57
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
58
- border: '1px solid #ccc',
59
  borderRadius: '5px',
60
  boxSizing: 'border-box',
 
 
61
  }}
62
  />
63
  </div>
64
 
65
  <div style={{ marginBottom: '1rem' }}>
66
- <label style={{ fontSize: '0.8rem', color: '#555', display: 'block', marginBottom: '0.3rem' }}>
67
  Content (Markdown or plain text)
68
  </label>
69
  <textarea
70
  value={text}
71
  onChange={e => setText(e.target.value)}
72
  rows={12}
73
- placeholder="Paste your document content here"
74
  style={{
75
  width: '100%',
76
  padding: '0.5rem 0.65rem',
77
  fontSize: '0.8rem',
78
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
79
- border: '1px solid #ccc',
80
  borderRadius: '5px',
81
  resize: 'vertical',
82
  boxSizing: 'border-box',
83
  lineHeight: 1.5,
 
 
84
  }}
85
  />
86
  </div>
@@ -92,9 +97,9 @@ function PasteModal({ onClose, onConfirm }: { onClose: () => void; onConfirm: (t
92
  padding: '0.5rem 1rem',
93
  fontSize: '0.85rem',
94
  fontFamily: 'system-ui, -apple-system, sans-serif',
95
- background: '#f5f5f5',
96
- color: '#555',
97
- border: '1px solid #ddd',
98
  borderRadius: '5px',
99
  cursor: 'pointer',
100
  }}
@@ -108,7 +113,7 @@ function PasteModal({ onClose, onConfirm }: { onClose: () => void; onConfirm: (t
108
  padding: '0.5rem 1rem',
109
  fontSize: '0.85rem',
110
  fontFamily: 'system-ui, -apple-system, sans-serif',
111
- background: text.trim() ? '#4285F4' : '#ccc',
112
  color: '#fff',
113
  border: 'none',
114
  borderRadius: '5px',
@@ -133,15 +138,14 @@ export default function DocumentManager({ documents, onUpload, onPaste }: Docume
133
  if (files && files.length > 0) {
134
  onUpload(files);
135
  }
136
- // Reset so the same file can be re-uploaded
137
  e.target.value = '';
138
  }
139
 
140
  return (
141
  <div style={{
142
  padding: '1rem',
143
- background: '#f8f8f8',
144
- border: '1px solid #e0e0e0',
145
  borderRadius: '8px',
146
  marginBottom: '1.5rem',
147
  fontFamily: 'system-ui, -apple-system, sans-serif',
@@ -156,7 +160,7 @@ export default function DocumentManager({ documents, onUpload, onPaste }: Docume
156
  margin: 0,
157
  fontSize: '0.85rem',
158
  fontWeight: 600,
159
- color: '#444',
160
  textTransform: 'uppercase',
161
  letterSpacing: '0.05em',
162
  }}>
@@ -165,7 +169,7 @@ export default function DocumentManager({ documents, onUpload, onPaste }: Docume
165
  marginLeft: '0.5rem',
166
  fontSize: '0.75rem',
167
  fontWeight: 400,
168
- color: '#888',
169
  }}>
170
  ({documents.length})
171
  </span>
@@ -176,7 +180,7 @@ export default function DocumentManager({ documents, onUpload, onPaste }: Docume
176
  style={{
177
  padding: '0.3rem 0.7rem',
178
  fontSize: '0.78rem',
179
- background: '#fff',
180
  color: '#4285F4',
181
  border: '1px solid #4285F4',
182
  borderRadius: '5px',
@@ -192,7 +196,7 @@ export default function DocumentManager({ documents, onUpload, onPaste }: Docume
192
  style={{
193
  padding: '0.3rem 0.7rem',
194
  fontSize: '0.78rem',
195
- background: '#fff',
196
  color: '#34a853',
197
  border: '1px solid #34a853',
198
  borderRadius: '5px',
@@ -216,7 +220,7 @@ export default function DocumentManager({ documents, onUpload, onPaste }: Docume
216
  />
217
 
218
  {documents.length === 0 ? (
219
- <p style={{ fontSize: '0.82rem', color: '#999', margin: 0 }}>
220
  No documents loaded. Upload .md or .txt files, or paste text.
221
  </p>
222
  ) : (
@@ -226,24 +230,24 @@ export default function DocumentManager({ documents, onUpload, onPaste }: Docume
226
  display: 'flex',
227
  alignItems: 'center',
228
  padding: '0.35rem 0.6rem',
229
- background: '#fff',
230
- border: '1px solid #e0e0e0',
231
  borderRadius: '5px',
232
  marginBottom: '0.3rem',
233
  gap: '0.5rem',
234
  }}>
235
  <span style={{
236
  fontSize: '0.75rem',
237
- color: '#ccc',
238
  flexShrink: 0,
239
  }}>
240
-
241
  </span>
242
  <span style={{
243
  flex: 1,
244
  fontSize: '0.8rem',
245
  fontWeight: 500,
246
- color: '#333',
247
  overflow: 'hidden',
248
  textOverflow: 'ellipsis',
249
  whiteSpace: 'nowrap',
@@ -253,7 +257,7 @@ export default function DocumentManager({ documents, onUpload, onPaste }: Docume
253
  <span style={{
254
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
255
  fontSize: '0.68rem',
256
- color: '#aaa',
257
  flexShrink: 0,
258
  }}>
259
  {doc.filepath}
 
21
  <div style={{
22
  position: 'fixed',
23
  inset: 0,
24
+ background: 'var(--modal-bg)',
25
  display: 'flex',
26
  alignItems: 'center',
27
  justifyContent: 'center',
 
30
  onClick={e => { if (e.target === e.currentTarget) onClose(); }}
31
  >
32
  <div style={{
33
+ background: 'var(--bg-card)',
34
  borderRadius: '10px',
35
  padding: '1.5rem',
36
  width: '90%',
37
  maxWidth: '560px',
38
+ boxShadow: '0 8px 32px var(--shadow)',
39
  fontFamily: 'system-ui, -apple-system, sans-serif',
40
+ border: '1px solid var(--border)',
41
  }}>
42
+ <h3 style={{ margin: '0 0 1rem 0', fontSize: '1rem', color: 'var(--text)' }}>
43
  Paste Document
44
  </h3>
45
 
46
  <div style={{ marginBottom: '0.75rem' }}>
47
+ <label style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', display: 'block', marginBottom: '0.3rem' }}>
48
  Filename
49
  </label>
50
  <input
 
56
  padding: '0.45rem 0.65rem',
57
  fontSize: '0.85rem',
58
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
59
+ border: '1px solid var(--input-border)',
60
  borderRadius: '5px',
61
  boxSizing: 'border-box',
62
+ background: 'var(--bg-input)',
63
+ color: 'var(--text)',
64
  }}
65
  />
66
  </div>
67
 
68
  <div style={{ marginBottom: '1rem' }}>
69
+ <label style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', display: 'block', marginBottom: '0.3rem' }}>
70
  Content (Markdown or plain text)
71
  </label>
72
  <textarea
73
  value={text}
74
  onChange={e => setText(e.target.value)}
75
  rows={12}
76
+ placeholder="Paste your document content here\u2026"
77
  style={{
78
  width: '100%',
79
  padding: '0.5rem 0.65rem',
80
  fontSize: '0.8rem',
81
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
82
+ border: '1px solid var(--input-border)',
83
  borderRadius: '5px',
84
  resize: 'vertical',
85
  boxSizing: 'border-box',
86
  lineHeight: 1.5,
87
+ background: 'var(--bg-input)',
88
+ color: 'var(--text)',
89
  }}
90
  />
91
  </div>
 
97
  padding: '0.5rem 1rem',
98
  fontSize: '0.85rem',
99
  fontFamily: 'system-ui, -apple-system, sans-serif',
100
+ background: 'var(--bg-section)',
101
+ color: 'var(--text-secondary)',
102
+ border: '1px solid var(--border)',
103
  borderRadius: '5px',
104
  cursor: 'pointer',
105
  }}
 
113
  padding: '0.5rem 1rem',
114
  fontSize: '0.85rem',
115
  fontFamily: 'system-ui, -apple-system, sans-serif',
116
+ background: text.trim() ? '#4285F4' : 'var(--border)',
117
  color: '#fff',
118
  border: 'none',
119
  borderRadius: '5px',
 
138
  if (files && files.length > 0) {
139
  onUpload(files);
140
  }
 
141
  e.target.value = '';
142
  }
143
 
144
  return (
145
  <div style={{
146
  padding: '1rem',
147
+ background: 'var(--bg-section)',
148
+ border: '1px solid var(--border)',
149
  borderRadius: '8px',
150
  marginBottom: '1.5rem',
151
  fontFamily: 'system-ui, -apple-system, sans-serif',
 
160
  margin: 0,
161
  fontSize: '0.85rem',
162
  fontWeight: 600,
163
+ color: 'var(--text-secondary)',
164
  textTransform: 'uppercase',
165
  letterSpacing: '0.05em',
166
  }}>
 
169
  marginLeft: '0.5rem',
170
  fontSize: '0.75rem',
171
  fontWeight: 400,
172
+ color: 'var(--text-muted)',
173
  }}>
174
  ({documents.length})
175
  </span>
 
180
  style={{
181
  padding: '0.3rem 0.7rem',
182
  fontSize: '0.78rem',
183
+ background: 'var(--bg-card)',
184
  color: '#4285F4',
185
  border: '1px solid #4285F4',
186
  borderRadius: '5px',
 
196
  style={{
197
  padding: '0.3rem 0.7rem',
198
  fontSize: '0.78rem',
199
+ background: 'var(--bg-card)',
200
  color: '#34a853',
201
  border: '1px solid #34a853',
202
  borderRadius: '5px',
 
220
  />
221
 
222
  {documents.length === 0 ? (
223
+ <p style={{ fontSize: '0.82rem', color: 'var(--text-muted)', margin: 0 }}>
224
  No documents loaded. Upload .md or .txt files, or paste text.
225
  </p>
226
  ) : (
 
230
  display: 'flex',
231
  alignItems: 'center',
232
  padding: '0.35rem 0.6rem',
233
+ background: 'var(--bg-card)',
234
+ border: '1px solid var(--border)',
235
  borderRadius: '5px',
236
  marginBottom: '0.3rem',
237
  gap: '0.5rem',
238
  }}>
239
  <span style={{
240
  fontSize: '0.75rem',
241
+ color: 'var(--text-muted)',
242
  flexShrink: 0,
243
  }}>
244
+ {'\u25AA'}
245
  </span>
246
  <span style={{
247
  flex: 1,
248
  fontSize: '0.8rem',
249
  fontWeight: 500,
250
+ color: 'var(--text)',
251
  overflow: 'hidden',
252
  textOverflow: 'ellipsis',
253
  whiteSpace: 'nowrap',
 
257
  <span style={{
258
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
259
  fontSize: '0.68rem',
260
+ color: 'var(--text-muted)',
261
  flexShrink: 0,
262
  }}>
263
  {doc.filepath}
src/components/ExpansionColumn.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import type { ExpandedQuery } from '../types';
 
2
 
3
  interface ExpansionColumnState {
4
  status: 'idle' | 'running' | 'done' | 'error';
@@ -8,6 +9,7 @@ interface ExpansionColumnState {
8
 
9
  interface ExpansionColumnProps {
10
  state: ExpansionColumnState;
 
11
  }
12
 
13
  function Spinner() {
@@ -16,7 +18,7 @@ function Spinner() {
16
  display: 'inline-block',
17
  width: '16px',
18
  height: '16px',
19
- border: '2px solid #ddd',
20
  borderTopColor: '#f9a825',
21
  borderRadius: '50%',
22
  animation: 'spin 0.7s linear infinite',
@@ -28,8 +30,8 @@ function ExpansionCard({ label, content }: { label: string; content: string | st
28
  const text = Array.isArray(content) ? content.join('\n') : content;
29
  return (
30
  <div style={{
31
- background: '#fff',
32
- border: '1px solid #e0e0e0',
33
  borderRadius: '6px',
34
  padding: '0.65rem 0.85rem',
35
  marginBottom: '0.5rem',
@@ -48,7 +50,7 @@ function ExpansionCard({ label, content }: { label: string; content: string | st
48
  <div style={{
49
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
50
  fontSize: '0.72rem',
51
- color: '#333',
52
  lineHeight: 1.6,
53
  whiteSpace: 'pre-wrap',
54
  wordBreak: 'break-word',
@@ -59,7 +61,7 @@ function ExpansionCard({ label, content }: { label: string; content: string | st
59
  );
60
  }
61
 
62
- export default function ExpansionColumn({ state }: ExpansionColumnProps) {
63
  const isIdle = state.status === 'idle';
64
  const isRunning = state.status === 'running';
65
  const isDone = state.status === 'done';
@@ -70,7 +72,7 @@ export default function ExpansionColumn({ state }: ExpansionColumnProps) {
70
  <div style={{
71
  display: 'flex',
72
  alignItems: 'center',
73
- gap: '0.5rem',
74
  marginBottom: '0.75rem',
75
  }}>
76
  <h3 style={{
@@ -78,12 +80,13 @@ export default function ExpansionColumn({ state }: ExpansionColumnProps) {
78
  fontSize: '0.8rem',
79
  fontFamily: 'system-ui, -apple-system, sans-serif',
80
  fontWeight: 700,
81
- color: '#5d4037',
82
  textTransform: 'uppercase',
83
  letterSpacing: '0.05em',
84
  }}>
85
  Query Expansion
86
  </h3>
 
87
  {isRunning && <Spinner />}
88
  </div>
89
 
@@ -91,10 +94,10 @@ export default function ExpansionColumn({ state }: ExpansionColumnProps) {
91
  <p style={{
92
  fontFamily: 'system-ui, -apple-system, sans-serif',
93
  fontSize: '0.8rem',
94
- color: '#999',
95
  margin: 0,
96
  }}>
97
- Awaiting query
98
  </p>
99
  )}
100
 
@@ -102,19 +105,19 @@ export default function ExpansionColumn({ state }: ExpansionColumnProps) {
102
  <p style={{
103
  fontFamily: 'system-ui, -apple-system, sans-serif',
104
  fontSize: '0.8rem',
105
- color: '#888',
106
  margin: 0,
107
  fontStyle: 'italic',
108
  }}>
109
- Generating expanded queries
110
  </p>
111
  )}
112
 
113
  {isError && (
114
  <div style={{
115
  padding: '0.65rem',
116
- background: '#fce4ec',
117
- border: '1px solid #ef9a9a',
118
  borderRadius: '6px',
119
  fontFamily: 'system-ui, -apple-system, sans-serif',
120
  fontSize: '0.8rem',
 
1
  import type { ExpandedQuery } from '../types';
2
+ import { InfoTooltip } from './PipelineView';
3
 
4
  interface ExpansionColumnState {
5
  status: 'idle' | 'running' | 'done' | 'error';
 
9
 
10
  interface ExpansionColumnProps {
11
  state: ExpansionColumnState;
12
+ info?: string;
13
  }
14
 
15
  function Spinner() {
 
18
  display: 'inline-block',
19
  width: '16px',
20
  height: '16px',
21
+ border: '2px solid var(--border)',
22
  borderTopColor: '#f9a825',
23
  borderRadius: '50%',
24
  animation: 'spin 0.7s linear infinite',
 
30
  const text = Array.isArray(content) ? content.join('\n') : content;
31
  return (
32
  <div style={{
33
+ background: 'var(--bg-card)',
34
+ border: '1px solid var(--border)',
35
  borderRadius: '6px',
36
  padding: '0.65rem 0.85rem',
37
  marginBottom: '0.5rem',
 
50
  <div style={{
51
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
52
  fontSize: '0.72rem',
53
+ color: 'var(--text)',
54
  lineHeight: 1.6,
55
  whiteSpace: 'pre-wrap',
56
  wordBreak: 'break-word',
 
61
  );
62
  }
63
 
64
+ export default function ExpansionColumn({ state, info }: ExpansionColumnProps) {
65
  const isIdle = state.status === 'idle';
66
  const isRunning = state.status === 'running';
67
  const isDone = state.status === 'done';
 
72
  <div style={{
73
  display: 'flex',
74
  alignItems: 'center',
75
+ gap: '0.4rem',
76
  marginBottom: '0.75rem',
77
  }}>
78
  <h3 style={{
 
80
  fontSize: '0.8rem',
81
  fontFamily: 'system-ui, -apple-system, sans-serif',
82
  fontWeight: 700,
83
+ color: '#f57f17',
84
  textTransform: 'uppercase',
85
  letterSpacing: '0.05em',
86
  }}>
87
  Query Expansion
88
  </h3>
89
+ {info && <InfoTooltip text={info} />}
90
  {isRunning && <Spinner />}
91
  </div>
92
 
 
94
  <p style={{
95
  fontFamily: 'system-ui, -apple-system, sans-serif',
96
  fontSize: '0.8rem',
97
+ color: 'var(--text-muted)',
98
  margin: 0,
99
  }}>
100
+ Awaiting query...
101
  </p>
102
  )}
103
 
 
105
  <p style={{
106
  fontFamily: 'system-ui, -apple-system, sans-serif',
107
  fontSize: '0.8rem',
108
+ color: 'var(--text-secondary)',
109
  margin: 0,
110
  fontStyle: 'italic',
111
  }}>
112
+ Generating expanded queries...
113
  </p>
114
  )}
115
 
116
  {isError && (
117
  <div style={{
118
  padding: '0.65rem',
119
+ background: 'var(--error-bg)',
120
+ border: '1px solid var(--error-border)',
121
  borderRadius: '6px',
122
  fontFamily: 'system-ui, -apple-system, sans-serif',
123
  fontSize: '0.8rem',
src/components/FusionColumn.tsx CHANGED
@@ -1,5 +1,6 @@
1
  import type { RRFResult, RerankedResult, FinalResult } from '../types';
2
  import ResultCard from './ResultCard';
 
3
 
4
  interface FusionColumnState {
5
  rrf: { status: 'idle' | 'done'; data?: { merged: RRFResult[] } };
@@ -9,6 +10,7 @@ interface FusionColumnState {
9
 
10
  interface FusionColumnProps {
11
  state: FusionColumnState;
 
12
  }
13
 
14
  function Spinner() {
@@ -17,7 +19,7 @@ function Spinner() {
17
  display: 'inline-block',
18
  width: '16px',
19
  height: '16px',
20
- border: '2px solid #ddd',
21
  borderTopColor: '#43a047',
22
  borderRadius: '50%',
23
  animation: 'spin 0.7s linear infinite',
@@ -41,7 +43,7 @@ function SectionHeader({ label, color, badge }: { label: string; color: string;
41
  }}>
42
  {label}
43
  {badge && (
44
- <span style={{ color: '#999', fontWeight: 400, fontSize: '0.68rem' }}>{badge}</span>
45
  )}
46
  </div>
47
  );
@@ -54,15 +56,15 @@ function RRFRow({ result, rank }: { result: RRFResult; rank: number }) {
54
  alignItems: 'center',
55
  gap: '0.5rem',
56
  padding: '0.35rem 0.55rem',
57
- background: '#fff',
58
- border: '1px solid #e0e0e0',
59
  borderRadius: '5px',
60
  marginBottom: '0.25rem',
61
  fontSize: '0.75rem',
62
  }}>
63
  <span style={{
64
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
65
- color: '#aaa',
66
  fontSize: '0.68rem',
67
  minWidth: '18px',
68
  }}>
@@ -71,7 +73,7 @@ function RRFRow({ result, rank }: { result: RRFResult; rank: number }) {
71
  <span style={{
72
  flex: 1,
73
  fontFamily: 'system-ui, -apple-system, sans-serif',
74
- color: '#1a1a1a',
75
  fontWeight: 500,
76
  overflow: 'hidden',
77
  textOverflow: 'ellipsis',
@@ -103,7 +105,7 @@ function BeforeAfterComparison({ before, after }: { before: RRFResult[]; after:
103
  fontSize: '0.68rem',
104
  fontWeight: 600,
105
  fontFamily: 'system-ui, -apple-system, sans-serif',
106
- color: '#888',
107
  marginBottom: '0.3rem',
108
  textAlign: 'center',
109
  }}>
@@ -112,8 +114,8 @@ function BeforeAfterComparison({ before, after }: { before: RRFResult[]; after:
112
  {top5before.map((r, i) => (
113
  <div key={r.docId} style={{
114
  padding: '0.3rem 0.4rem',
115
- background: '#fff',
116
- border: '1px solid #e0e0e0',
117
  borderRadius: '4px',
118
  marginBottom: '0.2rem',
119
  fontSize: '0.68rem',
@@ -122,7 +124,7 @@ function BeforeAfterComparison({ before, after }: { before: RRFResult[]; after:
122
  }}>
123
  <span style={{
124
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
125
- color: '#bbb',
126
  }}>
127
  {i + 1}.
128
  </span>
@@ -131,7 +133,7 @@ function BeforeAfterComparison({ before, after }: { before: RRFResult[]; after:
131
  overflow: 'hidden',
132
  textOverflow: 'ellipsis',
133
  whiteSpace: 'nowrap',
134
- color: '#333',
135
  }}>
136
  {r.title}
137
  </span>
@@ -152,8 +154,8 @@ function BeforeAfterComparison({ before, after }: { before: RRFResult[]; after:
152
  {top5after.map((r, i) => (
153
  <div key={r.docId} style={{
154
  padding: '0.3rem 0.4rem',
155
- background: '#f1f8e9',
156
- border: '1px solid #c8e6c9',
157
  borderRadius: '4px',
158
  marginBottom: '0.2rem',
159
  fontSize: '0.68rem',
@@ -183,7 +185,7 @@ function BeforeAfterComparison({ before, after }: { before: RRFResult[]; after:
183
  );
184
  }
185
 
186
- export default function FusionColumn({ state }: FusionColumnProps) {
187
  const rrfDone = state.rrf.status === 'done';
188
  const rerankRunning = state.rerank.status === 'running';
189
  const rerankDone = state.rerank.status === 'done';
@@ -192,24 +194,25 @@ export default function FusionColumn({ state }: FusionColumnProps) {
192
 
193
  return (
194
  <div style={{ opacity: isIdle ? 0.45 : 1, transition: 'opacity 0.3s' }}>
195
- <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
196
  <h3 style={{
197
  margin: 0,
198
  fontSize: '0.8rem',
199
  fontFamily: 'system-ui, -apple-system, sans-serif',
200
  fontWeight: 700,
201
- color: '#1b5e20',
202
  textTransform: 'uppercase',
203
  letterSpacing: '0.05em',
204
  }}>
205
  Fusion & Reranking
206
  </h3>
 
207
  {rerankRunning && <Spinner />}
208
  </div>
209
 
210
  {isIdle && (
211
- <p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.8rem', color: '#999', margin: 0 }}>
212
- Awaiting search
213
  </p>
214
  )}
215
 
@@ -225,7 +228,7 @@ export default function FusionColumn({ state }: FusionColumnProps) {
225
  <RRFRow key={r.docId} result={r} rank={i + 1} />
226
  ))}
227
  {state.rrf.data.merged.length > 5 && (
228
- <div style={{ fontSize: '0.72rem', color: '#999', fontFamily: 'system-ui, -apple-system, sans-serif', paddingLeft: '0.25rem' }}>
229
  +{state.rrf.data.merged.length - 5} more
230
  </div>
231
  )}
@@ -234,8 +237,8 @@ export default function FusionColumn({ state }: FusionColumnProps) {
234
 
235
  {/* Rerank running */}
236
  {rerankRunning && !rerankDone && (
237
- <p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.8rem', color: '#888', margin: '0 0 0.75rem 0', fontStyle: 'italic' }}>
238
- Reranking with cross-encoder
239
  </p>
240
  )}
241
 
 
1
  import type { RRFResult, RerankedResult, FinalResult } from '../types';
2
  import ResultCard from './ResultCard';
3
+ import { InfoTooltip } from './PipelineView';
4
 
5
  interface FusionColumnState {
6
  rrf: { status: 'idle' | 'done'; data?: { merged: RRFResult[] } };
 
10
 
11
  interface FusionColumnProps {
12
  state: FusionColumnState;
13
+ info?: string;
14
  }
15
 
16
  function Spinner() {
 
19
  display: 'inline-block',
20
  width: '16px',
21
  height: '16px',
22
+ border: '2px solid var(--border)',
23
  borderTopColor: '#43a047',
24
  borderRadius: '50%',
25
  animation: 'spin 0.7s linear infinite',
 
43
  }}>
44
  {label}
45
  {badge && (
46
+ <span style={{ color: 'var(--text-muted)', fontWeight: 400, fontSize: '0.68rem' }}>{badge}</span>
47
  )}
48
  </div>
49
  );
 
56
  alignItems: 'center',
57
  gap: '0.5rem',
58
  padding: '0.35rem 0.55rem',
59
+ background: 'var(--bg-card)',
60
+ border: '1px solid var(--border)',
61
  borderRadius: '5px',
62
  marginBottom: '0.25rem',
63
  fontSize: '0.75rem',
64
  }}>
65
  <span style={{
66
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
67
+ color: 'var(--text-muted)',
68
  fontSize: '0.68rem',
69
  minWidth: '18px',
70
  }}>
 
73
  <span style={{
74
  flex: 1,
75
  fontFamily: 'system-ui, -apple-system, sans-serif',
76
+ color: 'var(--text)',
77
  fontWeight: 500,
78
  overflow: 'hidden',
79
  textOverflow: 'ellipsis',
 
105
  fontSize: '0.68rem',
106
  fontWeight: 600,
107
  fontFamily: 'system-ui, -apple-system, sans-serif',
108
+ color: 'var(--text-secondary)',
109
  marginBottom: '0.3rem',
110
  textAlign: 'center',
111
  }}>
 
114
  {top5before.map((r, i) => (
115
  <div key={r.docId} style={{
116
  padding: '0.3rem 0.4rem',
117
+ background: 'var(--bg-card)',
118
+ border: '1px solid var(--border)',
119
  borderRadius: '4px',
120
  marginBottom: '0.2rem',
121
  fontSize: '0.68rem',
 
124
  }}>
125
  <span style={{
126
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
127
+ color: 'var(--text-muted)',
128
  }}>
129
  {i + 1}.
130
  </span>
 
133
  overflow: 'hidden',
134
  textOverflow: 'ellipsis',
135
  whiteSpace: 'nowrap',
136
+ color: 'var(--text)',
137
  }}>
138
  {r.title}
139
  </span>
 
154
  {top5after.map((r, i) => (
155
  <div key={r.docId} style={{
156
  padding: '0.3rem 0.4rem',
157
+ background: 'var(--rerank-after-bg)',
158
+ border: '1px solid var(--rerank-after-border)',
159
  borderRadius: '4px',
160
  marginBottom: '0.2rem',
161
  fontSize: '0.68rem',
 
185
  );
186
  }
187
 
188
+ export default function FusionColumn({ state, info }: FusionColumnProps) {
189
  const rrfDone = state.rrf.status === 'done';
190
  const rerankRunning = state.rerank.status === 'running';
191
  const rerankDone = state.rerank.status === 'done';
 
194
 
195
  return (
196
  <div style={{ opacity: isIdle ? 0.45 : 1, transition: 'opacity 0.3s' }}>
197
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.75rem' }}>
198
  <h3 style={{
199
  margin: 0,
200
  fontSize: '0.8rem',
201
  fontFamily: 'system-ui, -apple-system, sans-serif',
202
  fontWeight: 700,
203
+ color: '#388e3c',
204
  textTransform: 'uppercase',
205
  letterSpacing: '0.05em',
206
  }}>
207
  Fusion & Reranking
208
  </h3>
209
+ {info && <InfoTooltip text={info} />}
210
  {rerankRunning && <Spinner />}
211
  </div>
212
 
213
  {isIdle && (
214
+ <p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.8rem', color: 'var(--text-muted)', margin: 0 }}>
215
+ Awaiting search...
216
  </p>
217
  )}
218
 
 
228
  <RRFRow key={r.docId} result={r} rank={i + 1} />
229
  ))}
230
  {state.rrf.data.merged.length > 5 && (
231
+ <div style={{ fontSize: '0.72rem', color: 'var(--text-muted)', fontFamily: 'system-ui, -apple-system, sans-serif', paddingLeft: '0.25rem' }}>
232
  +{state.rrf.data.merged.length - 5} more
233
  </div>
234
  )}
 
237
 
238
  {/* Rerank running */}
239
  {rerankRunning && !rerankDone && (
240
+ <p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.8rem', color: 'var(--text-secondary)', margin: '0 0 0.75rem 0', fontStyle: 'italic' }}>
241
+ Reranking with cross-encoder...
242
  </p>
243
  )}
244
 
src/components/ModelStatus.tsx CHANGED
@@ -24,7 +24,7 @@ function ProgressBar({ progress, color }: { progress: number; color: string }) {
24
  return (
25
  <div style={{
26
  height: '4px',
27
- background: '#e0e0e0',
28
  borderRadius: '2px',
29
  overflow: 'hidden',
30
  marginTop: '4px',
@@ -47,8 +47,8 @@ function ModelRow({ model }: { model: ModelState }) {
47
  return (
48
  <div style={{
49
  padding: '0.5rem 0.75rem',
50
- background: '#fff',
51
- border: '1px solid #e0e0e0',
52
  borderRadius: '6px',
53
  marginBottom: '0.4rem',
54
  }}>
@@ -56,7 +56,7 @@ function ModelRow({ model }: { model: ModelState }) {
56
  <span style={{
57
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
58
  fontSize: '0.78rem',
59
- color: '#333',
60
  }}>
61
  {model.name}
62
  </span>
@@ -70,14 +70,14 @@ function ModelRow({ model }: { model: ModelState }) {
70
  gap: '0.3rem',
71
  }}>
72
  {model.status === 'ready' && (
73
- <span style={{ fontSize: '0.85rem' }}></span>
74
  )}
75
  {model.status === 'error' && (
76
- <span style={{ fontSize: '0.85rem' }}></span>
77
  )}
78
  {STATUS_LABEL[model.status]}
79
  {showProgress && (
80
- <span style={{ color: '#888', fontWeight: 400 }}>
81
  {Math.round(model.progress * 100)}%
82
  </span>
83
  )}
@@ -104,8 +104,8 @@ export default function ModelStatus({ models }: ModelStatusProps) {
104
  return (
105
  <div style={{
106
  padding: '1rem',
107
- background: '#f8f8f8',
108
- border: '1px solid #e0e0e0',
109
  borderRadius: '8px',
110
  marginBottom: '1.5rem',
111
  }}>
@@ -120,7 +120,7 @@ export default function ModelStatus({ models }: ModelStatusProps) {
120
  fontSize: '0.85rem',
121
  fontFamily: 'system-ui, -apple-system, sans-serif',
122
  fontWeight: 600,
123
- color: '#444',
124
  textTransform: 'uppercase',
125
  letterSpacing: '0.05em',
126
  }}>
@@ -137,12 +137,23 @@ export default function ModelStatus({ models }: ModelStatusProps) {
137
  </span>
138
  )}
139
  </div>
 
 
 
 
 
 
 
 
 
 
 
140
  {models.map(m => (
141
  <ModelRow key={m.name} model={m} />
142
  ))}
143
  {models.length === 0 && (
144
  <div style={{
145
- color: '#999',
146
  fontSize: '0.85rem',
147
  fontFamily: 'system-ui, -apple-system, sans-serif',
148
  }}>
 
24
  return (
25
  <div style={{
26
  height: '4px',
27
+ background: 'var(--border)',
28
  borderRadius: '2px',
29
  overflow: 'hidden',
30
  marginTop: '4px',
 
47
  return (
48
  <div style={{
49
  padding: '0.5rem 0.75rem',
50
+ background: 'var(--bg-card)',
51
+ border: '1px solid var(--border)',
52
  borderRadius: '6px',
53
  marginBottom: '0.4rem',
54
  }}>
 
56
  <span style={{
57
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
58
  fontSize: '0.78rem',
59
+ color: 'var(--text)',
60
  }}>
61
  {model.name}
62
  </span>
 
70
  gap: '0.3rem',
71
  }}>
72
  {model.status === 'ready' && (
73
+ <span style={{ fontSize: '0.85rem' }}>{'\u2713'}</span>
74
  )}
75
  {model.status === 'error' && (
76
+ <span style={{ fontSize: '0.85rem' }}>{'\u2717'}</span>
77
  )}
78
  {STATUS_LABEL[model.status]}
79
  {showProgress && (
80
+ <span style={{ color: 'var(--text-secondary)', fontWeight: 400 }}>
81
  {Math.round(model.progress * 100)}%
82
  </span>
83
  )}
 
104
  return (
105
  <div style={{
106
  padding: '1rem',
107
+ background: 'var(--bg-section)',
108
+ border: '1px solid var(--border)',
109
  borderRadius: '8px',
110
  marginBottom: '1.5rem',
111
  }}>
 
120
  fontSize: '0.85rem',
121
  fontFamily: 'system-ui, -apple-system, sans-serif',
122
  fontWeight: 600,
123
+ color: 'var(--text-secondary)',
124
  textTransform: 'uppercase',
125
  letterSpacing: '0.05em',
126
  }}>
 
137
  </span>
138
  )}
139
  </div>
140
+ {!allReady && (
141
+ <p style={{
142
+ margin: '0 0 0.5rem',
143
+ fontSize: '0.75rem',
144
+ fontFamily: 'system-ui, -apple-system, sans-serif',
145
+ color: 'var(--text-secondary)',
146
+ lineHeight: 1.4,
147
+ }}>
148
+ First load downloads ~4 GB of model weights. Subsequent visits use the browser cache.
149
+ </p>
150
+ )}
151
  {models.map(m => (
152
  <ModelRow key={m.name} model={m} />
153
  ))}
154
  {models.length === 0 && (
155
  <div style={{
156
+ color: 'var(--text-muted)',
157
  fontSize: '0.85rem',
158
  fontFamily: 'system-ui, -apple-system, sans-serif',
159
  }}>
src/components/PipelineView.tsx CHANGED
@@ -1,3 +1,4 @@
 
1
  import type { ExpandedQuery, ScoredChunk, RRFResult, RerankedResult, FinalResult } from '../types';
2
  import ExpansionColumn from './ExpansionColumn';
3
  import SearchColumn from './SearchColumn';
@@ -17,35 +18,120 @@ interface PipelineViewProps {
17
  }
18
 
19
  const COLUMNS = [
20
- { label: 'User Query', bg: '#E8F0FE', headerColor: '#1a237e' },
21
- { label: 'Query Expansion', bg: '#FFF8E1', headerColor: '#5d4037' },
22
- { label: 'Parallel Search', bg: '#E0F2F1', headerColor: '#004d40' },
23
- { label: 'Result Fusion & Reranking', bg: '#E8F5E9', headerColor: '#1b5e20' },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  ];
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  function QueryColumn({ query }: { query?: string }) {
27
  return (
28
  <div>
29
- <h3 style={{
30
- margin: '0 0 0.75rem 0',
31
- fontSize: '0.8rem',
32
- fontFamily: 'system-ui, -apple-system, sans-serif',
33
- fontWeight: 700,
34
- color: '#1a237e',
35
- textTransform: 'uppercase',
36
- letterSpacing: '0.05em',
37
  }}>
38
- User Query
39
- </h3>
 
 
 
 
 
 
 
 
 
 
 
40
  {query ? (
41
  <div style={{
42
  padding: '0.65rem 0.85rem',
43
- background: '#fff',
44
- border: '1px solid #c5cae9',
45
  borderRadius: '6px',
46
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
47
  fontSize: '0.85rem',
48
- color: '#1a237e',
49
  wordBreak: 'break-word',
50
  lineHeight: 1.5,
51
  }}>
@@ -55,7 +141,7 @@ function QueryColumn({ query }: { query?: string }) {
55
  <p style={{
56
  fontFamily: 'system-ui, -apple-system, sans-serif',
57
  fontSize: '0.8rem',
58
- color: '#999',
59
  margin: 0,
60
  }}>
61
  No query yet.
@@ -81,29 +167,28 @@ export default function PipelineView({ state, query }: PipelineViewProps) {
81
  gap: '0',
82
  borderRadius: '10px',
83
  overflow: 'hidden',
84
- border: '1px solid #d0d0d0',
85
- boxShadow: '0 2px 12px rgba(0,0,0,0.07)',
86
  }}>
87
- {/* Column backgrounds are rendered as wrappers */}
88
  {COLUMNS.map((col, i) => (
89
  <div
90
  key={col.label}
91
  style={{
92
  background: col.bg,
93
  padding: '1rem',
94
- borderRight: i < COLUMNS.length - 1 ? '1px solid #d0d0d0' : 'none',
95
  minHeight: '300px',
96
  }}
97
  >
98
  {i === 0 && <QueryColumn query={query} />}
99
- {i === 1 && <ExpansionColumn state={state.expansion} />}
100
- {i === 2 && <SearchColumn state={state.search} />}
101
  {i === 3 && (
102
  <FusionColumn state={{
103
  rrf: state.rrf,
104
  rerank: state.rerank,
105
  blend: state.blend,
106
- }} />
107
  )}
108
  </div>
109
  ))}
@@ -120,3 +205,5 @@ export default function PipelineView({ state, query }: PipelineViewProps) {
120
  </>
121
  );
122
  }
 
 
 
1
+ import { useState } from 'react';
2
  import type { ExpandedQuery, ScoredChunk, RRFResult, RerankedResult, FinalResult } from '../types';
3
  import ExpansionColumn from './ExpansionColumn';
4
  import SearchColumn from './SearchColumn';
 
18
  }
19
 
20
  const COLUMNS = [
21
+ {
22
+ label: 'User Query',
23
+ bg: 'var(--col-query)',
24
+ headerColor: '#5c6bc0',
25
+ info: 'The original search query you typed. This is the starting point for the entire pipeline.',
26
+ },
27
+ {
28
+ label: 'Query Expansion',
29
+ bg: 'var(--col-expansion)',
30
+ headerColor: '#f57f17',
31
+ info: 'A fine-tuned 1.7B LLM generates three query variants: lexical keywords (lex) for BM25, semantic sentences (vec) for vector search, and a hypothetical document (HyDE) to improve recall.',
32
+ },
33
+ {
34
+ label: 'Parallel Search',
35
+ bg: 'var(--col-search)',
36
+ headerColor: '#00897b',
37
+ info: 'Two search strategies run simultaneously: BM25 keyword search (exact term matching) and vector similarity search (semantic meaning via embeddings). Each finds relevant document chunks independently.',
38
+ },
39
+ {
40
+ label: 'Fusion & Reranking',
41
+ bg: 'var(--col-fusion)',
42
+ headerColor: '#388e3c',
43
+ info: 'Results are merged via Reciprocal Rank Fusion (RRF), then a cross-encoder reranker (Qwen3-Reranker-0.6B) re-scores the top candidates for precision. Final scores blend RRF and reranker signals.',
44
+ },
45
  ];
46
 
47
+ function InfoTooltip({ text }: { text: string }) {
48
+ const [open, setOpen] = useState(false);
49
+
50
+ return (
51
+ <span
52
+ style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}
53
+ onMouseEnter={() => setOpen(true)}
54
+ onMouseLeave={() => setOpen(false)}
55
+ onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
56
+ >
57
+ <span style={{
58
+ display: 'inline-flex',
59
+ alignItems: 'center',
60
+ justifyContent: 'center',
61
+ width: '16px',
62
+ height: '16px',
63
+ borderRadius: '50%',
64
+ border: '1px solid var(--border)',
65
+ background: 'var(--bg-card)',
66
+ color: 'var(--text-muted)',
67
+ fontSize: '0.62rem',
68
+ fontWeight: 700,
69
+ cursor: 'help',
70
+ flexShrink: 0,
71
+ lineHeight: 1,
72
+ }}>
73
+ ?
74
+ </span>
75
+ {open && (
76
+ <div style={{
77
+ position: 'absolute',
78
+ top: '100%',
79
+ left: '50%',
80
+ transform: 'translateX(-50%)',
81
+ marginTop: '6px',
82
+ padding: '0.6rem 0.75rem',
83
+ background: 'var(--bg-card)',
84
+ border: '1px solid var(--border)',
85
+ borderRadius: '6px',
86
+ boxShadow: '0 4px 16px var(--shadow)',
87
+ fontSize: '0.72rem',
88
+ fontFamily: 'system-ui, -apple-system, sans-serif',
89
+ fontWeight: 400,
90
+ color: 'var(--text)',
91
+ lineHeight: 1.55,
92
+ width: '220px',
93
+ zIndex: 100,
94
+ textTransform: 'none',
95
+ letterSpacing: 'normal',
96
+ }}>
97
+ {text}
98
+ </div>
99
+ )}
100
+ </span>
101
+ );
102
+ }
103
+
104
  function QueryColumn({ query }: { query?: string }) {
105
  return (
106
  <div>
107
+ <div style={{
108
+ display: 'flex',
109
+ alignItems: 'center',
110
+ gap: '0.4rem',
111
+ marginBottom: '0.75rem',
 
 
 
112
  }}>
113
+ <h3 style={{
114
+ margin: 0,
115
+ fontSize: '0.8rem',
116
+ fontFamily: 'system-ui, -apple-system, sans-serif',
117
+ fontWeight: 700,
118
+ color: COLUMNS[0].headerColor,
119
+ textTransform: 'uppercase',
120
+ letterSpacing: '0.05em',
121
+ }}>
122
+ User Query
123
+ </h3>
124
+ <InfoTooltip text={COLUMNS[0].info} />
125
+ </div>
126
  {query ? (
127
  <div style={{
128
  padding: '0.65rem 0.85rem',
129
+ background: 'var(--bg-card)',
130
+ border: '1px solid var(--border)',
131
  borderRadius: '6px',
132
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
133
  fontSize: '0.85rem',
134
+ color: 'var(--text)',
135
  wordBreak: 'break-word',
136
  lineHeight: 1.5,
137
  }}>
 
141
  <p style={{
142
  fontFamily: 'system-ui, -apple-system, sans-serif',
143
  fontSize: '0.8rem',
144
+ color: 'var(--text-muted)',
145
  margin: 0,
146
  }}>
147
  No query yet.
 
167
  gap: '0',
168
  borderRadius: '10px',
169
  overflow: 'hidden',
170
+ border: '1px solid var(--border)',
171
+ boxShadow: '0 2px 12px var(--shadow)',
172
  }}>
 
173
  {COLUMNS.map((col, i) => (
174
  <div
175
  key={col.label}
176
  style={{
177
  background: col.bg,
178
  padding: '1rem',
179
+ borderRight: i < COLUMNS.length - 1 ? '1px solid var(--border)' : 'none',
180
  minHeight: '300px',
181
  }}
182
  >
183
  {i === 0 && <QueryColumn query={query} />}
184
+ {i === 1 && <ExpansionColumn state={state.expansion} info={col.info} />}
185
+ {i === 2 && <SearchColumn state={state.search} info={col.info} />}
186
  {i === 3 && (
187
  <FusionColumn state={{
188
  rrf: state.rrf,
189
  rerank: state.rerank,
190
  blend: state.blend,
191
+ }} info={col.info} />
192
  )}
193
  </div>
194
  ))}
 
205
  </>
206
  );
207
  }
208
+
209
+ export { InfoTooltip };
src/components/QueryInput.tsx CHANGED
@@ -28,21 +28,21 @@ export default function QueryInput({ onSearch, disabled }: QueryInputProps) {
28
  value={query}
29
  onChange={e => setQuery(e.target.value)}
30
  disabled={disabled}
31
- placeholder={disabled ? 'Loading models' : 'Enter a search query'}
32
  style={{
33
  flex: 1,
34
  padding: '0.6rem 0.9rem',
35
  fontSize: '1rem',
36
  fontFamily: 'system-ui, -apple-system, sans-serif',
37
- border: '1px solid #ccc',
38
  borderRadius: '6px',
39
- background: disabled ? '#f5f5f5' : '#fff',
40
- color: disabled ? '#999' : '#111',
41
  outline: 'none',
42
  transition: 'border-color 0.15s',
43
  }}
44
  onFocus={e => { if (!disabled) e.target.style.borderColor = '#4285F4'; }}
45
- onBlur={e => { e.target.style.borderColor = '#ccc'; }}
46
  />
47
  <button
48
  type="submit"
@@ -51,7 +51,7 @@ export default function QueryInput({ onSearch, disabled }: QueryInputProps) {
51
  padding: '0.6rem 1.2rem',
52
  fontSize: '1rem',
53
  fontFamily: 'system-ui, -apple-system, sans-serif',
54
- background: disabled || !query.trim() ? '#ccc' : '#4285F4',
55
  color: '#fff',
56
  border: 'none',
57
  borderRadius: '6px',
@@ -65,7 +65,7 @@ export default function QueryInput({ onSearch, disabled }: QueryInputProps) {
65
  </form>
66
 
67
  <div style={{ marginTop: '0.6rem', display: 'flex', gap: '0.4rem', flexWrap: 'wrap', alignItems: 'center' }}>
68
- <span style={{ fontSize: '0.8rem', color: '#666', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
69
  Examples:
70
  </span>
71
  {EXAMPLE_QUERIES.map(q => (
@@ -77,9 +77,9 @@ export default function QueryInput({ onSearch, disabled }: QueryInputProps) {
77
  padding: '0.25rem 0.6rem',
78
  fontSize: '0.8rem',
79
  fontFamily: 'system-ui, -apple-system, sans-serif',
80
- background: '#f0f4ff',
81
- color: disabled ? '#aaa' : '#4285F4',
82
- border: '1px solid #c5d5ff',
83
  borderRadius: '4px',
84
  cursor: disabled ? 'not-allowed' : 'pointer',
85
  }}
 
28
  value={query}
29
  onChange={e => setQuery(e.target.value)}
30
  disabled={disabled}
31
+ placeholder={disabled ? 'Loading models\u2026' : 'Enter a search query\u2026'}
32
  style={{
33
  flex: 1,
34
  padding: '0.6rem 0.9rem',
35
  fontSize: '1rem',
36
  fontFamily: 'system-ui, -apple-system, sans-serif',
37
+ border: '1px solid var(--input-border)',
38
  borderRadius: '6px',
39
+ background: disabled ? 'var(--bg-section)' : 'var(--bg-input)',
40
+ color: disabled ? 'var(--text-muted)' : 'var(--text)',
41
  outline: 'none',
42
  transition: 'border-color 0.15s',
43
  }}
44
  onFocus={e => { if (!disabled) e.target.style.borderColor = '#4285F4'; }}
45
+ onBlur={e => { e.target.style.borderColor = ''; }}
46
  />
47
  <button
48
  type="submit"
 
51
  padding: '0.6rem 1.2rem',
52
  fontSize: '1rem',
53
  fontFamily: 'system-ui, -apple-system, sans-serif',
54
+ background: disabled || !query.trim() ? 'var(--border)' : '#4285F4',
55
  color: '#fff',
56
  border: 'none',
57
  borderRadius: '6px',
 
65
  </form>
66
 
67
  <div style={{ marginTop: '0.6rem', display: 'flex', gap: '0.4rem', flexWrap: 'wrap', alignItems: 'center' }}>
68
+ <span style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
69
  Examples:
70
  </span>
71
  {EXAMPLE_QUERIES.map(q => (
 
77
  padding: '0.25rem 0.6rem',
78
  fontSize: '0.8rem',
79
  fontFamily: 'system-ui, -apple-system, sans-serif',
80
+ background: 'var(--example-bg)',
81
+ color: disabled ? 'var(--text-muted)' : '#4285F4',
82
+ border: '1px solid var(--example-border)',
83
  borderRadius: '4px',
84
  cursor: disabled ? 'not-allowed' : 'pointer',
85
  }}
src/components/ResultCard.tsx CHANGED
@@ -10,8 +10,8 @@ interface ResultCardProps {
10
 
11
  function ScoreBadge({ score }: { score: number }) {
12
  const pct = Math.round(score * 100);
13
- const bg = pct >= 80 ? '#e8f5e9' : pct >= 50 ? '#fff8e1' : '#fce4ec';
14
- const color = pct >= 80 ? '#2e7d32' : pct >= 50 ? '#f57f17' : '#c62828';
15
 
16
  return (
17
  <span style={{
@@ -42,21 +42,21 @@ export default function ResultCard({ title, score, snippet, expanded: expandedPr
42
  }
43
  }
44
 
45
- const preview = snippet.length > 200 ? snippet.slice(0, 200) + '' : snippet;
46
 
47
  return (
48
  <div
49
  onClick={handleToggle}
50
  style={{
51
  padding: '0.65rem 0.85rem',
52
- background: '#fff',
53
- border: '1px solid #e0e0e0',
54
  borderRadius: '6px',
55
  marginBottom: '0.4rem',
56
  cursor: 'pointer',
57
  transition: 'box-shadow 0.15s',
58
  }}
59
- onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)'; }}
60
  onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = 'none'; }}
61
  >
62
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
@@ -64,7 +64,7 @@ export default function ResultCard({ title, score, snippet, expanded: expandedPr
64
  fontFamily: 'system-ui, -apple-system, sans-serif',
65
  fontSize: '0.85rem',
66
  fontWeight: 600,
67
- color: '#1a1a1a',
68
  overflow: 'hidden',
69
  textOverflow: 'ellipsis',
70
  whiteSpace: 'nowrap',
@@ -73,8 +73,8 @@ export default function ResultCard({ title, score, snippet, expanded: expandedPr
73
  {title}
74
  </span>
75
  <ScoreBadge score={score} />
76
- <span style={{ color: '#999', fontSize: '0.75rem', flexShrink: 0 }}>
77
- {expanded ? '' : ''}
78
  </span>
79
  </div>
80
 
@@ -82,7 +82,7 @@ export default function ResultCard({ title, score, snippet, expanded: expandedPr
82
  marginTop: '0.4rem',
83
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
84
  fontSize: '0.72rem',
85
- color: '#555',
86
  lineHeight: 1.5,
87
  whiteSpace: expanded ? 'pre-wrap' : 'nowrap',
88
  overflow: 'hidden',
 
10
 
11
  function ScoreBadge({ score }: { score: number }) {
12
  const pct = Math.round(score * 100);
13
+ const bg = pct >= 80 ? 'var(--score-good-bg)' : pct >= 50 ? 'var(--score-mid-bg)' : 'var(--score-bad-bg)';
14
+ const color = pct >= 80 ? '#2e7d32' : pct >= 50 ? '#f57f17' : 'var(--text-secondary)';
15
 
16
  return (
17
  <span style={{
 
42
  }
43
  }
44
 
45
+ const preview = snippet.length > 200 ? snippet.slice(0, 200) + '\u2026' : snippet;
46
 
47
  return (
48
  <div
49
  onClick={handleToggle}
50
  style={{
51
  padding: '0.65rem 0.85rem',
52
+ background: 'var(--bg-card)',
53
+ border: '1px solid var(--border)',
54
  borderRadius: '6px',
55
  marginBottom: '0.4rem',
56
  cursor: 'pointer',
57
  transition: 'box-shadow 0.15s',
58
  }}
59
+ onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = '0 2px 8px var(--shadow)'; }}
60
  onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = 'none'; }}
61
  >
62
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
 
64
  fontFamily: 'system-ui, -apple-system, sans-serif',
65
  fontSize: '0.85rem',
66
  fontWeight: 600,
67
+ color: 'var(--text)',
68
  overflow: 'hidden',
69
  textOverflow: 'ellipsis',
70
  whiteSpace: 'nowrap',
 
73
  {title}
74
  </span>
75
  <ScoreBadge score={score} />
76
+ <span style={{ color: 'var(--text-muted)', fontSize: '0.75rem', flexShrink: 0 }}>
77
+ {expanded ? '\u25B2' : '\u25BC'}
78
  </span>
79
  </div>
80
 
 
82
  marginTop: '0.4rem',
83
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
84
  fontSize: '0.72rem',
85
+ color: 'var(--text-secondary)',
86
  lineHeight: 1.5,
87
  whiteSpace: expanded ? 'pre-wrap' : 'nowrap',
88
  overflow: 'hidden',
src/components/SearchColumn.tsx CHANGED
@@ -1,5 +1,6 @@
1
  import { useState } from 'react';
2
  import type { ScoredChunk } from '../types';
 
3
 
4
  interface SearchColumnState {
5
  status: 'idle' | 'running' | 'done';
@@ -8,6 +9,7 @@ interface SearchColumnState {
8
 
9
  interface SearchColumnProps {
10
  state: SearchColumnState;
 
11
  }
12
 
13
  function Spinner() {
@@ -16,7 +18,7 @@ function Spinner() {
16
  display: 'inline-block',
17
  width: '16px',
18
  height: '16px',
19
- border: '2px solid #ddd',
20
  borderTopColor: '#00897b',
21
  borderRadius: '50%',
22
  animation: 'spin 0.7s linear infinite',
@@ -28,7 +30,7 @@ function ScoreBadge({ score, source }: { score: number; source: 'bm25' | 'vector
28
  const label = source === 'bm25'
29
  ? score.toFixed(2)
30
  : (score * 100).toFixed(1) + '%';
31
- const bg = source === 'vector' ? '#e0f2f1' : '#e8eaf6';
32
  const color = source === 'vector' ? '#00695c' : '#283593';
33
 
34
  return (
@@ -54,14 +56,14 @@ function HitRow({ hit }: { hit: ScoredChunk }) {
54
  onClick={() => setOpen(o => !o)}
55
  style={{
56
  padding: '0.45rem 0.65rem',
57
- background: '#fff',
58
- border: '1px solid #e0e0e0',
59
  borderRadius: '5px',
60
  marginBottom: '0.3rem',
61
  cursor: 'pointer',
62
  fontSize: '0.78rem',
63
  }}
64
- onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = '0 1px 5px rgba(0,0,0,0.08)'; }}
65
  onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = 'none'; }}
66
  >
67
  <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
@@ -69,7 +71,7 @@ function HitRow({ hit }: { hit: ScoredChunk }) {
69
  flex: 1,
70
  fontFamily: 'system-ui, -apple-system, sans-serif',
71
  fontWeight: 600,
72
- color: '#1a1a1a',
73
  overflow: 'hidden',
74
  textOverflow: 'ellipsis',
75
  whiteSpace: 'nowrap',
@@ -77,18 +79,18 @@ function HitRow({ hit }: { hit: ScoredChunk }) {
77
  {hit.chunk.title}
78
  </span>
79
  <ScoreBadge score={hit.score} source={hit.source} />
80
- <span style={{ color: '#bbb', fontSize: '0.65rem' }}>{open ? '' : ''}</span>
81
  </div>
82
  {open && (
83
  <div style={{
84
  marginTop: '0.4rem',
85
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
86
  fontSize: '0.68rem',
87
- color: '#555',
88
  lineHeight: 1.55,
89
  whiteSpace: 'pre-wrap',
90
  wordBreak: 'break-word',
91
- borderTop: '1px solid #f0f0f0',
92
  paddingTop: '0.4rem',
93
  }}>
94
  {hit.chunk.text}
@@ -111,7 +113,7 @@ function HitsSection({ label, hits, color }: { label: string; hits: ScoredChunk[
111
  letterSpacing: '0.06em',
112
  marginBottom: '0.4rem',
113
  }}>
114
- {label} <span style={{ color: '#999', fontWeight: 400 }}>({hits.length} hits)</span>
115
  </div>
116
  {top.map((hit, i) => (
117
  <HitRow key={`${hit.chunk.docId}-${hit.chunk.chunkIndex}-${i}`} hit={hit} />
@@ -119,7 +121,7 @@ function HitsSection({ label, hits, color }: { label: string; hits: ScoredChunk[
119
  {hits.length > 5 && (
120
  <div style={{
121
  fontSize: '0.72rem',
122
- color: '#999',
123
  fontFamily: 'system-ui, -apple-system, sans-serif',
124
  paddingLeft: '0.25rem',
125
  }}>
@@ -130,37 +132,38 @@ function HitsSection({ label, hits, color }: { label: string; hits: ScoredChunk[
130
  );
131
  }
132
 
133
- export default function SearchColumn({ state }: SearchColumnProps) {
134
  const isIdle = state.status === 'idle';
135
  const isRunning = state.status === 'running';
136
  const isDone = state.status === 'done';
137
 
138
  return (
139
  <div style={{ opacity: isIdle ? 0.45 : 1, transition: 'opacity 0.3s' }}>
140
- <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
141
  <h3 style={{
142
  margin: 0,
143
  fontSize: '0.8rem',
144
  fontFamily: 'system-ui, -apple-system, sans-serif',
145
  fontWeight: 700,
146
- color: '#004d40',
147
  textTransform: 'uppercase',
148
  letterSpacing: '0.05em',
149
  }}>
150
  Parallel Search
151
  </h3>
 
152
  {isRunning && <Spinner />}
153
  </div>
154
 
155
  {isIdle && (
156
- <p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.8rem', color: '#999', margin: 0 }}>
157
- Awaiting expansion
158
  </p>
159
  )}
160
 
161
  {isRunning && (
162
- <p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.8rem', color: '#888', margin: 0, fontStyle: 'italic' }}>
163
- Running vector + BM25 search
164
  </p>
165
  )}
166
 
 
1
  import { useState } from 'react';
2
  import type { ScoredChunk } from '../types';
3
+ import { InfoTooltip } from './PipelineView';
4
 
5
  interface SearchColumnState {
6
  status: 'idle' | 'running' | 'done';
 
9
 
10
  interface SearchColumnProps {
11
  state: SearchColumnState;
12
+ info?: string;
13
  }
14
 
15
  function Spinner() {
 
18
  display: 'inline-block',
19
  width: '16px',
20
  height: '16px',
21
+ border: '2px solid var(--border)',
22
  borderTopColor: '#00897b',
23
  borderRadius: '50%',
24
  animation: 'spin 0.7s linear infinite',
 
30
  const label = source === 'bm25'
31
  ? score.toFixed(2)
32
  : (score * 100).toFixed(1) + '%';
33
+ const bg = source === 'vector' ? 'var(--col-search)' : 'var(--col-query)';
34
  const color = source === 'vector' ? '#00695c' : '#283593';
35
 
36
  return (
 
56
  onClick={() => setOpen(o => !o)}
57
  style={{
58
  padding: '0.45rem 0.65rem',
59
+ background: 'var(--bg-card)',
60
+ border: '1px solid var(--border)',
61
  borderRadius: '5px',
62
  marginBottom: '0.3rem',
63
  cursor: 'pointer',
64
  fontSize: '0.78rem',
65
  }}
66
+ onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = '0 1px 5px var(--shadow)'; }}
67
  onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = 'none'; }}
68
  >
69
  <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
 
71
  flex: 1,
72
  fontFamily: 'system-ui, -apple-system, sans-serif',
73
  fontWeight: 600,
74
+ color: 'var(--text)',
75
  overflow: 'hidden',
76
  textOverflow: 'ellipsis',
77
  whiteSpace: 'nowrap',
 
79
  {hit.chunk.title}
80
  </span>
81
  <ScoreBadge score={hit.score} source={hit.source} />
82
+ <span style={{ color: 'var(--text-muted)', fontSize: '0.65rem' }}>{open ? '\u25B2' : '\u25BC'}</span>
83
  </div>
84
  {open && (
85
  <div style={{
86
  marginTop: '0.4rem',
87
  fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
88
  fontSize: '0.68rem',
89
+ color: 'var(--text-secondary)',
90
  lineHeight: 1.55,
91
  whiteSpace: 'pre-wrap',
92
  wordBreak: 'break-word',
93
+ borderTop: '1px solid var(--border-light)',
94
  paddingTop: '0.4rem',
95
  }}>
96
  {hit.chunk.text}
 
113
  letterSpacing: '0.06em',
114
  marginBottom: '0.4rem',
115
  }}>
116
+ {label} <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>({hits.length} hits)</span>
117
  </div>
118
  {top.map((hit, i) => (
119
  <HitRow key={`${hit.chunk.docId}-${hit.chunk.chunkIndex}-${i}`} hit={hit} />
 
121
  {hits.length > 5 && (
122
  <div style={{
123
  fontSize: '0.72rem',
124
+ color: 'var(--text-muted)',
125
  fontFamily: 'system-ui, -apple-system, sans-serif',
126
  paddingLeft: '0.25rem',
127
  }}>
 
132
  );
133
  }
134
 
135
+ export default function SearchColumn({ state, info }: SearchColumnProps) {
136
  const isIdle = state.status === 'idle';
137
  const isRunning = state.status === 'running';
138
  const isDone = state.status === 'done';
139
 
140
  return (
141
  <div style={{ opacity: isIdle ? 0.45 : 1, transition: 'opacity 0.3s' }}>
142
+ <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginBottom: '0.75rem' }}>
143
  <h3 style={{
144
  margin: 0,
145
  fontSize: '0.8rem',
146
  fontFamily: 'system-ui, -apple-system, sans-serif',
147
  fontWeight: 700,
148
+ color: '#00897b',
149
  textTransform: 'uppercase',
150
  letterSpacing: '0.05em',
151
  }}>
152
  Parallel Search
153
  </h3>
154
+ {info && <InfoTooltip text={info} />}
155
  {isRunning && <Spinner />}
156
  </div>
157
 
158
  {isIdle && (
159
+ <p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.8rem', color: 'var(--text-muted)', margin: 0 }}>
160
+ Awaiting expansion...
161
  </p>
162
  )}
163
 
164
  {isRunning && (
165
+ <p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0, fontStyle: 'italic' }}>
166
+ Running vector + BM25 search...
167
  </p>
168
  )}
169
 
src/constants.ts CHANGED
@@ -11,10 +11,6 @@ export const RRF_SECONDARY_WEIGHT = 1.0;
11
  export const RRF_RANK1_BONUS = 0.05;
12
  export const RRF_RANK2_BONUS = 0.02;
13
 
14
- // Score Blending
15
- export const BLEND_TOP3_RRF = 0.75;
16
- export const BLEND_MID_RRF = 0.6;
17
- export const BLEND_TAIL_RRF = 0.4;
18
 
19
  // BM25
20
  export const BM25_K1 = 1.2;
 
11
  export const RRF_RANK1_BONUS = 0.05;
12
  export const RRF_RANK2_BONUS = 0.02;
13
 
 
 
 
 
14
 
15
  // BM25
16
  export const BM25_K1 = 1.2;
src/pipeline/blend.test.ts CHANGED
@@ -1,7 +1,8 @@
1
  import { describe, it, expect } from "vitest";
2
  import { blendScores } from "./blend";
3
  import type { RRFResult } from "../types";
4
- import { BLEND_TOP3_RRF, BLEND_MID_RRF, BLEND_TAIL_RRF } from "../constants";
 
5
 
6
  // ---------------------------------------------------------------------------
7
  // Helpers
@@ -29,7 +30,7 @@ describe("blendScores", () => {
29
  expect(blendScores([], new Map())).toEqual([]);
30
  });
31
 
32
- it("uses top3 weight for rank 1-3", () => {
33
  const rrfResults = [
34
  makeRRFResult("doc1", 0.5),
35
  makeRRFResult("doc2", 0.4),
@@ -41,47 +42,17 @@ describe("blendScores", () => {
41
  ["doc3", 0.7],
42
  ]);
43
  const results = blendScores(rrfResults, rerankScores);
44
- // All three should use BLEND_TOP3_RRF weight
45
- // doc1: 0.75*0.5 + 0.25*0.9 = 0.375 + 0.225 = 0.600
46
  const doc1 = results.find((r) => r.docId === "doc1")!;
47
- const expected1 = BLEND_TOP3_RRF * 0.5 + (1 - BLEND_TOP3_RRF) * 0.9;
48
- expect(doc1.score).toBeCloseTo(expected1, 10);
49
- });
50
-
51
- it("uses mid weight for rank 4-10", () => {
52
- // Create 5 results; rank 4 and 5 should use mid weight
53
- const rrfResults = Array.from({ length: 5 }, (_, i) =>
54
- makeRRFResult(`doc${i}`, 0.5 - i * 0.05),
55
- );
56
- const rerankScores = new Map<string, number>([["doc3", 0.9]]);
57
-
58
- const results = blendScores(rrfResults, rerankScores);
59
- // doc3 is at rank 4 in RRF ordering -> uses BLEND_MID_RRF
60
- const doc3 = results.find((r) => r.docId === "doc3")!;
61
- const expected = BLEND_MID_RRF * 0.35 + (1 - BLEND_MID_RRF) * 0.9;
62
- expect(doc3.score).toBeCloseTo(expected, 10);
63
- });
64
-
65
- it("uses tail weight for rank 11+", () => {
66
- const rrfResults = Array.from({ length: 12 }, (_, i) =>
67
- makeRRFResult(`doc${i}`, 0.5 - i * 0.03),
68
- );
69
- const rerankScores = new Map<string, number>([["doc11", 0.95]]);
70
-
71
- const results = blendScores(rrfResults, rerankScores);
72
- // doc11 is at rank 12 -> uses BLEND_TAIL_RRF
73
- const doc11 = results.find((r) => r.docId === "doc11")!;
74
- const rrfScore = 0.5 - 11 * 0.03; // 0.17
75
- const expected = BLEND_TAIL_RRF * rrfScore + (1 - BLEND_TAIL_RRF) * 0.95;
76
- expect(doc11.score).toBeCloseTo(expected, 10);
77
  });
78
 
79
  it("defaults missing rerank scores to 0", () => {
80
  const rrfResults = [makeRRFResult("doc1", 0.5)];
81
- const rerankScores = new Map<string, number>(); // no rerank score for doc1
82
  const results = blendScores(rrfResults, rerankScores);
83
- // score = 0.75 * 0.5 + 0.25 * 0 = 0.375
84
- expect(results[0].score).toBeCloseTo(BLEND_TOP3_RRF * 0.5, 10);
85
  });
86
 
87
  it("sorts by blended score descending", () => {
@@ -90,7 +61,6 @@ describe("blendScores", () => {
90
  makeRRFResult("doc2", 0.4),
91
  makeRRFResult("doc3", 0.3),
92
  ];
93
- // High rerank score on doc3 should push it up
94
  const rerankScores = new Map([
95
  ["doc1", 0.1],
96
  ["doc2", 0.2],
@@ -107,14 +77,13 @@ describe("blendScores", () => {
107
  makeRRFResult("doc1", 0.5),
108
  makeRRFResult("doc2", 0.49),
109
  ];
110
- // doc2 gets much higher rerank score
111
  const rerankScores = new Map([
112
  ["doc1", 0.0],
113
  ["doc2", 1.0],
114
  ]);
115
  const results = blendScores(rrfResults, rerankScores);
116
- // doc1: 0.75*0.5 + 0.25*0.0 = 0.375
117
- // doc2: 0.75*0.49 + 0.25*1.0 = 0.3675 + 0.25 = 0.6175
118
  expect(results[0].docId).toBe("doc2");
119
  });
120
 
@@ -128,11 +97,9 @@ describe("blendScores", () => {
128
  });
129
 
130
  it("deduplicates by docId, keeping highest blended score", () => {
131
- // This shouldn't normally happen since RRF already deduplicates,
132
- // but the function handles it defensively
133
  const rrfResults = [
134
  makeRRFResult("doc1", 0.5),
135
- makeRRFResult("doc1", 0.3), // duplicate
136
  ];
137
  const rerankScores = new Map([["doc1", 0.8]]);
138
  const results = blendScores(rrfResults, rerankScores);
@@ -140,50 +107,25 @@ describe("blendScores", () => {
140
  expect(results[0].docId).toBe("doc1");
141
  });
142
 
143
- it("handles single result correctly", () => {
144
- const rrfResults = [makeRRFResult("doc1", 0.42)];
145
- const rerankScores = new Map([["doc1", 0.88]]);
146
- const results = blendScores(rrfResults, rerankScores);
147
- expect(results).toHaveLength(1);
148
- const expected = BLEND_TOP3_RRF * 0.42 + (1 - BLEND_TOP3_RRF) * 0.88;
149
- expect(results[0].score).toBeCloseTo(expected, 10);
150
- });
151
-
152
- it("weight transitions are correct at boundaries", () => {
153
- // Create exactly 11 results, check ranks 3, 4, 10, 11
154
- const rrfResults = Array.from({ length: 11 }, (_, i) =>
155
- makeRRFResult(`doc${i}`, 1.0), // same RRF score
156
- );
157
- const rerankScores = new Map<string, number>();
158
- // Give all the same rerank score so we can check weight differences
159
- for (let i = 0; i < 11; i++) {
160
- rerankScores.set(`doc${i}`, 1.0);
161
- }
162
-
163
- const results = blendScores(rrfResults, rerankScores);
164
- // All should have blended score = weight * 1.0 + (1-weight) * 1.0 = 1.0
165
- // regardless of weight, since both inputs are 1.0
166
- for (const r of results) {
167
- expect(r.score).toBeCloseTo(1.0, 10);
168
- }
169
- });
170
-
171
- it("correctly applies different weights when scores differ", () => {
172
- // 11 results with identical RRF=0.5, rerank=1.0
173
- const rrfResults = Array.from({ length: 11 }, (_, i) =>
174
- makeRRFResult(`doc${i}`, 0.5),
175
- );
176
- const rerankScores = new Map<string, number>();
177
- for (let i = 0; i < 11; i++) {
178
- rerankScores.set(`doc${i}`, 1.0);
179
- }
180
-
181
  const results = blendScores(rrfResults, rerankScores);
182
- // Rank 1-3: 0.75*0.5 + 0.25*1.0 = 0.625
183
- // Rank 4-10: 0.60*0.5 + 0.40*1.0 = 0.700
184
- // Rank 11: 0.40*0.5 + 0.60*1.0 = 0.800
185
- // So rank 11 should have highest score (tail weight favors reranker more)
186
- const topScore = results[0].score;
187
- expect(topScore).toBeCloseTo(0.8, 10); // rank 11 doc
188
  });
189
  });
 
1
  import { describe, it, expect } from "vitest";
2
  import { blendScores } from "./blend";
3
  import type { RRFResult } from "../types";
4
+
5
+ const RRF_W = 0.8; // must match BLEND_RRF_WEIGHT in blend.ts
6
 
7
  // ---------------------------------------------------------------------------
8
  // Helpers
 
30
  expect(blendScores([], new Map())).toEqual([]);
31
  });
32
 
33
+ it("applies uniform 70/30 weight to all ranks", () => {
34
  const rrfResults = [
35
  makeRRFResult("doc1", 0.5),
36
  makeRRFResult("doc2", 0.4),
 
42
  ["doc3", 0.7],
43
  ]);
44
  const results = blendScores(rrfResults, rerankScores);
45
+ // doc1: normalized RRF = 1.0, rerank = 0.9 -> 0.7*1.0 + 0.3*0.9 = 0.97
 
46
  const doc1 = results.find((r) => r.docId === "doc1")!;
47
+ expect(doc1.score).toBeCloseTo(RRF_W * 1.0 + (1 - RRF_W) * 0.9, 10);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  });
49
 
50
  it("defaults missing rerank scores to 0", () => {
51
  const rrfResults = [makeRRFResult("doc1", 0.5)];
52
+ const rerankScores = new Map<string, number>();
53
  const results = blendScores(rrfResults, rerankScores);
54
+ // score = 0.7 * 1.0 + 0.3 * 0 = 0.7 (normalized RRF = 1.0 for single result)
55
+ expect(results[0].score).toBeCloseTo(RRF_W, 10);
56
  });
57
 
58
  it("sorts by blended score descending", () => {
 
61
  makeRRFResult("doc2", 0.4),
62
  makeRRFResult("doc3", 0.3),
63
  ];
 
64
  const rerankScores = new Map([
65
  ["doc1", 0.1],
66
  ["doc2", 0.2],
 
77
  makeRRFResult("doc1", 0.5),
78
  makeRRFResult("doc2", 0.49),
79
  ];
 
80
  const rerankScores = new Map([
81
  ["doc1", 0.0],
82
  ["doc2", 1.0],
83
  ]);
84
  const results = blendScores(rrfResults, rerankScores);
85
+ // doc1: 0.7*1.0 + 0.3*0.0 = 0.700
86
+ // doc2: 0.7*(0.49/0.5) + 0.3*1.0 = 0.686 + 0.3 = 0.986
87
  expect(results[0].docId).toBe("doc2");
88
  });
89
 
 
97
  });
98
 
99
  it("deduplicates by docId, keeping highest blended score", () => {
 
 
100
  const rrfResults = [
101
  makeRRFResult("doc1", 0.5),
102
+ makeRRFResult("doc1", 0.3),
103
  ];
104
  const rerankScores = new Map([["doc1", 0.8]]);
105
  const results = blendScores(rrfResults, rerankScores);
 
107
  expect(results[0].docId).toBe("doc1");
108
  });
109
 
110
+ it("uniform weight does not cause rank leapfrogging from reranker noise", () => {
111
+ // The bug: with position-aware weights, rank 4 got 40% reranker weight
112
+ // while rank 3 got only 25%, causing irrelevant docs to jump up.
113
+ // With uniform weights, a low-RRF doc needs a very high reranker score to leapfrog.
114
+ const rrfResults = [
115
+ makeRRFResult("doc1", 0.12), // rank 1 relevant
116
+ makeRRFResult("doc2", 0.07), // rank 2 — relevant
117
+ makeRRFResult("doc3", 0.05), // rank 3 — relevant
118
+ makeRRFResult("doc4", 0.047), // rank 4 — irrelevant (Taj Mahal)
119
+ ];
120
+ const rerankScores = new Map([
121
+ ["doc1", 0.0],
122
+ ["doc2", 0.0],
123
+ ["doc3", 0.0],
124
+ ["doc4", 0.66], // noisy reranker gives moderate score to irrelevant doc
125
+ ]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  const results = blendScores(rrfResults, rerankScores);
127
+ // doc4 should NOT be at position 2
128
+ expect(results[0].docId).toBe("doc1");
129
+ expect(results[1].docId).not.toBe("doc4");
 
 
 
130
  });
131
  });
src/pipeline/blend.ts CHANGED
@@ -1,32 +1,24 @@
1
  import type { RRFResult, RerankedResult, FinalResult } from "../types";
2
- import { BLEND_TOP3_RRF, BLEND_MID_RRF, BLEND_TAIL_RRF } from "../constants";
3
 
4
- // Blend RRF score with reranker score using position-aware weights
 
 
 
 
 
5
  export function blendScores(
6
  rrfResults: RRFResult[],
7
  rerankScores: Map<string, number>, // docId -> rerank score
8
  ): FinalResult[] {
9
- const blended: RerankedResult[] = rrfResults.map((result, index) => {
10
- const rank = index + 1; // 1-indexed position in RRF ordering
 
 
11
  const rerankScore = rerankScores.get(result.docId) ?? 0;
 
12
 
13
- // Position-aware RRF weight (from QMD):
14
- // Rank 1-3: 75% RRF + 25% reranker
15
- // Rank 4-10: 60% RRF + 40% reranker
16
- // Rank 11+: 40% RRF + 60% reranker
17
- let rrfWeight: number;
18
- if (rank <= 3) {
19
- rrfWeight = BLEND_TOP3_RRF; // 0.75
20
- } else if (rank <= 10) {
21
- rrfWeight = BLEND_MID_RRF; // 0.60
22
- } else {
23
- rrfWeight = BLEND_TAIL_RRF; // 0.40
24
- }
25
-
26
- // Normalize RRF score to [0,1] range for blending
27
- // (RRF scores vary based on number of lists, so normalize by max)
28
  const blendedScore =
29
- rrfWeight * result.score + (1 - rrfWeight) * rerankScore;
30
 
31
  return {
32
  ...result,
 
1
  import type { RRFResult, RerankedResult, FinalResult } from "../types";
 
2
 
3
+ const BLEND_RRF_WEIGHT = 0.8; // uniform 80% RRF / 20% reranker
4
+
5
+ // Blend RRF score with reranker score using uniform weights.
6
+ // Browser-sized reranker models are noisy — position-aware weights
7
+ // (which gave tail ranks MORE reranker influence) caused irrelevant
8
+ // docs to leapfrog relevant ones.
9
  export function blendScores(
10
  rrfResults: RRFResult[],
11
  rerankScores: Map<string, number>, // docId -> rerank score
12
  ): FinalResult[] {
13
+ // Normalize RRF scores to [0,1] range so they're comparable with reranker scores (0-1)
14
+ const maxRRF = Math.max(...rrfResults.map(r => r.score), 1e-9);
15
+
16
+ const blended: RerankedResult[] = rrfResults.map((result) => {
17
  const rerankScore = rerankScores.get(result.docId) ?? 0;
18
+ const normalizedRRF = result.score / maxRRF;
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  const blendedScore =
21
+ BLEND_RRF_WEIGHT * normalizedRRF + (1 - BLEND_RRF_WEIGHT) * rerankScore;
22
 
23
  return {
24
  ...result,
src/pipeline/expansion.ts CHANGED
@@ -1,20 +1,21 @@
1
- import { getExpansionPipeline } from "./models";
 
2
  import type { ExpandedQuery } from "../types";
3
 
4
  // The fine-tuned expansion model prompt format (from QMD)
5
  const EXPANSION_PROMPT = (query: string) =>
6
  `/no_think Expand this search query: ${query}`;
7
 
8
- // Parse the model's output text into structured ExpandedQuery
9
- function parseExpansionOutput(text: string): ExpandedQuery {
10
- // The model outputs lines like:
11
- // lex: keyword1, keyword2, keyword3
12
- // vec: A semantically rich sentence about the topic
13
- // hyde: This document discusses the implementation of...
14
- //
15
- // Parse each line by prefix. Handle multiple vec lines.
16
- // Fallback: if parsing fails, use the raw text as all three variants.
17
 
 
 
18
  const lines = text.trim().split("\n");
19
  let lex = "";
20
  const vec: string[] = [];
@@ -31,38 +32,147 @@ function parseExpansionOutput(text: string): ExpandedQuery {
31
  }
32
  }
33
 
34
- // Fallback: if we couldn't parse any variant, use raw text
35
- if (!lex && vec.length === 0 && !hyde) {
36
- return { lex: text, vec: [text], hyde: text };
37
- }
38
-
39
  return {
40
- lex: lex || text,
41
- vec: vec.length > 0 ? vec : [text],
42
- hyde: hyde || text,
43
  };
44
  }
45
 
46
- // Run query expansion using the loaded model
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  export async function expandQuery(query: string): Promise<ExpandedQuery> {
48
- const pipe = getExpansionPipeline();
49
- if (!pipe) throw new Error("Expansion model not loaded");
 
50
 
51
  const prompt = EXPANSION_PROMPT(query);
52
- const output = await pipe(prompt, {
53
- max_new_tokens: 256,
54
- do_sample: false,
55
- });
56
 
57
- // pipeline("text-generation") returns array of { generated_text: string }
58
- const generated = (output as Array<{ generated_text: string }>)[0]
59
- .generated_text;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
- // The generated text includes the prompt - strip it
62
- const responseText =
63
- typeof generated === "string" ? generated.slice(prompt.length) : generated;
64
 
65
- return parseExpansionOutput(String(responseText));
66
  }
67
 
68
  export { parseExpansionOutput }; // export for testing
 
1
+ import { Tensor } from "@huggingface/transformers";
2
+ import { getExpansionModel, getExpansionTokenizer } from "./models";
3
  import type { ExpandedQuery } from "../types";
4
 
5
  // The fine-tuned expansion model prompt format (from QMD)
6
  const EXPANSION_PROMPT = (query: string) =>
7
  `/no_think Expand this search query: ${query}`;
8
 
9
+ const MAX_NEW_TOKENS = 128;
10
+
11
+ // Sampling parameters matches Qwen3 recommended settings from QMD's node-llama-cpp impl.
12
+ // Greedy decoding (temp=0) causes degeneration with Qwen3; sampling is required.
13
+ const TEMPERATURE = 0.7;
14
+ const TOP_K = 20;
15
+ const TOP_P = 0.8;
 
 
16
 
17
+ // Parse the model's output text into structured ExpandedQuery
18
+ function parseExpansionOutput(text: string, query: string): ExpandedQuery {
19
  const lines = text.trim().split("\n");
20
  let lex = "";
21
  const vec: string[] = [];
 
32
  }
33
  }
34
 
35
+ // Fallback to raw query if parsing failed (matches QMD's fallback behavior)
 
 
 
 
36
  return {
37
+ lex: lex || query,
38
+ vec: vec.length > 0 ? vec : [query],
39
+ hyde: hyde || `Information about ${query}`,
40
  };
41
  }
42
 
43
+ // Sample from logits with temperature, top-k, and top-p (nucleus sampling)
44
+ function sampleToken(logitsData: Float32Array | Float64Array, vocabSize: number): number {
45
+ // Apply temperature
46
+ const scaled = new Float64Array(vocabSize);
47
+ for (let i = 0; i < vocabSize; i++) {
48
+ scaled[i] = logitsData[i] / TEMPERATURE;
49
+ }
50
+
51
+ // Sort indices by logit value (descending) for top-k
52
+ const indices = Array.from({ length: vocabSize }, (_, i) => i);
53
+ indices.sort((a, b) => scaled[b] - scaled[a]);
54
+
55
+ // Top-k: keep only top-k candidates
56
+ const topKIndices = indices.slice(0, TOP_K);
57
+
58
+ // Softmax over top-k
59
+ let maxLogit = scaled[topKIndices[0]];
60
+ const exps = topKIndices.map(i => Math.exp(scaled[i] - maxLogit));
61
+ const sumExp = exps.reduce((a, b) => a + b, 0);
62
+ const probs = exps.map(e => e / sumExp);
63
+
64
+ // Top-p: find cutoff
65
+ let cumProb = 0;
66
+ let cutoff = probs.length;
67
+ for (let i = 0; i < probs.length; i++) {
68
+ cumProb += probs[i];
69
+ if (cumProb >= TOP_P) {
70
+ cutoff = i + 1;
71
+ break;
72
+ }
73
+ }
74
+
75
+ // Renormalize after top-p
76
+ const finalIndices = topKIndices.slice(0, cutoff);
77
+ const finalProbs = probs.slice(0, cutoff);
78
+ const finalSum = finalProbs.reduce((a, b) => a + b, 0);
79
+
80
+ // Sample
81
+ let r = Math.random() * finalSum;
82
+ for (let i = 0; i < finalIndices.length; i++) {
83
+ r -= finalProbs[i];
84
+ if (r <= 0) return finalIndices[i];
85
+ }
86
+ return finalIndices[finalIndices.length - 1];
87
+ }
88
+
89
+ // Manual autoregressive generation — bypasses Transformers.js generate()
90
+ // which breaks when the ONNX model was exported without KV cache tensors.
91
+ async function manualGenerate(
92
+ model: any,
93
+ inputIds: bigint[],
94
+ eosTokenId: number,
95
+ tokenizer: any,
96
+ ): Promise<bigint[]> {
97
+ const generated: bigint[] = [...inputIds];
98
+ const newTokens: bigint[] = [];
99
+
100
+ for (let i = 0; i < MAX_NEW_TOKENS; i++) {
101
+ const idsTensor = new Tensor("int64", BigInt64Array.from(generated), [1, generated.length]);
102
+ const maskTensor = new Tensor("int64", new BigInt64Array(generated.length).fill(1n), [1, generated.length]);
103
+
104
+ const output = await model({ input_ids: idsTensor, attention_mask: maskTensor });
105
+
106
+ const logits = output.logits;
107
+ const vocabSize = logits.dims[2];
108
+ const lastTokenLogits = logits.data.slice(
109
+ (generated.length - 1) * vocabSize,
110
+ generated.length * vocabSize,
111
+ );
112
+
113
+ const nextToken = sampleToken(lastTokenLogits, vocabSize);
114
+
115
+ if (nextToken === eosTokenId) break;
116
+
117
+ const tokenBigInt = BigInt(nextToken);
118
+ generated.push(tokenBigInt);
119
+ newTokens.push(tokenBigInt);
120
+
121
+ // Early stop: detect degeneration (same token repeated 4+ times)
122
+ if (
123
+ newTokens.length >= 4 &&
124
+ newTokens[newTokens.length - 1] === newTokens[newTokens.length - 2] &&
125
+ newTokens[newTokens.length - 2] === newTokens[newTokens.length - 3] &&
126
+ newTokens[newTokens.length - 3] === newTokens[newTokens.length - 4]
127
+ ) {
128
+ generated.splice(-4);
129
+ break;
130
+ }
131
+
132
+ // Early stop: if we see all three fields or double newline, done
133
+ if (newTokens.length >= 10 && newTokens.length % 5 === 0) {
134
+ const partial = tokenizer.decode(newTokens, { skip_special_tokens: true });
135
+ if (partial.includes("\n\n") || (partial.toLowerCase().includes("hyde:") && partial.split("\n").length >= 3)) {
136
+ break;
137
+ }
138
+ }
139
+ }
140
+
141
+ return generated;
142
+ }
143
+
144
+ // Run query expansion using the loaded model (no KV cache — model exported without it)
145
  export async function expandQuery(query: string): Promise<ExpandedQuery> {
146
+ const model = getExpansionModel();
147
+ const tokenizer = getExpansionTokenizer();
148
+ if (!model || !tokenizer) throw new Error("Expansion model not loaded");
149
 
150
  const prompt = EXPANSION_PROMPT(query);
 
 
 
 
151
 
152
+ // Apply chat template then tokenize (matches eval.py: tokenize=False + separate tokenize)
153
+ const chatPrompt = tokenizer.apply_chat_template(
154
+ [{ role: "user", content: prompt }],
155
+ { add_generation_prompt: true, tokenize: false },
156
+ ) as string;
157
+ const inputs = tokenizer(chatPrompt, { return_tensor: true });
158
+ const inputIds = Array.from(inputs.input_ids.data as BigInt64Array);
159
+
160
+ const eosTokenId = tokenizer.model?.config?.eos_token_id
161
+ ?? (tokenizer as any).eos_token_id
162
+ ?? 151643; // Qwen default
163
+
164
+ const allIds = await manualGenerate(model, inputIds, eosTokenId, tokenizer);
165
+
166
+ // Decode only the newly generated tokens (skip the prompt)
167
+ const newIds = allIds.slice(inputIds.length);
168
+ let responseText = tokenizer.decode(newIds, {
169
+ skip_special_tokens: true,
170
+ });
171
 
172
+ // Strip <think>...</think> blocks (model may emit reasoning despite /no_think)
173
+ responseText = responseText.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
 
174
 
175
+ return parseExpansionOutput(responseText, query);
176
  }
177
 
178
  export { parseExpansionOutput }; // export for testing
src/pipeline/models.ts CHANGED
@@ -3,7 +3,6 @@ import {
3
  AutoTokenizer,
4
  AutoModelForCausalLM,
5
  type FeatureExtractionPipeline,
6
- type TextGenerationPipeline,
7
  type PreTrainedTokenizer,
8
  type PreTrainedModel,
9
  type ProgressInfo,
@@ -15,7 +14,6 @@ type ProgressCallback = (state: ModelState) => void;
15
 
16
  // Singleton model instances
17
  let embeddingPipeline: FeatureExtractionPipeline | null = null;
18
- let generationPipeline: TextGenerationPipeline | null = null;
19
 
20
  // Reranker uses AutoModel + AutoTokenizer (not a pipeline)
21
  let rerankerModel: PreTrainedModel | null = null;
@@ -23,6 +21,10 @@ let rerankerTokenizer: PreTrainedTokenizer | null = null;
23
  let rerankerTokenYes = -1;
24
  let rerankerTokenNo = -1;
25
 
 
 
 
 
26
  /** Check whether WebGPU is available in this browser. */
27
  export async function checkWebGPU(): Promise<boolean> {
28
  if (!navigator.gpu) return false;
@@ -153,19 +155,32 @@ export async function loadRerankerModel(
153
  export async function loadExpansionModel(
154
  onProgress?: ProgressCallback,
155
  ): Promise<void> {
156
- if (generationPipeline) return;
157
  const name = "expansion";
158
  onProgress?.({ name, status: "pending", progress: 0 });
159
  try {
160
- generationPipeline = await pipeline(
161
- "text-generation",
162
- MODEL_EXPANSION,
163
- {
164
- dtype: "q4",
165
- device: "webgpu",
166
- progress_callback: makeProgressHandler(name, onProgress),
167
- },
168
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  onProgress?.({ name, status: "ready", progress: 1 });
170
  } catch (err) {
171
  onProgress?.({
@@ -237,8 +252,12 @@ export function getRerankerTokenIds(): { yes: number; no: number } {
237
  return { yes: rerankerTokenYes, no: rerankerTokenNo };
238
  }
239
 
240
- export function getExpansionPipeline(): TextGenerationPipeline | null {
241
- return generationPipeline;
 
 
 
 
242
  }
243
 
244
  export function isAllModelsReady(): boolean {
@@ -247,5 +266,5 @@ export function isAllModelsReady(): boolean {
247
  }
248
 
249
  export function isExpansionReady(): boolean {
250
- return generationPipeline !== null;
251
  }
 
3
  AutoTokenizer,
4
  AutoModelForCausalLM,
5
  type FeatureExtractionPipeline,
 
6
  type PreTrainedTokenizer,
7
  type PreTrainedModel,
8
  type ProgressInfo,
 
14
 
15
  // Singleton model instances
16
  let embeddingPipeline: FeatureExtractionPipeline | null = null;
 
17
 
18
  // Reranker uses AutoModel + AutoTokenizer (not a pipeline)
19
  let rerankerModel: PreTrainedModel | null = null;
 
21
  let rerankerTokenYes = -1;
22
  let rerankerTokenNo = -1;
23
 
24
+ // Expansion uses AutoModel + AutoTokenizer (model was exported without KV cache)
25
+ let expansionModel: PreTrainedModel | null = null;
26
+ let expansionTokenizer: PreTrainedTokenizer | null = null;
27
+
28
  /** Check whether WebGPU is available in this browser. */
29
  export async function checkWebGPU(): Promise<boolean> {
30
  if (!navigator.gpu) return false;
 
155
  export async function loadExpansionModel(
156
  onProgress?: ProgressCallback,
157
  ): Promise<void> {
158
+ if (expansionModel) return;
159
  const name = "expansion";
160
  onProgress?.({ name, status: "pending", progress: 0 });
161
  try {
162
+ const progressHandler = makeProgressHandler(name, onProgress);
163
+
164
+ expansionTokenizer = await AutoTokenizer.from_pretrained(MODEL_EXPANSION, {
165
+ progress_callback: progressHandler,
166
+ });
167
+
168
+ // The HF repo has chat_template.jinja but it's not in tokenizer_config.json,
169
+ // so set the Qwen ChatML template manually.
170
+ if (!expansionTokenizer.chat_template) {
171
+ expansionTokenizer.chat_template =
172
+ "{% for message in messages %}" +
173
+ "<|im_start|>{{ message.role }}\n{{ message.content }}<|im_end|>\n" +
174
+ "{% endfor %}" +
175
+ "{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}";
176
+ }
177
+
178
+ expansionModel = await AutoModelForCausalLM.from_pretrained(MODEL_EXPANSION, {
179
+ dtype: "q4",
180
+ device: "webgpu",
181
+ progress_callback: progressHandler,
182
+ });
183
+
184
  onProgress?.({ name, status: "ready", progress: 1 });
185
  } catch (err) {
186
  onProgress?.({
 
252
  return { yes: rerankerTokenYes, no: rerankerTokenNo };
253
  }
254
 
255
+ export function getExpansionModel(): PreTrainedModel | null {
256
+ return expansionModel;
257
+ }
258
+
259
+ export function getExpansionTokenizer(): PreTrainedTokenizer | null {
260
+ return expansionTokenizer;
261
  }
262
 
263
  export function isAllModelsReady(): boolean {
 
266
  }
267
 
268
  export function isExpansionReady(): boolean {
269
+ return expansionModel !== null;
270
  }