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 +76 -0
- public/eval-docs/history-of-coffee.md +21 -0
- src/App.tsx +62 -4
- src/components/DocumentManager.tsx +31 -27
- src/components/ExpansionColumn.tsx +16 -13
- src/components/FusionColumn.tsx +24 -21
- src/components/ModelStatus.tsx +22 -11
- src/components/PipelineView.tsx +112 -25
- src/components/QueryInput.tsx +10 -10
- src/components/ResultCard.tsx +10 -10
- src/components/SearchColumn.tsx +21 -18
- src/constants.ts +0 -4
- src/pipeline/blend.test.ts +30 -88
- src/pipeline/blend.ts +12 -20
- src/pipeline/expansion.ts +142 -32
- src/pipeline/models.ts +34 -15
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 |
-
<
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
</p>
|
| 141 |
</header>
|
| 142 |
|
| 143 |
<ModelStatus models={models} />
|
| 144 |
|
| 145 |
{indexing && (
|
| 146 |
-
<div style={{ padding: '0.5rem 1rem', background: '
|
| 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: '
|
| 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: '
|
| 34 |
borderRadius: '10px',
|
| 35 |
padding: '1.5rem',
|
| 36 |
width: '90%',
|
| 37 |
maxWidth: '560px',
|
| 38 |
-
boxShadow: '0 8px 32px
|
| 39 |
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
|
|
| 40 |
}}>
|
| 41 |
-
<h3 style={{ margin: '0 0 1rem 0', fontSize: '1rem', color: '
|
| 42 |
Paste Document
|
| 43 |
</h3>
|
| 44 |
|
| 45 |
<div style={{ marginBottom: '0.75rem' }}>
|
| 46 |
-
<label style={{ fontSize: '0.8rem', color: '
|
| 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
|
| 59 |
borderRadius: '5px',
|
| 60 |
boxSizing: 'border-box',
|
|
|
|
|
|
|
| 61 |
}}
|
| 62 |
/>
|
| 63 |
</div>
|
| 64 |
|
| 65 |
<div style={{ marginBottom: '1rem' }}>
|
| 66 |
-
<label style={{ fontSize: '0.8rem', color: '
|
| 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
|
| 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: '
|
| 96 |
-
color: '
|
| 97 |
-
border: '1px solid
|
| 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' : '
|
| 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: '
|
| 144 |
-
border: '1px solid
|
| 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: '
|
| 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: '
|
| 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: '
|
| 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: '
|
| 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: '
|
| 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: '
|
| 230 |
-
border: '1px solid
|
| 231 |
borderRadius: '5px',
|
| 232 |
marginBottom: '0.3rem',
|
| 233 |
gap: '0.5rem',
|
| 234 |
}}>
|
| 235 |
<span style={{
|
| 236 |
fontSize: '0.75rem',
|
| 237 |
-
color: '
|
| 238 |
flexShrink: 0,
|
| 239 |
}}>
|
| 240 |
-
|
| 241 |
</span>
|
| 242 |
<span style={{
|
| 243 |
flex: 1,
|
| 244 |
fontSize: '0.8rem',
|
| 245 |
fontWeight: 500,
|
| 246 |
-
color: '
|
| 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: '
|
| 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
|
| 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: '
|
| 32 |
-
border: '1px solid
|
| 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: '
|
| 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.
|
| 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: '#
|
| 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: '
|
| 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: '
|
| 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: '
|
| 117 |
-
border: '1px solid
|
| 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
|
| 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: '
|
| 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: '
|
| 58 |
-
border: '1px solid
|
| 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: '
|
| 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: '
|
| 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: '
|
| 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: '
|
| 116 |
-
border: '1px solid
|
| 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: '
|
| 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: '
|
| 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: '
|
| 156 |
-
border: '1px solid
|
| 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.
|
| 196 |
<h3 style={{
|
| 197 |
margin: 0,
|
| 198 |
fontSize: '0.8rem',
|
| 199 |
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 200 |
fontWeight: 700,
|
| 201 |
-
color: '#
|
| 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: '
|
| 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: '
|
| 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: '
|
| 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: '
|
| 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: '
|
| 51 |
-
border: '1px solid
|
| 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: '
|
| 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' }}>
|
| 74 |
)}
|
| 75 |
{model.status === 'error' && (
|
| 76 |
-
<span style={{ fontSize: '0.85rem' }}>
|
| 77 |
)}
|
| 78 |
{STATUS_LABEL[model.status]}
|
| 79 |
{showProgress && (
|
| 80 |
-
<span style={{ color: '
|
| 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: '
|
| 108 |
-
border: '1px solid
|
| 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: '
|
| 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: '
|
| 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 |
-
{
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
];
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
function QueryColumn({ query }: { query?: string }) {
|
| 27 |
return (
|
| 28 |
<div>
|
| 29 |
-
<
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
color: '#1a237e',
|
| 35 |
-
textTransform: 'uppercase',
|
| 36 |
-
letterSpacing: '0.05em',
|
| 37 |
}}>
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
{query ? (
|
| 41 |
<div style={{
|
| 42 |
padding: '0.65rem 0.85rem',
|
| 43 |
-
background: '
|
| 44 |
-
border: '1px solid
|
| 45 |
borderRadius: '6px',
|
| 46 |
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 47 |
fontSize: '0.85rem',
|
| 48 |
-
color: '
|
| 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: '
|
| 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
|
| 85 |
-
boxShadow: '0 2px 12px
|
| 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
|
| 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
|
| 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
|
| 38 |
borderRadius: '6px',
|
| 39 |
-
background: disabled ? '
|
| 40 |
-
color: disabled ? '
|
| 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,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() ? '
|
| 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: '
|
| 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: '
|
| 81 |
-
color: disabled ? '
|
| 82 |
-
border: '1px solid
|
| 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 ? '
|
| 14 |
-
const color = pct >= 80 ? '#2e7d32' : pct >= 50 ? '#f57f17' : '
|
| 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) + '
|
| 46 |
|
| 47 |
return (
|
| 48 |
<div
|
| 49 |
onClick={handleToggle}
|
| 50 |
style={{
|
| 51 |
padding: '0.65rem 0.85rem',
|
| 52 |
-
background: '
|
| 53 |
-
border: '1px solid
|
| 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
|
| 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: '
|
| 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: '
|
| 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: '
|
| 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
|
| 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' ? '
|
| 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: '
|
| 58 |
-
border: '1px solid
|
| 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
|
| 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: '
|
| 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: '
|
| 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: '
|
| 88 |
lineHeight: 1.55,
|
| 89 |
whiteSpace: 'pre-wrap',
|
| 90 |
wordBreak: 'break-word',
|
| 91 |
-
borderTop: '1px solid
|
| 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: '
|
| 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: '
|
| 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.
|
| 141 |
<h3 style={{
|
| 142 |
margin: 0,
|
| 143 |
fontSize: '0.8rem',
|
| 144 |
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 145 |
fontWeight: 700,
|
| 146 |
-
color: '#
|
| 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: '
|
| 157 |
-
Awaiting expansion
|
| 158 |
</p>
|
| 159 |
)}
|
| 160 |
|
| 161 |
{isRunning && (
|
| 162 |
-
<p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.8rem', color: '
|
| 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 |
-
|
|
|
|
| 5 |
|
| 6 |
// ---------------------------------------------------------------------------
|
| 7 |
// Helpers
|
|
@@ -29,7 +30,7 @@ describe("blendScores", () => {
|
|
| 29 |
expect(blendScores([], new Map())).toEqual([]);
|
| 30 |
});
|
| 31 |
|
| 32 |
-
it("
|
| 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 |
-
//
|
| 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 |
-
|
| 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>();
|
| 82 |
const results = blendScores(rrfResults, rerankScores);
|
| 83 |
-
// score = 0.
|
| 84 |
-
expect(results[0].score).toBeCloseTo(
|
| 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.
|
| 117 |
-
// doc2: 0.
|
| 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),
|
| 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("
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 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 |
-
//
|
| 183 |
-
|
| 184 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
export function blendScores(
|
| 6 |
rrfResults: RRFResult[],
|
| 7 |
rerankScores: Map<string, number>, // docId -> rerank score
|
| 8 |
): FinalResult[] {
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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 {
|
|
|
|
| 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 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 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
|
| 35 |
-
if (!lex && vec.length === 0 && !hyde) {
|
| 36 |
-
return { lex: text, vec: [text], hyde: text };
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
return {
|
| 40 |
-
lex: lex ||
|
| 41 |
-
vec: vec.length > 0 ? vec : [
|
| 42 |
-
hyde: hyde ||
|
| 43 |
};
|
| 44 |
}
|
| 45 |
|
| 46 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
export async function expandQuery(query: string): Promise<ExpandedQuery> {
|
| 48 |
-
const
|
| 49 |
-
|
|
|
|
| 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 |
-
//
|
| 58 |
-
const
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
//
|
| 62 |
-
|
| 63 |
-
typeof generated === "string" ? generated.slice(prompt.length) : generated;
|
| 64 |
|
| 65 |
-
return parseExpansionOutput(
|
| 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 (
|
| 157 |
const name = "expansion";
|
| 158 |
onProgress?.({ name, status: "pending", progress: 0 });
|
| 159 |
try {
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 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
|
| 241 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
}
|
| 243 |
|
| 244 |
export function isAllModelsReady(): boolean {
|
|
@@ -247,5 +266,5 @@ export function isAllModelsReady(): boolean {
|
|
| 247 |
}
|
| 248 |
|
| 249 |
export function isExpansionReady(): boolean {
|
| 250 |
-
return
|
| 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 |
}
|