src/embed.js (13507 bytes)
1 // Search by meaning, and groups of ideas that are close in meaning. This follows memstate 2 // (github.com/map588/memstate): nomic-embed-text with its task prefixes, one vector for each idea 3 // that a content hash keeps current, cosine similarity with a threshold, and word search when the 4 // embedding server does not answer. The groups and the "related" links come from the vectors, so 5 // ideamine needs no graph database. 6 7 import crypto from 'node:crypto'; 8 import fs from 'node:fs'; 9 import path from 'node:path'; 10 import * as config from './config.js'; 11 import { home, listIdeas, load, withLock, writeAtomic } from './store.js'; 12 import { clip, wordSet } from './text.js'; 13 14 const MAX_CHARS = 1500; // the first cut; embed() halves a text again when the server says it is too long 15 const MIN_CHARS = 100; 16 const BATCH = 32; 17 18 /** The embedding server cannot answer. Callers then use word search. */ 19 export class EmbedError extends Error {} 20 21 const vectorsPath = () => path.join(home(), 'vectors.json'); 22 23 /** The text that stands for an idea: its title (when the text does not start with it), text, and tags. */ 24 export function ideaText(idea) { 25 const text = String(idea.text || ''); 26 const title = String(idea.title || '').replace(/…$/, ''); 27 const head = text.startsWith(title) ? text : `${idea.title}\n${text}`; 28 return idea.tags?.length ? `${head}\ntags: ${idea.tags.join(', ')}` : head; 29 } 30 31 // nomic-embed models expect a task prefix on each side of a search. Other models get the raw text. 32 const isNomic = (model) => /^nomic-embed/i.test(model); 33 export const documentText = (model, text) => (isNomic(model) ? `search_document: ${text}` : text); 34 export const queryText = (model, text) => (isNomic(model) ? `search_query: ${text}` : text); 35 36 /** Base64 of the vector as little-endian float32, the form that data.json and vectors.json use. */ 37 export function encodeVec(vec) { 38 const buf = Buffer.alloc(vec.length * 4); 39 for (let i = 0; i < vec.length; i++) buf.writeFloatLE(vec[i], i * 4); 40 return buf.toString('base64'); 41 } 42 43 export function decodeVec(b64) { 44 const buf = Buffer.from(b64, 'base64'); 45 const vec = new Float32Array(buf.length >> 2); 46 for (let i = 0; i < vec.length; i++) vec[i] = buf.readFloatLE(i * 4); 47 return vec; 48 } 49 50 /** The vector scaled to length 1. */ 51 export function unit(values) { 52 const vec = Float32Array.from(values); 53 let sum = 0; 54 for (const x of vec) sum += x * x; 55 const norm = Math.sqrt(sum); 56 if (!norm || !Number.isFinite(norm)) throw new EmbedError('the embedding server returned an empty vector'); 57 for (let i = 0; i < vec.length; i++) vec[i] /= norm; 58 return vec; 59 } 60 61 /** Cosine similarity of two unit vectors. */ 62 export function cosine(a, b) { 63 let sum = 0; 64 for (let i = 0; i < a.length; i++) sum += a[i] * b[i]; 65 return sum; 66 } 67 68 /** Cut at a code point, never inside a surrogate pair. */ 69 const cut = (text, max) => (text.length <= max ? text : [...text].slice(0, max).join('')); 70 71 async function post(texts, { url, model, timeoutMs }) { 72 const endpoint = `${url.replace(/\/+$/, '')}/embeddings`; 73 let res; 74 try { 75 res = await fetch(endpoint, { 76 method: 'POST', 77 headers: { 'content-type': 'application/json' }, 78 body: JSON.stringify({ model, input: texts }), 79 signal: AbortSignal.timeout(timeoutMs), 80 }); 81 } catch (e) { 82 const why = e.name === 'TimeoutError' ? `no answer in ${timeoutMs / 1000} s` : e.cause?.code || e.cause?.message || e.message; 83 throw new EmbedError(`cannot reach ${endpoint} (${why})`); 84 } 85 const body = await res.text(); 86 if (!res.ok) { 87 const err = new EmbedError(`${endpoint} answered ${res.status}: ${clip(body, 160)}`); 88 err.tooLong = /too large|too long|context/i.test(body); 89 throw err; 90 } 91 let data; 92 try { 93 data = JSON.parse(body).data; 94 } catch { 95 data = null; 96 } 97 if (!Array.isArray(data) || data.length !== texts.length) { 98 throw new EmbedError(`${endpoint} did not return ${texts.length} embeddings: ${clip(body, 160)}`); 99 } 100 return [...data].sort((a, b) => a.index - b.index).map((d) => unit(d.embedding)); 101 } 102 103 /** 104 * Unit vectors for the texts, with the document or the query prefix. When the server says that a 105 * text is too long, it embeds the texts one at a time and halves each text that is still too long. 106 */ 107 export async function embed(texts, { query = false, timeoutMs = 30000 } = {}) { 108 const url = config.get('embed_url'); 109 const model = config.get('embed_model'); 110 const wrap = (t) => (query ? queryText(model, t) : documentText(model, t)); 111 const first = texts.map((t) => cut(String(t), MAX_CHARS)); 112 try { 113 return await post(first.map(wrap), { url, model, timeoutMs }); 114 } catch (e) { 115 if (!e.tooLong) throw e; 116 } 117 const out = []; 118 for (let text of first) { 119 for (;;) { 120 try { 121 out.push((await post([wrap(text)], { url, model, timeoutMs }))[0]); 122 break; 123 } catch (e) { 124 if (!e.tooLong || text.length <= MIN_CHARS) throw e; 125 text = cut(text, Math.floor([...text].length / 2)); 126 } 127 } 128 } 129 return out; 130 } 131 132 const hashOf = (model, text) => crypto.createHash('sha256').update(`${model}\n${text}`).digest('hex').slice(0, 16); 133 134 function readVectors(model) { 135 try { 136 const file = JSON.parse(fs.readFileSync(vectorsPath(), 'utf8')); 137 return file.model === model && file.items ? file.items : {}; 138 } catch { 139 return {}; 140 } 141 } 142 143 /** Add vectors to the cache. Vectors of deleted ideas go, so a deleted idea leaves nothing behind. */ 144 function saveVectors(model, entries) { 145 withLock(() => { 146 const live = new Set(load().ideas.map((i) => String(i.id))); 147 const items = Object.fromEntries(Object.entries(readVectors(model)).filter(([id]) => live.has(id))); 148 for (const [id, item] of entries) if (live.has(String(id))) items[id] = item; 149 writeAtomic(vectorsPath(), JSON.stringify({ model, items }) + '\n'); 150 }); 151 } 152 153 /** A vector for each idea. Only new and changed ideas go to the server; the others come from the cache. */ 154 export async function vectorsFor(ideas, { timeoutMs } = {}) { 155 const model = config.get('embed_model'); 156 const cached = readVectors(model); 157 const vectors = new Map(); 158 const todo = []; 159 for (const idea of ideas) { 160 const text = ideaText(idea); 161 const hash = hashOf(model, text); 162 if (cached[idea.id]?.hash === hash) vectors.set(idea.id, decodeVec(cached[idea.id].vec)); 163 else todo.push({ id: idea.id, text, hash }); 164 } 165 for (let i = 0; i < todo.length; i += BATCH) { 166 const part = todo.slice(i, i + BATCH); 167 const vecs = await embed(part.map((t) => t.text), { timeoutMs }); 168 part.forEach((t, k) => vectors.set(t.id, vecs[k])); 169 saveVectors(model, part.map((t, k) => [t.id, { hash: t.hash, vec: encodeVec(vecs[k]) }])); 170 } 171 return vectors; 172 } 173 174 /** 175 * Groups of ideas that are close in meaning, by average-linkage clustering: two groups join while 176 * the mean similarity of the pairs across them is at least `threshold`. The ids are sorted first, 177 * so the order of the input does not change the result. Only groups of 2 or more ideas are returned. 178 */ 179 export function groupIds(ids, vectors, threshold) { 180 const sorted = [...new Set(ids)].sort((a, b) => a - b); 181 const n = sorted.length; 182 // sum[i][j]: sum of the similarities of all pairs across clusters i and j. 183 const sum = sorted.map((a) => sorted.map((b) => cosine(vectors.get(a), vectors.get(b)))); 184 const clusters = sorted.map((id) => [id]); 185 const alive = new Array(n).fill(true); 186 for (;;) { 187 let best = -Infinity; 188 let pair = null; 189 for (let i = 0; i < n; i++) { 190 if (!alive[i]) continue; 191 for (let j = i + 1; j < n; j++) { 192 if (!alive[j]) continue; 193 const avg = sum[i][j] / (clusters[i].length * clusters[j].length); 194 if (avg > best) { 195 best = avg; 196 pair = [i, j]; 197 } 198 } 199 } 200 if (!pair || best < threshold) break; 201 const [i, j] = pair; 202 clusters[i] = [...clusters[i], ...clusters[j]].sort((a, b) => a - b); 203 alive[j] = false; 204 for (let k = 0; k < n; k++) { 205 sum[i][k] += sum[j][k]; 206 sum[k][i] = sum[i][k]; 207 } 208 } 209 return clusters.filter((c, i) => alive[i] && c.length > 1); 210 } 211 212 // Words that many ideas use and that say little about a group ("Add full support for ..."). 213 const LABEL_SKIP = new Set( 214 'add added also app apps better build built fix full idea ideas made need new nicer now should support want work'.split(' '), 215 ); 216 217 /** A few words that the ideas of a group share and other ideas do not, most distinctive first. */ 218 export function groupLabel(members, all) { 219 const words = (idea) => [...wordSet(`${idea.title} ${idea.text} ${(idea.tags || []).join(' ')}`)].filter((w) => !LABEL_SKIP.has(w)); 220 const count = (ideas) => { 221 const df = new Map(); 222 for (const idea of ideas) for (const w of words(idea)) df.set(w, (df.get(w) || 0) + 1); 223 return df; 224 }; 225 const inside = count(members); 226 const overall = count(all); 227 const best = [...inside] 228 .filter(([, n]) => n >= 2) 229 .map(([w, n]) => [w, (n / members.length) * Math.log((all.length + 1) / (overall.get(w) || 1))]) 230 .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) 231 .slice(0, 3) 232 .map(([w]) => w); 233 return best.length ? best.join(' · ') : clip(members[0].title, 40); 234 } 235 236 /** Groups for these ideas: [{ ids, label }], the biggest first. Throws EmbedError when the server does not answer. */ 237 export async function groupIdeas(ideas, { threshold = config.get('group_threshold'), vectors = null, timeoutMs } = {}) { 238 vectors ||= await vectorsFor(ideas, { timeoutMs }); 239 const byId = new Map(ideas.map((i) => [i.id, i])); 240 return groupIds(ideas.map((i) => i.id), vectors, threshold) 241 .map((ids) => ({ ids, label: groupLabel(ids.map((id) => byId.get(id)), ideas) })) 242 .sort((a, b) => b.ids.length - a.ids.length || a.ids[0] - b.ids[0]); 243 } 244 245 /** The `k` ideas closest to idea `id`, with a similarity of at least `threshold`. */ 246 export function nearest(id, ids, vectors, { k = 3, threshold = 0 } = {}) { 247 const own = vectors.get(id); 248 if (!own) return []; 249 return ids 250 .filter((other) => other !== id && vectors.has(other)) 251 .map((other) => ({ id: other, score: Math.round(cosine(own, vectors.get(other)) * 1000) / 1000 })) 252 .filter((r) => r.score >= threshold) 253 .sort((a, b) => b.score - a.score || a.id - b.id) 254 .slice(0, k); 255 } 256 257 /** Word search for when meaning search is not available: more query words found ranks higher. */ 258 function byWords(ideas, query) { 259 const words = [...wordSet(query)]; 260 if (!words.length) words.push(...String(query).toLowerCase().split(/\s+/).filter(Boolean)); 261 return ideas 262 .map((idea) => { 263 const hay = `${ideaText(idea)}\n${idea.triage?.brief || ''}`.toLowerCase(); 264 return { idea, score: words.filter((w) => hay.includes(w)).length / words.length }; 265 }) 266 .filter((r) => r.score > 0) 267 .sort((a, b) => b.score - a.score || b.idea.id - a.idea.id); 268 } 269 270 /** 271 * Ideas like the query, best first: { mode: 'meaning' | 'words', results: [{ idea, score }], note }. 272 * When the embedding server does not answer, it falls back to word search and says why in `note`. 273 */ 274 export async function find(db, query, { filter = 'all', project = null, limit = 10, threshold, timeoutMs = 8000 } = {}) { 275 const ideas = listIdeas(db, { filter, project }); 276 threshold ??= config.get('search_threshold'); 277 try { 278 const vectors = await vectorsFor(ideas, { timeoutMs }); 279 const [q] = await embed([query], { query: true, timeoutMs }); 280 const results = ideas 281 .map((idea) => ({ idea, score: cosine(q, vectors.get(idea.id)) })) 282 .filter((r) => r.score >= threshold) 283 .sort((a, b) => b.score - a.score || a.idea.id - b.idea.id); 284 return { mode: 'meaning', results: limit ? results.slice(0, limit) : results, note: '' }; 285 } catch (e) { 286 if (!(e instanceof EmbedError)) throw e; 287 const results = byWords(ideas, query); 288 return { mode: 'words', results: limit ? results.slice(0, limit) : results, note: e.message }; 289 } 290 } 291 292 /** Percentile `p` (0 to 1) of sorted numbers. */ 293 const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))]; 294 295 /** 296 * Embed every idea that has no current vector, then describe the set, like memstate's `embed 297 * status`: coverage, the nearest-neighbour similarity of each idea, and how many ideas the 298 * thresholds keep. Use it to set group_threshold and search_threshold for a new model. 299 */ 300 export async function status(db, { timeoutMs } = {}) { 301 const url = config.get('embed_url'); 302 const model = config.get('embed_model'); 303 const groupThreshold = config.get('group_threshold'); 304 const lines = [`model ${model} at ${url}`]; 305 const vectors = await vectorsFor(db.ideas, { timeoutMs }); 306 const ids = db.ideas.map((i) => i.id); 307 const dim = vectors.size ? vectors.values().next().value.length : 0; 308 lines.push(`vectors ${vectors.size}/${ids.length}${dim ? `, ${dim} dimensions` : ''} · ${vectorsPath()}`); 309 if (ids.length > 1) { 310 const nn = ids.map((id) => nearest(id, ids, vectors, { k: 1 })[0]?.score ?? 0).sort((a, b) => a - b); 311 const p = (x) => percentile(nn, x).toFixed(2); 312 lines.push(`nearest neighbour similarity: p10 ${p(0.1)} · p50 ${p(0.5)} · p90 ${p(0.9)} · max ${nn[nn.length - 1].toFixed(2)}`); 313 const groups = groupIds(ids, vectors, groupThreshold); 314 const grouped = groups.reduce((n, g) => n + g.length, 0); 315 lines.push(`group_threshold ${groupThreshold}: ${groups.length} groups hold ${grouped} of ${ids.length} ideas`); 316 } 317 return lines.join('\n'); 318 }