Skip to content

Commit e16fd83

Browse files
authored
fix(search): count two-letter query terms when ranking across sources (#9158)
* 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. * 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.
1 parent 2af5443 commit e16fd83

4 files changed

Lines changed: 82 additions & 9 deletions

File tree

‎apps/sim/lib/knowledge/search/snippet.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,13 @@ export function stripLeadingHeaders(content: string): string {
4444
* The query's terms worth matching, longest first so the most specific one
4545
* wins: quotes and other search syntax around a term are not part of it.
4646
*/
47-
export function queryTerms(query: string | undefined): string[] {
47+
export function queryTerms(query: string | undefined, minLength = MIN_TERM_LENGTH): string[] {
4848
return [
4949
...new Set(
5050
(query ?? '')
5151
.split(/\s+/)
5252
.map((term) => term.replace(/^["'“”‘’(]+|["'“”‘’),.;:!?]+$/g, '').trim())
53-
.filter((term) => term.length >= MIN_TERM_LENGTH)
53+
.filter((term) => term.length >= minLength)
5454
),
5555
].sort((a, b) => b.length - a.length)
5656
}

‎apps/sim/lib/sim-search/live/application.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,22 @@ describe('authorized live retrieval', () => {
586586
'sheet',
587587
])
588588
})
589+
it('counts two-letter query terms, so a page named for the query leads one sharing a word', async () => {
590+
mocks.search.mockResolvedValue({
591+
documents: [
592+
{ ...document, id: 'generic', title: 'Agents', url: 'https://docs.google.com/generic' },
593+
{ ...document, id: 'named', title: 'AI agents', url: 'https://docs.google.com/named' },
594+
],
595+
})
596+
const { results } = await searchLiveKnowledge.execute({
597+
principal,
598+
input: { ...input, query: 'AI agents' },
599+
})
600+
expect(results.map((row) => decodeLiveReference(row.documentId).id)).toEqual([
601+
'named',
602+
'generic',
603+
])
604+
})
589605
it('applies date filters before spending provider verification', async () => {
590606
const verify = vi.fn(async () => true)
591607
mocks.service.mockResolvedValue({ policy: defaultLiveSearchPolicy(), verify, partial: false })

‎apps/sim/lib/sim-search/live/text-match.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
confluenceTextClause,
44
holdsHalfTheTerms,
55
jiraTextClause,
6+
queryRelevance,
67
} from '@/lib/sim-search/live/text-match'
78

89
describe('text match clauses', () => {
@@ -92,4 +93,17 @@ describe('text match clauses', () => {
9293
expect(holdsHalfTheTerms('contractor offboarding')('Contractor directory')).toBe(true)
9394
})
9495
})
96+
97+
describe('queryRelevance', () => {
98+
it('never folds a two-letter term to a single letter', () => {
99+
const relevance = queryRelevance('OS performance')
100+
expect(relevance('OS performance reference guide', '')).toEqual([2, 2, 0.5])
101+
expect(relevance('O(n) performance', '')).toEqual([1, 1, 1 / 3])
102+
})
103+
104+
it('ranks a two-letter acronym in capitals but not the same word in lowercase', () => {
105+
expect(queryRelevance('IT onboarding')('IT onboarding', '')).toEqual([2, 2, 1])
106+
expect(queryRelevance('reset it')('Reset it', '')).toEqual([1, 1, 0.5])
107+
})
108+
})
95109
})

‎apps/sim/lib/sim-search/live/text-match.ts‎

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,34 +10,64 @@ export type TextMatch = 'all' | 'any'
1010
/** Terms an any-term query sends; more would make the provider query long without adding recall. */
1111
const MAX_ANY_TERMS = 8
1212

13+
/**
14+
* Ranking also counts two-letter words such as `AI` or `VM`: every title word counts toward the
15+
* title's share of query terms, so skipping them would rank a page named `AI agents` below one
16+
* named `Agents` for the query `AI agents`.
17+
*/
18+
const MIN_RANKING_TERM_LENGTH = 2
19+
1320
/** Words that match nearly every page, so they would make an any-term query return noise. */
1421
const STOPWORDS = new Set([
1522
'about',
1623
'all',
24+
'am',
25+
'an',
1726
'and',
1827
'any',
1928
'are',
29+
'as',
30+
'at',
31+
'be',
32+
'by',
2033
'can',
2134
'could',
2235
'did',
36+
'do',
2337
'does',
2438
'for',
2539
'from',
2640
'had',
2741
'has',
2842
'have',
43+
'he',
2944
'how',
45+
'if',
46+
'in',
3047
'into',
48+
'is',
49+
'it',
3150
'its',
51+
'me',
52+
'my',
53+
'no',
3254
'not',
55+
'of',
56+
'on',
57+
'or',
3358
'our',
3459
'should',
60+
'so',
3561
'that',
3662
'the',
3763
'their',
3864
'there',
3965
'this',
66+
'to',
67+
'up',
68+
'us',
4069
'was',
70+
'we',
4171
'were',
4272
'what',
4373
'when',
@@ -60,12 +90,20 @@ export function significantTerms(query: string): string[] {
6090
return distinctTerms(query, (term) => term.toLowerCase())
6191
}
6292

63-
function distinctTerms(query: string, keyOf: (term: string) => string): string[] {
93+
/** A two-letter word written in capitals, such as `IT` or `US`, is an acronym, not a stopword. */
94+
const isStopword = (term: string) =>
95+
STOPWORDS.has(term.toLowerCase()) && !(term.length === 2 && term === term.toUpperCase())
96+
97+
function distinctTerms(
98+
query: string,
99+
keyOf: (term: string) => string,
100+
minLength?: number
101+
): string[] {
64102
const seen = new Set<string>()
65-
return queryTerms(query)
103+
return queryTerms(query, minLength)
66104
.filter((term) => {
67105
const key = keyOf(term)
68-
if (STOPWORDS.has(term.toLowerCase()) || seen.has(key)) return false
106+
if (isStopword(term) || seen.has(key)) return false
69107
seen.add(key)
70108
return true
71109
})
@@ -75,10 +113,12 @@ function distinctTerms(query: string, keyOf: (term: string) => string): string[]
75113
/**
76114
* One key for a word and its regular plural (`token`/`tokens`, `process`/`processes`). As in the
77115
* Porter stemmer, a final `-y` and `-ie(s)` fold to `i`, so `policy`/`policies` and
78-
* `movie`/`movies` each share a key.
116+
* `movie`/`movies` each share a key. A word of two letters or fewer is its own key, so `OS` never
117+
* folds to the letter `o`.
79118
*/
80119
function singularKey(term: string): string {
81120
const key = term.toLowerCase()
121+
if (key.length <= 2) return key
82122
const stripped = /[^su]s$/.test(key) ? key.slice(0, -1) : key
83123
const stem = /(?:s|x|z|ch|sh|i)e$/.test(stripped) ? stripped.slice(0, -1) : stripped
84124
return stem.replace(/y$/, 'i')
@@ -125,13 +165,16 @@ export function termCoverage(text: string, terms: readonly string[]): number {
125165
* Query and text words are compared by their singular key, so a word and its regular plural count
126166
* as one term; a term with punctuation inside it is matched as written.
127167
*/
128-
function stemmedTerms(query: string): {
168+
function stemmedTerms(
169+
query: string,
170+
minLength?: number
171+
): {
129172
count: number
130173
/** At least half the terms, the minimum a partial match must hold. */
131174
required: number
132175
held: (text: string) => number
133176
} {
134-
const terms = distinctTerms(query, singularKey)
177+
const terms = distinctTerms(query, singularKey, minLength)
135178
const keys = terms.map(singularKey)
136179
return {
137180
count: terms.length,
@@ -154,7 +197,7 @@ function stemmedTerms(query: string): {
154197
export function queryRelevance(
155198
query: string
156199
): (title: string, text: string) => [number, number, number] {
157-
const { held } = stemmedTerms(query)
200+
const { held } = stemmedTerms(query, MIN_RANKING_TERM_LENGTH)
158201
return (title, text) => {
159202
const inTitle = held(title)
160203
return [held(`${title}\n${text}`), inTitle, inTitle / Math.max(1, textWords(title).length)]

0 commit comments

Comments
 (0)