Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/tools/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,26 @@ const QUERY_STOPWORDS = new Set([
"those"
]);

/** Solr boolean keywords: all-caps by convention, never a term to score on. */
const SOLR_OPERATORS = new Set(["AND", "OR", "NOT"]);

export const extractQueryTerms = (query: string): string[] => {
const raw = query.normalize("NFC").match(/[\p{L}\p{N}]+/gu) ?? [];
const normalized = query.normalize("NFC");
const raw = normalized.match(/[\p{L}\p{N}]+/gu) ?? [];
// Inside double quotes Solr reads a keyword as a literal, so `"OR"` is a term to
// score on while a bare `OR` is syntax. Tokenisation has already dropped the quotes,
// so the quoted tokens are collected first.
const quoted = new Set<string>();
for (const m of normalized.matchAll(/"([^"]*)"/g)) {
for (const t of m[1].match(/[\p{L}\p{N}]+/gu) ?? []) quoted.add(t);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
// An all-caps token is an acronym, not an article: the stopword list is there for
// `defibrillatori Comune di Lecce`, and must not swallow the `UN` of `UN population`
// on a catalog in another language.
// on a catalog in another language. Solr's own operators are the exception to the
// exception — `aria OR acqua` is a query, not a mention of an organisation called OR.
const terms = raw
.filter((token) => {
if (SOLR_OPERATORS.has(token) && !quoted.has(token)) return false;
const term = token.toLowerCase();
if (term.length <= 1) return false;
if (!QUERY_STOPWORDS.has(term)) return true;
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/package-scoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,20 @@ describe('multilingual term extraction', () => {
expect(extractQueryTerms('un comune di lecce')).toEqual(['comune', 'lecce']);
});

it('never scores on a Solr operator, even though it is all-caps', () => {
// The acronym rule let `OR` through: `aria OR acqua` scored on three terms and
// a title carrying one of them got 4 × 1/3 instead of 4 × 1/2.
expect(extractQueryTerms('aria OR acqua')).toEqual(['aria', 'acqua']);
expect(extractQueryTerms('aria AND NOT rifiuti')).toEqual(['aria', 'rifiuti']);
// lowercase `or` was already a stopword; an English `Or` mid-sentence stays one too
expect(extractQueryTerms('water or sewage')).toEqual(['water', 'sewage']);
});

it('keeps a quoted operator: inside quotes Solr reads it as a literal', () => {
expect(extractQueryTerms('"OR" Oregon')).toEqual(['or', 'oregon']);
expect(extractQueryTerms('aria OR "AND"')).toEqual(['aria', 'and']);
});

it('matches across Unicode normal forms', () => {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
const nfd = 'mobilita\u0300 urbana'; // decomposed
expect(countMatchingTerms(nfd, ['mobilità'])).toBe(1);
Expand Down