A simple, clean, fast browser for the AtmosphereConf(2026) VODs
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: align semantic query model and ranking tie-breaks

jack 669df404 6b8b14a1

+58 -10
+58 -10
functions/api/search.ts
··· 35 35 type RankedEntry = { 36 36 uri: string 37 37 score: number 38 + semantic: number 39 + lexical: number 40 + freshness: number 38 41 } 39 42 40 43 let cachedIndex: { loadedAt: number; index: EmbeddingIndex; norms: number[] } | null = null ··· 46 49 } | null = null 47 50 const INDEX_TTL_MS = 5 * 60 * 1000 48 51 const LIVE_CATALOG_TTL_MS = 2 * 60 * 1000 49 - const EMBEDDING_DIMENSIONS = 4096 50 52 51 53 function normalizeVector(vector: number[]): number { 52 54 let sum = 0 ··· 308 310 return { index, norms } 309 311 } 310 312 311 - async function embedQuery(context: PagesFunctionContextLike, query: string): Promise<number[] | null> { 313 + async function embedQuery( 314 + context: PagesFunctionContextLike, 315 + query: string, 316 + embeddingModel: string, 317 + ): Promise<number[] | null> { 312 318 const apiKey = context.env.OPENROUTER_API_KEY 313 319 if (!apiKey) { 314 320 return null 315 321 } 316 322 317 - const model = context.env.OPENROUTER_EMBEDDING_MODEL || 'qwen/qwen3-embedding-8b' 318 323 const response = await fetch('https://openrouter.ai/api/v1/embeddings', { 319 324 method: 'POST', 320 325 headers: { ··· 324 329 'X-OpenRouter-Title': 'Streamplace VOD semantic search', 325 330 }, 326 331 body: JSON.stringify({ 327 - model, 332 + model: embeddingModel, 328 333 input: query, 329 334 input_type: 'search_query', 330 335 }), ··· 389 394 throw new Error('Embedding index entries missing') 390 395 } 391 396 397 + let expectedDimensions = 0 398 + 392 399 const validEntries = index.entries.filter((entry) => { 393 400 if (!entry?.uri || !Array.isArray(entry.embedding)) { 394 401 return false 395 402 } 396 - if (entry.embedding.length !== EMBEDDING_DIMENSIONS) { 403 + 404 + if (entry.embedding.length === 0) { 397 405 return false 398 406 } 399 - return entry.embedding.every((value) => Number.isFinite(value)) 407 + 408 + if (!entry.embedding.every((value) => Number.isFinite(value))) { 409 + return false 410 + } 411 + 412 + if (expectedDimensions === 0) { 413 + expectedDimensions = entry.embedding.length 414 + } 415 + 416 + return entry.embedding.length === expectedDimensions 400 417 }) 401 418 402 419 if (validEntries.length === 0) { ··· 464 481 } 465 482 466 483 const normByUri = mapNormsByUri(entries, norms) 467 - const queryVector = await embedQuery(context, query) 484 + const queryVector = await embedQuery(context, query, index.model) 468 485 if (!queryVector) { 469 486 const lexicalRanked = liveCatalog.talks 470 487 .map((entry) => ({ ··· 484 501 }) 485 502 } 486 503 504 + const expectedDimensions = embeddedEntries[0]?.embedding.length ?? 0 505 + if (expectedDimensions > 0 && queryVector.length !== expectedDimensions) { 506 + const lexicalRanked = liveCatalog.talks 507 + .map((entry) => ({ 508 + uri: entry.uri, 509 + score: lexicalScore(query, entry.title ?? ''), 510 + })) 511 + .filter((entry) => entry.score > 0) 512 + .sort((a, b) => b.score - a.score) 513 + .slice(0, limit) 514 + 515 + return jsonResponse({ 516 + uris: lexicalRanked.map((entry) => entry.uri), 517 + mode: 'lexical', 518 + notice: `Embedding model mismatch (${index.model}); using lexical title search fallback.`, 519 + generatedAt: index.generatedAt, 520 + indexedCount: embeddedEntries.length, 521 + }) 522 + } 523 + 487 524 const queryNorm = normalizeVector(queryVector) 488 525 const bounds = recencyBounds(embeddedEntries) 489 526 const ranked: RankedEntry[] = [] ··· 494 531 const liveTitle = liveCatalog.titleByUri.get(entry.uri) ?? entry.title ?? '' 495 532 const lexical = lexicalScore(query, liveTitle) 496 533 const freshness = recencyScore(liveCatalog.recencyByUri.get(entry.uri) ?? entry.createdAt, bounds) 497 - const score = similarity * 0.82 + lexical * 0.13 + freshness * 0.05 534 + const semantic = (similarity + 1) / 2 535 + const score = semantic * 0.9 + lexical * 0.08 + freshness * 0.02 498 536 499 537 if (score > 0) { 500 - ranked.push({ uri: entry.uri, score }) 538 + ranked.push({ uri: entry.uri, score, semantic, lexical, freshness }) 501 539 } 502 540 } 503 541 ··· 505 543 .map((talk) => ({ 506 544 uri: talk.uri, 507 545 score: lexicalScore(query, talk.title) * 0.55, 546 + semantic: 0, 547 + lexical: lexicalScore(query, talk.title), 548 + freshness: 0, 508 549 })) 509 550 .filter((entry) => entry.score > 0) 510 551 511 552 ranked.push(...lexicalForUnembedded) 512 553 513 - ranked.sort((a, b) => b.score - a.score) 554 + ranked.sort( 555 + (a, b) => 556 + b.score - a.score || 557 + b.semantic - a.semantic || 558 + b.lexical - a.lexical || 559 + b.freshness - a.freshness || 560 + a.uri.localeCompare(b.uri), 561 + ) 514 562 515 563 const stalenessNotice = 516 564 unembeddedTalks.length > 0