| 1 | /** |
| 2 | * Recall's choices, apart from where the passages come from: how the |
| 3 | * vector query is filtered, which matches are close enough, and how |
| 4 | * results of words and meaning are fused. Pure; hybrid search runs it |
| 5 | * over Vectorize and D1. |
| 6 | */ |
| 7 | |
| 8 | /** |
| 9 | * The least cosine similarity a passage needs to count as being about the |
| 10 | * query. Measured on g1t's own docs folder (606 passages, chunked and |
| 11 | * embedded as here, bge-base-en-v1.5 with Workers AI's mean pooling): the |
| 12 | * best passage for 16 questions the docs answer scored 0.72 to 0.84, while |
| 13 | * the best for 16 they don't ("hello", a recipe, Postgres tuning, a |
| 14 | * vacation policy, SSO with Okta) scored 0.54 to 0.67. At 0.6, 11 of those |
| 15 | * 16 pulled in passages (up to 89 above it); 0.7 keeps every answer and |
| 16 | * keeps recall quiet when the docs say nothing about the question. |
| 17 | */ |
| 18 | export const MEANING_FLOOR = 0.7; |
| 19 | /** Nearest passages asked of the index. */ |
| 20 | export const TOP_K = 24; |
| 21 | /** Asked when the index can't filter by space and recall filters after: more, so enough are left. */ |
| 22 | export const TOP_K_UNFILTERED = 50; |
| 23 | /** The most space ids put in one `$in` filter; past it, filter after the query (a Vectorize filter is at most 2 KB of JSON). */ |
| 24 | export const MAX_IN_FILTER = 40; |
| 25 | |
| 26 | /** |
| 27 | * How to ask the index: by the allowed spaces when there are few enough |
| 28 | * to name, otherwise by workspace alone, more of them, filtered after. |
| 29 | * Null when nothing may be read. |
| 30 | */ |
| 31 | export function vectorQueryPlan(workspaceId: string, allowed: string[]): { topK: number; filter: { workspace_id: string; space_ids?: string[] }; filterAfter: boolean } | null { |
| 32 | const ids = [...new Set(allowed)]; |
| 33 | if (!ids.length) return null; |
| 34 | if (ids.length <= MAX_IN_FILTER) return { topK: TOP_K, filter: { workspace_id: workspaceId, space_ids: ids }, filterAfter: false }; |
| 35 | return { topK: TOP_K_UNFILTERED, filter: { workspace_id: workspaceId }, filterAfter: true }; |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * Hybrid search's order for people: each document's place in the word |
| 40 | * results and in the meaning results, fused by reciprocal rank (k = 60), |
| 41 | * so a page both find comes first and either alone still counts. |
| 42 | */ |
| 43 | export function fuseRanks(words: string[], meaning: string[], k = 60): string[] { |
| 44 | const score = new Map<string, number>(); |
| 45 | const add = (ids: string[]) => |
| 46 | [...new Set(ids)].forEach((id, rank) => { |
| 47 | score.set(id, (score.get(id) ?? 0) + 1 / (k + rank + 1)); |
| 48 | }); |
| 49 | add(words); |
| 50 | add(meaning); |
| 51 | return [...score.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id); |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * A query's embeddings, kept a minute per isolate: a search page |
| 56 | * reloaded embeds once. |
| 57 | */ |
| 58 | export class QueryCache { |
| 59 | private readonly entries = new Map<string, { at: number; vector: number[] }>(); |
| 60 | private readonly ttlMs: number; |
| 61 | private readonly max: number; |
| 62 | constructor(ttlMs = 60_000, max = 200) { |
| 63 | this.ttlMs = ttlMs; |
| 64 | this.max = max; |
| 65 | } |
| 66 | |
| 67 | get(key: string, now = Date.now()): number[] | null { |
| 68 | const hit = this.entries.get(key); |
| 69 | if (!hit) return null; |
| 70 | if (now - hit.at > this.ttlMs) { |
| 71 | this.entries.delete(key); |
| 72 | return null; |
| 73 | } |
| 74 | return hit.vector; |
| 75 | } |
| 76 | |
| 77 | set(key: string, vector: number[], now = Date.now()): void { |
| 78 | if (this.entries.size >= this.max) { |
| 79 | for (const [k, v] of this.entries) if (now - v.at > this.ttlMs) this.entries.delete(k); |
| 80 | while (this.entries.size >= this.max) this.entries.delete(this.entries.keys().next().value!); |
| 81 | } |
| 82 | this.entries.set(key, { at: now, vector }); |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | /** A query as the cache keys it: the same words, however spaced or cased. */ |
| 87 | export function queryKey(query: string): string { |
| 88 | return String(query ?? "").replace(/\s+/g, " ").trim().toLowerCase().slice(0, 2000); |
| 89 | } |