From f52d593c8927ce464375902c435e9b2502759a77 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 16 Oct 2026 18:18:28 -0700 Subject: [PATCH 1/2] fix(search): count two-letter query terms when ranking across sources queryRelevance took its terms from the snippet tokenizer, which drops words under three characters, while the title-share denominator counted every title word. For 'AI agents', a page titled 'AI agents' scored below one titled 'Agents'. Ranking now keeps two-letter terms and stoplists two-letter function words; snippets and the Atlassian half-terms filter keep the three-character minimum. --- apps/sim/lib/knowledge/search/snippet.ts | 4 +- .../lib/sim-search/live/application.test.ts | 16 +++++++ apps/sim/lib/sim-search/live/text-match.ts | 47 +++++++++++++++++-- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/knowledge/search/snippet.ts b/apps/sim/lib/knowledge/search/snippet.ts index cb604e143ee..1fc1b130e95 100644 --- a/apps/sim/lib/knowledge/search/snippet.ts +++ b/apps/sim/lib/knowledge/search/snippet.ts @@ -44,13 +44,13 @@ export function stripLeadingHeaders(content: string): string { * The query's terms worth matching, longest first so the most specific one * wins: quotes and other search syntax around a term are not part of it. */ -export function queryTerms(query: string | undefined): string[] { +export function queryTerms(query: string | undefined, minLength = MIN_TERM_LENGTH): string[] { return [ ...new Set( (query ?? '') .split(/\s+/) .map((term) => term.replace(/^["'“”‘’(]+|["'“”‘’),.;:!?]+$/g, '').trim()) - .filter((term) => term.length >= MIN_TERM_LENGTH) + .filter((term) => term.length >= minLength) ), ].sort((a, b) => b.length - a.length) } diff --git a/apps/sim/lib/sim-search/live/application.test.ts b/apps/sim/lib/sim-search/live/application.test.ts index c8e17b85a8d..0767c515023 100644 --- a/apps/sim/lib/sim-search/live/application.test.ts +++ b/apps/sim/lib/sim-search/live/application.test.ts @@ -586,6 +586,22 @@ describe('authorized live retrieval', () => { 'sheet', ]) }) + it('counts two-letter query terms, so a page named for the query leads one sharing a word', async () => { + mocks.search.mockResolvedValue({ + documents: [ + { ...document, id: 'generic', title: 'Agents', url: 'https://docs.google.com/generic' }, + { ...document, id: 'named', title: 'AI agents', url: 'https://docs.google.com/named' }, + ], + }) + const { results } = await searchLiveKnowledge.execute({ + principal, + input: { ...input, query: 'AI agents' }, + }) + expect(results.map((row) => decodeLiveReference(row.documentId).id)).toEqual([ + 'named', + 'generic', + ]) + }) it('applies date filters before spending provider verification', async () => { const verify = vi.fn(async () => true) mocks.service.mockResolvedValue({ policy: defaultLiveSearchPolicy(), verify, partial: false }) diff --git a/apps/sim/lib/sim-search/live/text-match.ts b/apps/sim/lib/sim-search/live/text-match.ts index 9dd2d4c751c..5be0c17ec4d 100644 --- a/apps/sim/lib/sim-search/live/text-match.ts +++ b/apps/sim/lib/sim-search/live/text-match.ts @@ -10,34 +10,64 @@ export type TextMatch = 'all' | 'any' /** Terms an any-term query sends; more would make the provider query long without adding recall. */ const MAX_ANY_TERMS = 8 +/** + * Ranking also counts two-letter words such as `AI` or `VM`: every title word counts toward the + * title's share of query terms, so skipping them would rank a page named `AI agents` below one + * named `Agents` for the query `AI agents`. + */ +const MIN_RANKING_TERM_LENGTH = 2 + /** Words that match nearly every page, so they would make an any-term query return noise. */ const STOPWORDS = new Set([ 'about', 'all', + 'am', + 'an', 'and', 'any', 'are', + 'as', + 'at', + 'be', + 'by', 'can', 'could', 'did', + 'do', 'does', 'for', 'from', 'had', 'has', 'have', + 'he', 'how', + 'if', + 'in', 'into', + 'is', + 'it', 'its', + 'me', + 'my', + 'no', 'not', + 'of', + 'on', + 'or', 'our', 'should', + 'so', 'that', 'the', 'their', 'there', 'this', + 'to', + 'up', + 'us', 'was', + 'we', 'were', 'what', 'when', @@ -60,9 +90,13 @@ export function significantTerms(query: string): string[] { return distinctTerms(query, (term) => term.toLowerCase()) } -function distinctTerms(query: string, keyOf: (term: string) => string): string[] { +function distinctTerms( + query: string, + keyOf: (term: string) => string, + minLength?: number +): string[] { const seen = new Set() - return queryTerms(query) + return queryTerms(query, minLength) .filter((term) => { const key = keyOf(term) if (STOPWORDS.has(term.toLowerCase()) || seen.has(key)) return false @@ -125,13 +159,16 @@ export function termCoverage(text: string, terms: readonly string[]): number { * Query and text words are compared by their singular key, so a word and its regular plural count * as one term; a term with punctuation inside it is matched as written. */ -function stemmedTerms(query: string): { +function stemmedTerms( + query: string, + minLength?: number +): { count: number /** At least half the terms, the minimum a partial match must hold. */ required: number held: (text: string) => number } { - const terms = distinctTerms(query, singularKey) + const terms = distinctTerms(query, singularKey, minLength) const keys = terms.map(singularKey) return { count: terms.length, @@ -154,7 +191,7 @@ function stemmedTerms(query: string): { export function queryRelevance( query: string ): (title: string, text: string) => [number, number, number] { - const { held } = stemmedTerms(query) + const { held } = stemmedTerms(query, MIN_RANKING_TERM_LENGTH) return (title, text) => { const inTitle = held(title) return [held(`${title}\n${text}`), inTitle, inTitle / Math.max(1, textWords(title).length)] From 053eddcb5469f56dc6eb144e98a42592e61ae198 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 16 Oct 2026 18:28:14 -0700 Subject: [PATCH 2/2] fix(search): keep two-letter terms whole and capitalized acronyms ranked singularKey folded two-letter words to one letter (OS to o, IE to i), so they matched unrelated single letters; words of two letters or fewer are now their own key. A two-letter word written in capitals (IT, US) is an acronym, so it is no longer dropped as a stopword. --- apps/sim/lib/sim-search/live/text-match.test.ts | 14 ++++++++++++++ apps/sim/lib/sim-search/live/text-match.ts | 10 ++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/sim-search/live/text-match.test.ts b/apps/sim/lib/sim-search/live/text-match.test.ts index c9c9bf78d99..bd05d59e34c 100644 --- a/apps/sim/lib/sim-search/live/text-match.test.ts +++ b/apps/sim/lib/sim-search/live/text-match.test.ts @@ -3,6 +3,7 @@ import { confluenceTextClause, holdsHalfTheTerms, jiraTextClause, + queryRelevance, } from '@/lib/sim-search/live/text-match' describe('text match clauses', () => { @@ -92,4 +93,17 @@ describe('text match clauses', () => { expect(holdsHalfTheTerms('contractor offboarding')('Contractor directory')).toBe(true) }) }) + + describe('queryRelevance', () => { + it('never folds a two-letter term to a single letter', () => { + const relevance = queryRelevance('OS performance') + expect(relevance('OS performance reference guide', '')).toEqual([2, 2, 0.5]) + expect(relevance('O(n) performance', '')).toEqual([1, 1, 1 / 3]) + }) + + it('ranks a two-letter acronym in capitals but not the same word in lowercase', () => { + expect(queryRelevance('IT onboarding')('IT onboarding', '')).toEqual([2, 2, 1]) + expect(queryRelevance('reset it')('Reset it', '')).toEqual([1, 1, 0.5]) + }) + }) }) diff --git a/apps/sim/lib/sim-search/live/text-match.ts b/apps/sim/lib/sim-search/live/text-match.ts index 5be0c17ec4d..c5dd8958455 100644 --- a/apps/sim/lib/sim-search/live/text-match.ts +++ b/apps/sim/lib/sim-search/live/text-match.ts @@ -90,6 +90,10 @@ export function significantTerms(query: string): string[] { return distinctTerms(query, (term) => term.toLowerCase()) } +/** A two-letter word written in capitals, such as `IT` or `US`, is an acronym, not a stopword. */ +const isStopword = (term: string) => + STOPWORDS.has(term.toLowerCase()) && !(term.length === 2 && term === term.toUpperCase()) + function distinctTerms( query: string, keyOf: (term: string) => string, @@ -99,7 +103,7 @@ function distinctTerms( return queryTerms(query, minLength) .filter((term) => { const key = keyOf(term) - if (STOPWORDS.has(term.toLowerCase()) || seen.has(key)) return false + if (isStopword(term) || seen.has(key)) return false seen.add(key) return true }) @@ -109,10 +113,12 @@ function distinctTerms( /** * One key for a word and its regular plural (`token`/`tokens`, `process`/`processes`). As in the * Porter stemmer, a final `-y` and `-ie(s)` fold to `i`, so `policy`/`policies` and - * `movie`/`movies` each share a key. + * `movie`/`movies` each share a key. A word of two letters or fewer is its own key, so `OS` never + * folds to the letter `o`. */ function singularKey(term: string): string { const key = term.toLowerCase() + if (key.length <= 2) return key const stripped = /[^su]s$/.test(key) ? key.slice(0, -1) : key const stem = /(?:s|x|z|ch|sh|i)e$/.test(stripped) ? stripped.slice(0, -1) : stripped return stem.replace(/y$/, 'i')