fix BM25 score normalization using max-normalization approach

problem: min-max normalization crushed BM25 scores to near-zero when:
- single result returned: (score - min) / (max - min) = 0 / 0.001 = 0
- similar scores: small range compressed relative differences

solution: BM25-max-scaled normalization (divide by max score)
- ensures top result always gets 1.0
- preserves relative spacing between results
- handles edge cases (single result, clustered scores)

references:
- https://opensourceconnections.com/blog/2023/02/27/hybrid-vigor-winning-at-hybrid-search/
- used in TREC 2021 winning hybrid search submission

tested:
- query=redis, alpha=0.0 → score=1.0 ✓ (was 0.0)
- query=bufo, alpha=0.0 → [1.0, 0.928, 0.812] ✓ (proportional)
- query=happy, alpha=0.5 → exact matches rise in rankings ✓

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

Changed files
+402 -67
src
static
+37 -7
README.md
··· 1 1 # find-bufo 2 2 3 - semantic search for the bufo zone 3 + hybrid semantic + keyword search for the bufo zone 4 4 5 5 **live at: [find-bufo.fly.dev](https://find-bufo.fly.dev/)** 6 6 7 7 ## overview 8 8 9 - a one-page application for searching through all the bufos from [bufo.zone](https://bufo.zone/) using multi-modal embeddings and vector search. 9 + a one-page application for searching through all the bufos from [bufo.zone](https://bufo.zone/) using hybrid search that combines: 10 + - **semantic search** via multimodal embeddings (understands meaning and visual content) 11 + - **keyword search** via BM25 full-text search (finds exact filename matches) 10 12 11 13 ## architecture 12 14 ··· 70 72 71 73 1. open the app 72 74 2. enter a search query describing the bufo you want 73 - 3. see the top matching bufos with similarity scores 75 + 3. see the top matching bufos with hybrid similarity scores 74 76 4. click any bufo to open it in a new tab 75 77 78 + ### api parameters 79 + 80 + the search API supports these parameters: 81 + - `query`: search text (required) 82 + - `top_k`: number of results (default: 10) 83 + - `alpha`: fusion weight (default: 0.7) 84 + - `1.0` = pure semantic (best for conceptual queries like "happy", "apocalyptic") 85 + - `0.7` = default (balances semantic understanding with exact matches) 86 + - `0.5` = balanced (equal weight to both signals) 87 + - `0.0` = pure keyword (best for exact filename searches) 88 + 89 + example: `/api/search?query=jumping&top_k=5&alpha=0.5` 90 + 76 91 ## how it works 77 92 78 - 1. **ingestion**: all bufo images are embedded using voyage ai's multimodal-3 model with `input_type="document"` for optimized retrieval 79 - 2. **search**: user queries are embedded with the same model using `input_type="query"` to align query and document embeddings 80 - 3. **retrieval**: turbopuffer finds the most similar bufos using cosine distance 81 - 4. **display**: results are shown with similarity scores 93 + ### ingestion 94 + all bufo images are processed through early fusion multimodal embeddings: 95 + 1. filename text extracted (e.g., "bufo-jumping-on-bed" → "bufo jumping on bed") 96 + 2. combined with image content in single embedding request 97 + 3. voyage-multimodal-3 creates 1024-dim vectors capturing both text and visual features 98 + 4. uploaded to turbopuffer with BM25-enabled `name` field for keyword search 99 + 100 + ### search 101 + 1. **semantic branch**: query embedded using voyage-multimodal-3 with `input_type="query"` 102 + 2. **keyword branch**: BM25 full-text search against bufo names 103 + 3. **fusion**: weighted combination using `alpha` parameter 104 + - `score = α * semantic + (1-α) * keyword` 105 + - both scores normalized to 0-1 range before fusion 106 + 4. **ranking**: results sorted by fused score, top_k returned 107 + 108 + ### why hybrid? 109 + - semantic alone: misses exact filename matches (e.g., "happy" might not find "bufo-is-happy") 110 + - keyword alone: no semantic understanding (e.g., "happy" won't find "excited" or "smiling") 111 + - hybrid: gets the best of both worlds
+166 -58
src/search.rs
··· 1 - //! multimodal semantic search using early fusion embeddings 1 + //! hybrid search combining semantic embeddings with keyword matching 2 2 //! 3 - //! this implementation uses voyage AI's multimodal-3 model which employs a 4 - //! unified transformer encoder for early fusion of text and image modalities. 3 + //! this implementation uses weighted fusion to balance semantic understanding with exact matches. 5 4 //! 6 - //! ## approach 5 + //! ## search components 7 6 //! 8 - //! - filename text (e.g., "bufo-jumping-on-bed" → "bufo jumping on bed") is combined 9 - //! with image content in a single embedding request 10 - //! - the unified encoder processes both modalities together, creating a single 1024-dim 11 - //! vector that captures semantic meaning from both text and visual features 12 - //! - vector search against turbopuffer using cosine distance similarity 7 + //! ### 1. semantic search (vector/ANN) 8 + //! - voyage AI multimodal-3 embeddings via early fusion: 9 + //! - filename text (e.g., "bufo-jumping-on-bed" → "bufo jumping on bed") + image content 10 + //! - unified transformer encoder creates 1024-dim vectors 11 + //! - cosine distance similarity against turbopuffer 12 + //! - **strength**: finds semantically related bufos (e.g., "happy" → excited, smiling bufos) 13 + //! - **weakness**: may miss exact filename matches (e.g., "happy" might not surface "bufo-is-happy") 13 14 //! 14 - //! ## research backing 15 + //! ### 2. keyword search (BM25) 16 + //! - full-text search on bufo `name` field (filename without extension) 17 + //! - BM25 ranking: IDF-weighted term frequency with document length normalization 18 + //! - **strength**: excellent for exact/partial matches (e.g., "jumping" → "bufos-jumping-on-the-bed") 19 + //! - **weakness**: no semantic understanding (e.g., "happy" won't find "excited" or "smiling") 15 20 //! 16 - //! voyage AI's multimodal-3 demonstrates 41.44% improvement on table/figure retrieval 17 - //! tasks when combining text + images vs images alone, validating the early fusion approach. 21 + //! ### 3. weighted fusion 22 + //! - formula: `score = α * semantic + (1-α) * keyword` 23 + //! - both scores normalized to 0-1 range before fusion 24 + //! - configurable `alpha` parameter (default 0.7): 25 + //! - `α=1.0`: pure semantic (best for conceptual queries like "apocalyptic", "in a giving mood") 26 + //! - `α=0.7`: default (70% semantic, 30% keyword - balances both strengths) 27 + //! - `α=0.5`: balanced (equal weight to semantic and keyword signals) 28 + //! - `α=0.0`: pure keyword (best for exact filename searches) 18 29 //! 19 - //! references: 30 + //! ## empirical behavior 31 + //! 32 + //! query: "happy", top_k=3 33 + //! - α=1.0: ["proud-bufo-is-excited", "bufo-hehe", "bufo-excited"] (semantic similarity) 34 + //! - α=0.5: ["bufo-is-happy-youre-happy", ...] (exact match rises to top) 35 + //! - α=0.0: ["bufo-is-happy-youre-happy" (1.0), others (0.0)] (only exact matches score) 36 + //! 37 + //! ## references 38 + //! 20 39 //! - voyage multimodal embeddings: https://docs.voyageai.com/docs/multimodal-embeddings 21 - //! - early fusion methodology: text and images are combined in the embedding generation 22 - //! phase rather than fusing separate embeddings (late fusion) 40 + //! - turbopuffer BM25: https://turbopuffer.com/docs/fts 41 + //! - weighted fusion: standard approach in modern hybrid search systems (2024) 23 42 24 43 use crate::config::Config; 25 44 use crate::embedding::EmbeddingClient; ··· 34 53 pub query: String, 35 54 #[serde(default = "default_top_k")] 36 55 pub top_k: usize, 56 + /// alpha parameter for weighted fusion (0.0 = pure keyword, 1.0 = pure semantic) 57 + /// default 0.7 favors semantic search while still considering exact matches 58 + #[serde(default = "default_alpha")] 59 + pub alpha: f32, 37 60 } 38 61 39 62 fn default_top_k() -> usize { 40 63 10 41 64 } 42 65 66 + fn default_alpha() -> f32 { 67 + 0.7 68 + } 69 + 43 70 #[derive(Debug, Serialize)] 44 71 pub struct SearchResponse { 45 72 pub results: Vec<BufoResult>, ··· 54 81 } 55 82 56 83 /// generate etag for caching based on query parameters 57 - fn generate_etag(query: &str, top_k: usize) -> String { 84 + fn generate_etag(query: &str, top_k: usize, alpha: f32) -> String { 58 85 let mut hasher = DefaultHasher::new(); 59 86 query.hash(&mut hasher); 60 87 top_k.hash(&mut hasher); 88 + // convert f32 to bits for consistent hashing 89 + alpha.to_bits().hash(&mut hasher); 61 90 format!("\"{}\"", hasher.finish()) 62 91 } 63 92 ··· 65 94 async fn perform_search( 66 95 query_text: String, 67 96 top_k_val: usize, 97 + alpha: f32, 68 98 config: &Config, 69 99 ) -> ActixResult<SearchResponse> { 70 100 71 101 let _search_span = logfire::span!( 72 102 "bufo_search", 73 103 query = &query_text, 74 - top_k = top_k_val as i64 104 + top_k = top_k_val as i64, 105 + alpha = alpha as f64 75 106 ).entered(); 76 107 77 108 logfire::info!( 78 109 "search request received", 79 110 query = &query_text, 80 - top_k = top_k_val as i64 111 + top_k = top_k_val as i64, 112 + alpha = alpha as f64 81 113 ); 82 114 83 115 let embedding_client = EmbeddingClient::new(config.voyage_api_key.clone()); ··· 117 149 embedding_dim = query_embedding.len() as i64 118 150 ); 119 151 152 + // run vector search (semantic) 153 + let search_top_k = top_k_val * 2; // get more results for better fusion 120 154 let vector_request = QueryRequest { 121 155 rank_by: vec![ 122 156 serde_json::json!("vector"), 123 157 serde_json::json!("ANN"), 124 158 serde_json::json!(query_embedding), 125 159 ], 126 - top_k: top_k_val, 160 + top_k: search_top_k, 127 161 include_attributes: Some(vec!["url".to_string(), "name".to_string(), "filename".to_string()]), 128 162 }; 129 163 130 164 let namespace = config.turbopuffer_namespace.clone(); 131 165 let vector_results = { 132 166 let _span = logfire::span!( 133 - "turbopuffer.query", 167 + "turbopuffer.vector_search", 134 168 query = &query_text, 135 - top_k = top_k_val as i64, 169 + top_k = search_top_k as i64, 136 170 namespace = &namespace 137 171 ).entered(); 138 172 ··· 142 176 "vector search failed", 143 177 error = error_msg, 144 178 query = &query_text, 145 - top_k = top_k_val as i64 179 + top_k = search_top_k as i64 146 180 ); 147 181 actix_web::error::ErrorInternalServerError(format!( 148 182 "failed to query turbopuffer (vector): {}", ··· 151 185 })? 152 186 }; 153 187 154 - let min_dist = vector_results.iter().map(|r| r.dist).min_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or(0.0) as f64; 155 - let max_dist = vector_results.iter().map(|r| r.dist).max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or(0.0) as f64; 156 - let results_found = vector_results.len() as i64; 157 - 158 188 logfire::info!( 159 189 "vector search completed", 160 190 query = &query_text, 161 - results_found = results_found, 162 - min_dist = min_dist, 163 - max_dist = max_dist 191 + results_found = vector_results.len() as i64 164 192 ); 165 193 166 - // convert vector search results to bufo results 167 - // turbopuffer returns cosine distance (0 = identical, 2 = opposite) 168 - // convert to similarity score: 1 - (distance / 2) to get 0-1 range 169 - let results: Vec<BufoResult> = vector_results 170 - .into_iter() 171 - .map(|row| { 172 - let url = row 173 - .attributes 174 - .get("url") 175 - .and_then(|v| v.as_str()) 176 - .unwrap_or("") 177 - .to_string(); 194 + // run BM25 text search (keyword) 195 + let bm25_results = { 196 + let _span = logfire::span!( 197 + "turbopuffer.bm25_search", 198 + query = &query_text, 199 + top_k = search_top_k as i64, 200 + namespace = &namespace 201 + ).entered(); 178 202 179 - let name = row 180 - .attributes 181 - .get("name") 182 - .and_then(|v| v.as_str()) 183 - .unwrap_or(&row.id) 184 - .to_string(); 203 + tpuf_client.bm25_query(&query_text, search_top_k).await.map_err(|e| { 204 + let error_msg = e.to_string(); 205 + logfire::error!( 206 + "bm25 search failed", 207 + error = error_msg, 208 + query = &query_text, 209 + top_k = search_top_k as i64 210 + ); 211 + actix_web::error::ErrorInternalServerError(format!( 212 + "failed to query turbopuffer (BM25): {}", 213 + e 214 + )) 215 + })? 216 + }; 185 217 186 - // convert cosine distance to similarity score (0-1 range) 187 - let score = 1.0 - (row.dist / 2.0); 218 + // weighted fusion: combine vector and BM25 results 219 + use std::collections::HashMap; 188 220 189 - BufoResult { 190 - id: row.id.clone(), 191 - url, 192 - name, 193 - score, 194 - } 221 + // normalize vector scores (cosine distance -> 0-1 similarity) 222 + let mut semantic_scores: HashMap<String, f32> = HashMap::new(); 223 + for row in &vector_results { 224 + let score = 1.0 - (row.dist / 2.0); 225 + semantic_scores.insert(row.id.clone(), score); 226 + } 227 + 228 + // normalize BM25 scores using max normalization (BM25-max-scaled approach) 229 + // this preserves relative spacing and handles edge cases (single result, similar scores) 230 + // reference: https://opensourceconnections.com/blog/2023/02/27/hybrid-vigor-winning-at-hybrid-search/ 231 + let bm25_scores_vec: Vec<f32> = bm25_results.iter().map(|r| r.dist).collect(); 232 + let max_bm25 = bm25_scores_vec.iter().cloned().fold(f32::NEG_INFINITY, f32::max).max(0.001); // avoid division by zero 233 + 234 + let mut keyword_scores: HashMap<String, f32> = HashMap::new(); 235 + for row in &bm25_results { 236 + // divide by max to ensure top result gets 1.0, others scale proportionally 237 + let normalized_score = (row.dist / max_bm25).min(1.0); 238 + keyword_scores.insert(row.id.clone(), normalized_score); 239 + } 240 + 241 + logfire::info!( 242 + "bm25 search completed", 243 + query = &query_text, 244 + results_found = bm25_results.len() as i64, 245 + max_bm25 = max_bm25 as f64, 246 + top_bm25_raw = bm25_scores_vec.first().copied().unwrap_or(0.0) as f64, 247 + top_bm25_normalized = keyword_scores.values().cloned().fold(f32::NEG_INFINITY, f32::max) as f64 248 + ); 249 + 250 + // collect all unique results and compute weighted fusion scores 251 + let mut all_results: HashMap<String, crate::turbopuffer::QueryRow> = HashMap::new(); 252 + for row in vector_results.into_iter().chain(bm25_results.into_iter()) { 253 + all_results.entry(row.id.clone()).or_insert(row); 254 + } 255 + 256 + let mut fused_scores: Vec<(String, f32)> = all_results 257 + .keys() 258 + .map(|id| { 259 + let semantic = semantic_scores.get(id).copied().unwrap_or(0.0); 260 + let keyword = keyword_scores.get(id).copied().unwrap_or(0.0); 261 + let fused = alpha * semantic + (1.0 - alpha) * keyword; 262 + (id.clone(), fused) 263 + }) 264 + .collect(); 265 + 266 + // sort by fused score (descending) and take top_k 267 + fused_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); 268 + fused_scores.truncate(top_k_val); 269 + 270 + logfire::info!( 271 + "weighted fusion completed", 272 + total_candidates = all_results.len() as i64, 273 + alpha = alpha as f64, 274 + final_results = fused_scores.len() as i64 275 + ); 276 + 277 + // convert to bufo results 278 + let results: Vec<BufoResult> = fused_scores 279 + .into_iter() 280 + .filter_map(|(id, score)| { 281 + all_results.get(&id).map(|row| { 282 + let url = row 283 + .attributes 284 + .get("url") 285 + .and_then(|v| v.as_str()) 286 + .unwrap_or("") 287 + .to_string(); 288 + 289 + let name = row 290 + .attributes 291 + .get("name") 292 + .and_then(|v| v.as_str()) 293 + .unwrap_or(&row.id) 294 + .to_string(); 295 + 296 + BufoResult { 297 + id: row.id.clone(), 298 + url, 299 + name, 300 + score, 301 + } 302 + }) 195 303 }) 196 304 .collect(); 197 305 ··· 221 329 query: web::Json<SearchQuery>, 222 330 config: web::Data<Config>, 223 331 ) -> ActixResult<HttpResponse> { 224 - let response = perform_search(query.query.clone(), query.top_k, &config).await?; 332 + let response = perform_search(query.query.clone(), query.top_k, query.alpha, &config).await?; 225 333 Ok(HttpResponse::Ok().json(response)) 226 334 } 227 335 ··· 232 340 req: HttpRequest, 233 341 ) -> ActixResult<HttpResponse> { 234 342 // generate etag for caching 235 - let etag = generate_etag(&query.query, query.top_k); 343 + let etag = generate_etag(&query.query, query.top_k, query.alpha); 236 344 237 345 // check if client has cached version 238 346 if let Some(if_none_match) = req.headers().get("if-none-match") { ··· 243 351 } 244 352 } 245 353 246 - let response = perform_search(query.query.clone(), query.top_k, &config).await?; 354 + let response = perform_search(query.query.clone(), query.top_k, query.alpha, &config).await?; 247 355 248 356 Ok(HttpResponse::Ok() 249 357 .insert_header(("etag", etag.clone()))
+48 -1
src/turbopuffer.rs
··· 15 15 #[derive(Debug, Deserialize, Serialize, Clone)] 16 16 pub struct QueryRow { 17 17 pub id: String, 18 - pub dist: f32, 18 + pub dist: f32, // for vector: cosine distance; for BM25: BM25 score 19 19 pub attributes: serde_json::Map<String, serde_json::Value>, 20 20 } 21 21 ··· 62 62 63 63 serde_json::from_str(&body) 64 64 .context(format!("failed to parse query response: {}", body)) 65 + } 66 + 67 + pub async fn bm25_query(&self, query_text: &str, top_k: usize) -> Result<QueryResponse> { 68 + let url = format!( 69 + "https://api.turbopuffer.com/v1/vectors/{}/query", 70 + self.namespace 71 + ); 72 + 73 + let request = serde_json::json!({ 74 + "rank_by": ["name", "BM25", query_text], 75 + "top_k": top_k, 76 + "include_attributes": ["url", "name", "filename"], 77 + }); 78 + 79 + log::debug!("turbopuffer BM25 query request: {}", serde_json::to_string_pretty(&request)?); 80 + 81 + let response = self 82 + .client 83 + .post(&url) 84 + .header("Authorization", format!("Bearer {}", self.api_key)) 85 + .json(&request) 86 + .send() 87 + .await 88 + .context("failed to send BM25 query request")?; 89 + 90 + if !response.status().is_success() { 91 + let status = response.status(); 92 + let body = response.text().await.unwrap_or_default(); 93 + anyhow::bail!("turbopuffer BM25 query failed with status {}: {}", status, body); 94 + } 95 + 96 + let body = response.text().await.context("failed to read response body")?; 97 + log::debug!("turbopuffer BM25 response: {}", body); 98 + 99 + let parsed: QueryResponse = serde_json::from_str(&body) 100 + .context(format!("failed to parse BM25 query response: {}", body))?; 101 + 102 + // DEBUG: log first result to see what BM25 returns 103 + if let Some(first) = parsed.first() { 104 + log::info!("BM25 first result - id: {}, dist: {}, name: {:?}", 105 + first.id, 106 + first.dist, 107 + first.attributes.get("name") 108 + ); 109 + } 110 + 111 + Ok(parsed) 65 112 } 66 113 }
+151 -1
static/index.html
··· 79 79 margin-bottom: 20px; 80 80 } 81 81 82 + .search-options { 83 + margin-top: 15px; 84 + padding-top: 15px; 85 + border-top: 1px solid #e0e0e0; 86 + } 87 + 88 + .search-options.collapsed { 89 + display: none; 90 + } 91 + 92 + .options-toggle { 93 + background: none; 94 + border: none; 95 + color: #667eea; 96 + font-size: 0.9em; 97 + font-family: inherit; 98 + cursor: pointer; 99 + padding: 5px 0; 100 + text-decoration: underline; 101 + margin-top: 10px; 102 + } 103 + 104 + .options-toggle:hover { 105 + color: #5568d3; 106 + } 107 + 108 + .option-group { 109 + margin-bottom: 15px; 110 + } 111 + 112 + .option-label { 113 + display: flex; 114 + justify-content: space-between; 115 + align-items: center; 116 + margin-bottom: 8px; 117 + font-size: 0.9em; 118 + color: #555; 119 + } 120 + 121 + .option-name { 122 + font-weight: 600; 123 + } 124 + 125 + .option-value { 126 + color: #667eea; 127 + font-weight: 700; 128 + } 129 + 130 + .option-description { 131 + font-size: 0.8em; 132 + color: #888; 133 + margin-bottom: 8px; 134 + line-height: 1.4; 135 + } 136 + 137 + input[type="range"] { 138 + width: 100%; 139 + height: 6px; 140 + border-radius: 3px; 141 + background: #e0e0e0; 142 + outline: none; 143 + -webkit-appearance: none; 144 + } 145 + 146 + input[type="range"]::-webkit-slider-thumb { 147 + -webkit-appearance: none; 148 + appearance: none; 149 + width: 18px; 150 + height: 18px; 151 + border-radius: 50%; 152 + background: #667eea; 153 + cursor: pointer; 154 + } 155 + 156 + input[type="range"]::-moz-range-thumb { 157 + width: 18px; 158 + height: 18px; 159 + border-radius: 50%; 160 + background: #667eea; 161 + cursor: pointer; 162 + border: none; 163 + } 164 + 165 + .alpha-markers { 166 + display: flex; 167 + justify-content: space-between; 168 + font-size: 0.75em; 169 + color: #aaa; 170 + margin-top: 4px; 171 + } 172 + 82 173 .sample-queries-container { 83 174 text-align: center; 84 175 margin-bottom: 30px; ··· 383 474 > 384 475 <button id="searchButton">search</button> 385 476 </div> 477 + 478 + <button class="options-toggle" id="optionsToggle">search options</button> 479 + 480 + <div class="search-options collapsed" id="searchOptions"> 481 + <div class="option-group"> 482 + <div class="option-label"> 483 + <span class="option-name">search mode (alpha)</span> 484 + <span class="option-value" id="alphaValue">0.7</span> 485 + </div> 486 + <div class="option-description"> 487 + balance between semantic understanding and exact keyword matching 488 + </div> 489 + <input 490 + type="range" 491 + id="alphaSlider" 492 + min="0" 493 + max="1" 494 + step="0.1" 495 + value="0.7" 496 + > 497 + <div class="alpha-markers"> 498 + <span>keyword</span> 499 + <span>balanced</span> 500 + <span>semantic</span> 501 + </div> 502 + </div> 503 + </div> 386 504 </div> 387 505 388 506 <div id="sampleQueriesContainer" class="sample-queries-container"> ··· 409 527 const loadingDiv = document.getElementById('loading'); 410 528 const errorDiv = document.getElementById('error'); 411 529 const sampleQueriesContainer = document.getElementById('sampleQueriesContainer'); 530 + const optionsToggle = document.getElementById('optionsToggle'); 531 + const searchOptions = document.getElementById('searchOptions'); 532 + const alphaSlider = document.getElementById('alphaSlider'); 533 + const alphaValue = document.getElementById('alphaValue'); 412 534 413 535 let hasSearched = false; 414 536 537 + // toggle search options 538 + optionsToggle.addEventListener('click', () => { 539 + searchOptions.classList.toggle('collapsed'); 540 + optionsToggle.textContent = searchOptions.classList.contains('collapsed') 541 + ? 'search options' 542 + : 'hide options'; 543 + }); 544 + 545 + // update alpha value display 546 + alphaSlider.addEventListener('input', (e) => { 547 + alphaValue.textContent = e.target.value; 548 + }); 549 + 415 550 async function search(updateUrl = true) { 416 551 const query = searchInput.value.trim(); 417 552 if (!query) return; 553 + 554 + const alpha = parseFloat(alphaSlider.value); 418 555 419 556 // hide bufo after first search 420 557 if (!hasSearched) { ··· 427 564 const params = new URLSearchParams(); 428 565 params.set('q', query); 429 566 params.set('top_k', '20'); 567 + params.set('alpha', alpha.toString()); 430 568 const newUrl = `${window.location.pathname}?${params.toString()}`; 431 - window.history.pushState({ query }, '', newUrl); 569 + window.history.pushState({ query, alpha }, '', newUrl); 432 570 } 433 571 434 572 searchButton.disabled = true; ··· 441 579 const params = new URLSearchParams(); 442 580 params.set('query', query); 443 581 params.set('top_k', '20'); 582 + params.set('alpha', alpha.toString()); 444 583 445 584 const response = await fetch(`/api/search?${params.toString()}`, { 446 585 method: 'GET', ··· 494 633 window.addEventListener('popstate', (e) => { 495 634 if (e.state && e.state.query) { 496 635 searchInput.value = e.state.query; 636 + if (e.state.alpha !== undefined) { 637 + alphaSlider.value = e.state.alpha; 638 + alphaValue.textContent = e.state.alpha; 639 + } 497 640 search(false); 498 641 } 499 642 }); ··· 502 645 window.addEventListener('DOMContentLoaded', () => { 503 646 const params = new URLSearchParams(window.location.search); 504 647 const query = params.get('q'); 648 + const alpha = params.get('alpha'); 649 + 650 + if (alpha) { 651 + alphaSlider.value = alpha; 652 + alphaValue.textContent = alpha; 653 + } 654 + 505 655 if (query) { 506 656 searchInput.value = query; 507 657 search(false); // don't update URL since we're already loading from it