openvibers/openvibe.search Commits

Relaxed queries rank by coverage, rare words weighing more (#197)

OP OpenVibers committed 11 days ago · 4ffedbc5f6 · 4 files, +118 −16

"things to do chongqing" found the Chongqing page with every word, but fewer than five documents
did, so the relaxed any-word answer took over and "do" alone put seven "Do I need…?" help
articles first; Chongqing was not on the page. "chongqing walk" listed Go Walk shoes.

Relaxed, a query now keeps its content words (no to/do/the), and both the engine's rank and
rankOrder sort first by coverage: each word's weight, ln(1 + 10000 / (1 + df)) with df counted up
to 1,000 public documents, times its best field (title 1, summary 0.6, body 0.3). The weights are
added after the cursor hash, so a later page still belongs to the query.

Changes

ddiff --git a/server/api/query.js b/server/api/query.js
iindex f969dac..b4cea16 100644
---- a/server/api/query.js
++++ b/server/api/query.js
@@@ -29,7 +29,7 @@ const { AuthError } = require('../auth');
const { OWNER_RE, TYPE_RE, ID_RE } = require('./documents');
const { fuseRrf } = require('../fabric/rrf');
const { createQueryEmbeddingCache } = require('../fabric/embed');
--const { docTier, isThin, dedupeKey, siteHosts, isQuestion, questionMatch } = require('../ranking');
++const { docTier, isThin, dedupeKey, siteHosts, isQuestion, questionMatch, contentWords, coverage } = require('../ranking');
const FACET_KEY_RE = /^[a-z][a-z0-9_]{0,39}$/;
const LANG_RE = /^[a-zA-Z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
@@@ -197,9 +197,10 @@ async function rankOrder(fused, { match, store }) {
.filter((hit) => rows.get(hit.rid))
.map((hit, i) => {
const found = rows.get(hit.rid);
-- return { hit, found, i, tier: docTier(found.doc, match, hosts), thin: isThin(found.doc), answer: ask && isAnswer(found.doc) ? 1 : 0 };
++ return { hit, found, i, cover: coverage(found.doc, match), tier: docTier(found.doc, match, hosts), thin: isThin(found.doc), answer: ask && isAnswer(found.doc) ? 1 : 0 };
})
-- .sort((a, b) => b.tier - a.tier || b.answer - a.answer || (a.thin === b.thin ? 0 : a.thin ? 1 : -1) || a.i - b.i)
++ // A relaxed query: how much of it a document holds comes first (rare words weigh more), as in the engine.
++ .sort((a, b) => b.cover - a.cover || b.tier - a.tier || b.answer - a.answer || (a.thin === b.thin ? 0 : a.thin ? 1 : -1) || a.i - b.i)
.map(({ hit, found }) => ({ ...hit, found }));
}
@@@ -323,7 +324,8 @@ function createSearcher({ config, store, engine, vectorStore = null, embeddings
const trimmed = text.trim();
// A question matches on its content words (ranking.js questionMatch) and keeps its answer-first order.
const exact = trimmed ? questionMatch(toMatch(text, { maxTerms: config.query.maxTerms })) : null;
-- const relaxedMatch = exact && exact.words.length > 1 ? { ...exact, any: true } : null;
++ // Relaxed, a query keeps its content words: "to" or "do" alone matches everything (ranking.js contentWords).
++ const relaxedMatch = exact && exact.words.length > 1 ? { ...contentWords(exact), any: true } : null;
// A later page of a relaxed answer says so in its cursor (x), and continues that query.
const relaxedCursor = Boolean(relaxedMatch && cursor && cursorFlag(cursor, 'x'));
const match = relaxedCursor ? relaxedMatch : exact;
@@@ -342,12 +344,15 @@ function createSearcher({ config, store, engine, vectorStore = null, embeddings
let next = null;
let explain;
let used = match;
++ // A relaxed match carries its words' weights (engine wordWeights), added after the cursor hash: they follow the
++ // index, and a later page must still belong to the same query.
++ const weighted = async (m) => (m && m.any && typeof engine.wordWeights === 'function' ? { ...m, weights: await engine.wordWeights(m) } : m);
if (match) {
const state = await semanticState(trimmed);
-- let page = await matchedPage({ match, hash, after, state, filters, viewer, limit, now, relaxed: relaxedCursor });
++ let page = await matchedPage({ match: relaxedCursor ? await weighted(match) : match, hash, after, state, filters, viewer, limit, now, relaxed: relaxedCursor });
// The first page of several words that found little: every document with any of the words instead.
if (!cursor && relaxedMatch && !relaxedCursor && page.results.length < RELAX_BELOW) {
-- const loose = await matchedPage({ match: relaxedMatch, hash: queryHash([relaxedMatch, filters]), after: null, state, filters, viewer, limit, now, relaxed: true });
++ const loose = await matchedPage({ match: await weighted(relaxedMatch), hash: queryHash([relaxedMatch, filters]), after: null, state, filters, viewer, limit, now, relaxed: true });
if (loose.results.length > page.results.length) { page = loose; used = relaxedMatch; }
}
({ results, next, explain } = page);
ddiff --git a/server/engine/pg.js b/server/engine/pg.js
iindex 37fa878..395a187 100644
---- a/server/engine/pg.js
++++ b/server/engine/pg.js
@@@ -22,7 +22,8 @@
* Nothing outside this file writes SQL against search_fts.
*/
const { EXPOSURE } = require('../document');
--const { fold, words, siteOnlyQuery, siteUrlPattern, isQuestion } = require('../ranking');
++const { fold, words, siteOnlyQuery, siteUrlPattern, isQuestion, FIELD_FACTORS } = require('../ranking');
++const FIELD = Object.fromEntries(FIELD_FACTORS);
const CONFIG = 'simple';
const WEIGHTS = '{0.1, 0.1, 0.4, 1.0}'; // D, C body, B summary, A title: ts_rank weights 1 / 4 / 10
@@@ -166,7 +167,19 @@ function createEngine(db, { freshness = { weight: 1, halfLifeDays: 30 } } = {})
// A question lifts an article half a tier: above the recordings that share its tier, below the next tier up,
// so it is among the candidates the page ranks (ranking.js isQuestion).
const answer = isQuestion(match) ? `CASE WHEN d.type = 'article' THEN 50 ELSE 0 END` : '0';
-- const rank = `-(${tier} * 100 + ${answer} + (${relevance}) + CASE WHEN ${thin} THEN -2 ELSE 0 END)`;
++ // A relaxed query ranks by how much of it a document holds before the field tiers: the summed weights of
++ // its words in the document (rare words weigh more), to one decimal (ranking.js coverage does the same).
++ // Each word counts in its best field: the title (A) fully, the summary (B) 0.6, the body 0.3 (FIELD_FACTORS).
++ const cover = match.any && match.weights ? `ROUND((${match.words.map((w, i) => {
++ const wt = Number(match.weights[w]) || 0;
++ params[`cwa_${i}`] = tsTerm(match, i, 'A');
++ params[`cwb_${i}`] = tsTerm(match, i, 'B');
++ params[`cw_${i}`] = tsTerm(match, i);
++ return `CASE WHEN f.tsv @@ to_tsquery('${CONFIG}', @cwa_${i}) THEN ${wt * FIELD.title}
++ WHEN f.tsv @@ to_tsquery('${CONFIG}', @cwb_${i}) THEN ${wt * FIELD.summary}
++ WHEN f.tsv @@ to_tsquery('${CONFIG}', @cw_${i}) THEN ${wt * FIELD.body} ELSE 0 END`;
++ }).join(' + ')})::numeric, 1)::float8` : '0';
++ const rank = `-(${cover} * 10000 + ${tier} * 100 + ${answer} + (${relevance}) + CASE WHEN ${thin} THEN -2 ELSE 0 END)`;
const facetNumber = (key) => `(SELECT x.value::float8 FROM doc_facets x
WHERE x.rid = d.rid AND x.key = '${key}' AND x.value ~ '^[0-9]+([.][0-9]+)?$'
ORDER BY x.value LIMIT 1)`;
@@@ -226,6 +239,17 @@ function createEngine(db, { freshness = { weight: 1, halfLifeDays: 30 } } = {})
// ── Queries ──────────────────────────────────────────────
++ /** { word: weight } for a relaxed query's words (wordWeight of each word's bounded public document frequency). */
++ async function wordWeights(match) {
++ const out = {};
++ for (let i = 0; i < match.words.length; i++) {
++ const row = await db.prepare(`SELECT COUNT(*)::int AS n FROM (SELECT 1 FROM search_fts f
++ WHERE f.audience = ${EXPOSURE.public_listed} AND f.tsv @@ to_tsquery('${CONFIG}', @q) LIMIT ${DF_CAP}) x`).get({ q: tsTerm(match, i) });
++ out[match.words[i]] = wordWeight(row ? row.n : 0);
++ }
++ return out;
++ }
++
/**
* Ranked full-text search. after = { rank, importance, recency, rid } keyset cursor. The snippet is
* computed for the page's rows only: the summary and body when they match, else the title.
@@@ -327,7 +351,18 @@ function createEngine(db, { freshness = { weight: 1, halfLifeDays: 30 } } = {})
return { rebuilt };
}
-- return { name: 'postgresql-fts', put, remove, search, browse, facetCounts, suggest, counts, reconcile, freshness };
++ return { name: 'postgresql-fts', put, remove, search, browse, facetCounts, suggest, counts, reconcile, freshness, wordWeights };
++}
++
++// ── Relaxed queries: how much a word says ────────────────────
++
++// A word's document frequency is counted up to DF_CAP public documents (a bounded index scan); its weight is
++// ln(1 + DF_SCALE / (1 + df)), so a place or brand name in 30 documents (5.8) outweighs "walk" or "things" in a
++// thousand (2.4). Only the relaxed (any-word) answer uses it.
++const DF_CAP = 1000;
++const DF_SCALE = 10000;
++function wordWeight(df) {
++ return Math.round(Math.log(1 + DF_SCALE / (1 + Math.max(0, Number(df) || 0))) * 100) / 100;
}
// ── Query text → tsquery ─────────────────────────────────────
@@@ -349,12 +384,14 @@ function toMatch(q, { maxTerms = 16, column = null, prefixLast = false } = {}) {
* toMatch()'s query → to_tsquery text: 'w1' & 'w2' & 'w3':* (and :A on each word for the title). any: true (the
* relaxed answer to several words that found little, server/api/query.js) joins them with | instead.
*/
++function tsTerm(m, i, field = null) {
++ const weight = field || (m.column === 'title' ? 'A' : '');
++ const star = m.prefix && i === m.words.length - 1 ? '*' : '';
++ return `'${m.words[i]}'${star || weight ? `:${star}${weight}` : ''}`;
++}
++
function tsquery(m) {
-- const weight = m.column === 'title' ? 'A' : '';
-- return m.words.map((w, i) => {
-- const star = m.prefix && i === m.words.length - 1 ? '*' : '';
-- return `'${w}'${star || weight ? `:${star}${weight}` : ''}`;
-- }).join(m.any ? ' | ' : ' & ');
++ return m.words.map((_, i) => tsTerm(m, i)).join(m.any ? ' | ' : ' & ');
}
/** Plain text with the engine's snippet marks → HTML with <mark>, everything else escaped. */
@@@ -364,4 +401,4 @@ function snippetHtml(snip) {
return esc.split(MARK_OPEN).join('<mark>').split(MARK_CLOSE).join('</mark>');
}
--module.exports = { createEngine, toMatch, tsquery, snippetHtml, freshnessBoost, fold, words, filterClause };
++module.exports = { createEngine, toMatch, tsquery, tsTerm, wordWeight, snippetHtml, freshnessBoost, fold, words, filterClause };
ddiff --git a/server/ranking.js b/server/ranking.js
iindex 0687622..e183027 100644
---- a/server/ranking.js
++++ b/server/ranking.js
@@@ -180,8 +180,41 @@ function questionMatch(match) {
return content.length ? { ...match, words: content, prefix: match.prefix && content[content.length - 1] === match.words[match.words.length - 1], question: true } : match;
}
++/**
++ * The words a relaxed (any-word) query keeps: its content words when it has any. "things to do chongqing" found the
++ * city page with every word, and fewer than five documents did, so the relaxed answer took any word: "do" alone put
++ * seven "Do I need…?" help articles above Chongqing (production, 2026-11-02).
++ */
++function contentWords(match) {
++ if (!match || !Array.isArray(match.words)) return match;
++ const content = match.words.filter((w) => !FUNCTION_WORDS.has(w));
++ if (!content.length || content.length === match.words.length) return match;
++ return { ...match, words: content, prefix: Boolean(match.prefix && content[content.length - 1] === match.words[match.words.length - 1]) };
++}
++
++// Where a relaxed query's word is found counts too: in the title fully, in the summary 0.6, only in the body 0.3.
++const FIELD_FACTORS = [['title', 1], ['summary', 0.6], ['body', 0.3]];
++
++/**
++ * How much of a relaxed query a document holds: for each query word, its weight (match.weights, the engine's
++ * wordWeights: a rare word weighs more than a common one) times the factor of the best field holding it, summed and
++ * kept to one decimal so near-equal covers tie and the field tiers decide. 0 for a query that is not relaxed or has
++ * no weights. The engine ranks with the same sum (server/engine/pg.js).
++ */
++function coverage(doc, match) {
++ if (!doc || !match || !match.any || !match.weights) return 0;
++ const fields = FIELD_FACTORS.map(([name, factor]) => [new Set(tokens(doc[name])), factor]);
++ let sum = 0;
++ match.words.forEach((word, i) => {
++ const prefix = Boolean(match.prefix && i === match.words.length - 1);
++ const hit = fields.find(([ts]) => ts.has(word) || (prefix && [...ts].some((t) => t.startsWith(word))));
++ if (hit) sum += (Number(match.weights[word]) || 0) * hit[1];
++ });
++ return Math.round(sum * 10) / 10;
++}
++
module.exports = {
-- isQuestion, questionMatch,
++ isQuestion, questionMatch, contentWords, coverage, FIELD_FACTORS,
SITE_ALIASES, fold, words, tokens, siteHosts, siteOnlyQuery, siteUrlPattern, urlBelongsToSites,
textMatches, fieldTier, docTier, isThin, dedupeKey,
};
ddiff --git a/test/relaxed-words.test.js b/test/relaxed-words.test.js
iindex f911441..4b62761 100644
---- a/test/relaxed-words.test.js
++++ b/test/relaxed-words.test.js
@@@ -61,6 +61,33 @@ t('a query whose every word finds enough is answered exactly as before', async (
assert.ok(ids(none).every((id) => id.startsWith('garden-')), 'relaxed, the garden pages still answer');
});
++t('relaxed, a query keeps its content words: "do" alone does not answer "things to do chongqing"', async () => {
++ for (let i = 1; i <= 7; i++) {
++ await put({ owner: 'help', type: 'article', id: `do-${i}`, title: `Do I need thing number ${i}?`, summary: 'An answer.', canonical_url: `https://openvibe.help/a/do-${i}` });
++ }
++ await put({ owner: 'tours', type: 'city', id: 'chongqing', title: 'Chongqing, China', summary: 'A city on the Yangtze. 24 places to see on OpenVibe.Tours.',
++ body: 'Chongqing\nThings to do, places to see, walks, stays and flights.', canonical_url: 'https://openvibe.tours/city/chongqing' });
++ const r = await q('things to do chongqing', '&limit=10');
++ const got = ids(r);
++ assert.strictEqual(got[0], 'chongqing', `the city first: ${got.join(', ')}`);
++ assert.ok(!got.some((id) => id.startsWith('do-')), 'no answer for "do" alone');
++});
++
++t('relaxed, a rare word outweighs a common one, and the field it is in counts (ranking.js coverage)', async () => {
++ const { coverage, contentWords } = require('../server/ranking');
++ const { wordWeight } = require('../server/engine/pg');
++ assert.ok(wordWeight(30) > wordWeight(1000), 'rarer weighs more');
++ const match = { words: ['chongqing', 'walk'], any: true, weights: { chongqing: wordWeight(30), walk: wordWeight(1000) } };
++ const place = { title: '洪崖洞', summary: 'Attraction in Chongqing, China.', body: '' };
++ const shoe = { title: 'Go Walk Max Cushioning Flex', summary: 'A slip-on.', body: '' };
++ const route = { title: 'Old town walk', summary: 'A self-guided walk in Chongqing, China.', body: '' };
++ assert.ok(coverage(place, match) > coverage(shoe, match), `a place in Chongqing (${coverage(place, match)}) before a shoe called Walk (${coverage(shoe, match)})`);
++ assert.ok(coverage(route, match) > coverage(place, match), 'a walk in Chongqing before both');
++ assert.strictEqual(coverage(place, { ...match, any: false }), 0, 'only a relaxed query has a cover');
++ assert.deepStrictEqual(contentWords({ words: ['things', 'to', 'do', 'chongqing'], prefix: false }).words, ['things', 'chongqing']);
++ assert.deepStrictEqual(contentWords({ words: ['to', 'do'], prefix: false }).words, ['to', 'do'], 'all function words: kept');
++});
++
t('a single word that finds nothing still finds nothing', async () => {
assert.deepStrictEqual(ids(await q('zzzunknownword')), []);
});