@athenox/talos
Version:
Talos: a backend you describe instead of write. Declare a schema, write the rules in plain language, map URLs to sentences; a small built-in model turns them into query plans.
95 lines (94 loc) • 4.31 kB
JavaScript
import { cosine, embedText, hasEmbedder } from './embed.js';
const WORD = /[a-z0-9]+/g;
const STOP = new Set(('le la les l de du des d un une et en au aux a y ou dans pour avec sur par est sont qui que ces cette son ses '
+ 'leur plus ne pas se sa ce the of to in on at is it an a and for with are was this that from').split(' '));
const tokens = (s) => (s.toLowerCase().normalize('NFD').replace(/\p{Mn}/gu, '').match(WORD) ?? []).filter(w => w.length > 1 && !STOP.has(w));
// ponytail: unbounded, keyed by text; bound it (LRU) for very large, fast-changing tables.
const cache = new Map();
const toks = (s) => { let t = cache.get(s); if (!t) {
t = tokens(s);
cache.set(s, t);
} return t; };
const stripHtml = (s) => s.replace(/<[^>]*>/g, ' ').replace(/ | /g, ' ').replace(/&[a-z]+;/g, ' ');
/** at most one edit (insert, delete, substitute) between a and b */
function oneEdit(a, b) {
if (Math.abs(a.length - b.length) > 1)
return false;
let i = 0, j = 0, edits = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
i++;
j++;
continue;
}
if (++edits > 1)
return false;
if (a.length > b.length)
i++;
else if (a.length < b.length)
j++;
else {
i++;
j++;
}
}
return edits + (a.length - i) + (b.length - j) <= 1;
}
export function find(rows, o) {
const query = tokens(o.q);
if (!query.length)
return [];
const text = (r, f) => { const v = String(r[f] ?? ''); return o.html ? stripHtml(v) : v; };
if (o.suggest) {
const last = query.at(-1);
return rows.filter(r => toks(text(r, o.suggest)).some(w => w.startsWith(last))).slice(0, o.limit ?? 5);
}
// Document frequencies and mean field lengths, over the candidates.
const fields = Object.entries(o.fields);
const docs = rows.map(r => Object.fromEntries(fields.map(([f]) => [f, toks(text(r, f))])));
const vocab = new Map();
for (const d of docs)
for (const w of new Set(fields.flatMap(([f]) => d[f])))
vocab.set(w, (vocab.get(w) ?? 0) + 1);
const avg = Object.fromEntries(fields.map(([f]) => [f, docs.reduce((s, d) => s + d[f].length, 0) / (docs.length || 1) || 1]));
// Which row words each query word stands for: itself, what it starts, a typo of it.
const forms = query.map(q => [...vocab.keys()].filter(w => w === q || (q.length >= 3 && w.startsWith(q)) || (q.length >= 5 && oneEdit(q, w))));
const idf = (w) => Math.log(1 + (rows.length - (vocab.get(w) ?? 0) + 0.5) / ((vocab.get(w) ?? 0) + 0.5));
const k1 = 1.2, b = 0.75;
const lexical = docs.map(d => {
let s = 0, matched = 0;
forms.forEach((fs, qi) => {
let best = 0;
for (const w of fs) {
// An exact word counts fully; a prefix or a typo a little less.
const exact = w === query[qi] ? 1 : 0.7;
for (const [f, weight] of fields) {
const tf = d[f].filter(x => x === w).length;
if (!tf)
continue;
const len = d[f].length;
best = Math.max(best, exact * weight * idf(w) * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * len / avg[f])));
}
}
if (best > 0)
matched++;
s += best;
});
return { s, matched };
});
const top = Math.max(1e-9, ...lexical.map(l => l.s));
const qv = hasEmbedder() ? embedText(o.q) : null;
return rows
.map((r, i) => {
const words = lexical[i].s / top;
const meaning = qv ? Math.max(0, cosine(qv, embedText(fields.map(([f]) => text(r, f)).join('. ').slice(0, 2000)))) : 0;
// Meaning alone must be clear to bring in a row that matched no word:
// measured, a related text scores about 0.2 and unrelated ones about 0.
const keep = lexical[i].matched > 0 || meaning > 0.15;
return { r, keep, score: 0.7 * words + 0.3 * meaning };
})
.filter(x => x.keep)
.sort((a, b) => b.score - a.score)
.slice(0, o.limit ?? 20)
.map(({ r, score }) => ({ ...r, score: Math.round(score * 1000) / 1000 }));
}