Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

96 linesCodeBlameRaw
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 */
18export const MEANING_FLOOR = 0.7;
19/** Nearest passages asked of the index. */
20export const TOP_K = 24;
21/** Asked when the index can't filter by space and recall filters after: more, so enough are left. */
22export 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). */
24export 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 */
31export function vectorQueryPlan(
32 workspaceId: string,
33 allowed: string[],
34): { topK: number; filter: { workspace_id: string; space_ids?: string[] }; filterAfter: boolean } | null {
35 const ids = [...new Set(allowed)];
36 if (!ids.length) return null;
37 if (ids.length <= MAX_IN_FILTER) return { topK: TOP_K, filter: { workspace_id: workspaceId, space_ids: ids }, filterAfter: false };
38 return { topK: TOP_K_UNFILTERED, filter: { workspace_id: workspaceId }, filterAfter: true };
39}
40
41/**
42 * Hybrid search's order for people: each document's place in the word
43 * results and in the meaning results, fused by reciprocal rank (k = 60),
44 * so a page both find comes first and either alone still counts.
45 */
46export function fuseRanks(words: string[], meaning: string[], k = 60): string[] {
47 const score = new Map<string, number>();
48 const add = (ids: string[]) =>
49 [...new Set(ids)].forEach((id, rank) => {
50 score.set(id, (score.get(id) ?? 0) + 1 / (k + rank + 1));
51 });
52 add(words);
53 add(meaning);
54 return [...score.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
55}
56
57/**
58 * A query's embeddings, kept a minute per isolate: a search page
59 * reloaded embeds once.
60 */
61export class QueryCache {
62 private readonly entries = new Map<string, { at: number; vector: number[] }>();
63 private readonly ttlMs: number;
64 private readonly max: number;
65 constructor(ttlMs = 60_000, max = 200) {
66 this.ttlMs = ttlMs;
67 this.max = max;
68 }
69
70 get(key: string, now = Date.now()): number[] | null {
71 const hit = this.entries.get(key);
72 if (!hit) return null;
73 if (now - hit.at > this.ttlMs) {
74 this.entries.delete(key);
75 return null;
76 }
77 return hit.vector;
78 }
79
80 set(key: string, vector: number[], now = Date.now()): void {
81 if (this.entries.size >= this.max) {
82 for (const [k, v] of this.entries) if (now - v.at > this.ttlMs) this.entries.delete(k);
83 while (this.entries.size >= this.max) this.entries.delete(this.entries.keys().next().value!);
84 }
85 this.entries.set(key, { at: now, vector });
86 }
87}
88
89/** A query as the cache keys it: the same words, however spaced or cased. */
90export function queryKey(query: string): string {
91 return String(query ?? "")
92 .replace(/\s+/g, " ")
93 .trim()
94 .toLowerCase()
95 .slice(0, 2000);
96}