Skip to content

Commit d01f2c6

Browse files
committed
fix(plugin-match-highlight): record a position for every token of a multi-token word
recursivePositionInsertion kept only the first token of each matched word (;[token] = orama.tokenizer.tokenize(word)). CJK text has no word spaces, so a whole run is matched as one word here that the tokenizer splits into several tokens, leaving every later token without a recorded position and unhighlightable even though search matches it. searchWithHighlight already iterates the full token array on the query side. Record a position for each token; the recorded span stays the matched word, so single-token (Latin) behavior is unchanged.
1 parent 1731ac0 commit d01f2c6

2 files changed

Lines changed: 58 additions & 13 deletions

File tree

packages/plugin-match-highlight/src/index.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,20 +71,22 @@ async function recursivePositionInsertion<T extends AnyOrama, ResultDocument = T
7171
let regExResult: RegExpExecArray | null
7272
while ((regExResult = wordRegEx.exec(text)) !== null) {
7373
const word = regExResult[0].toLowerCase()
74-
const key = `${orama.tokenizer.language}:${word}`
75-
let token: string
76-
if (orama.tokenizer.normalizationCache.has(key)) {
77-
token = orama.tokenizer.normalizationCache.get(key)!
78-
} else {
79-
;[token] = orama.tokenizer.tokenize(word)
80-
orama.tokenizer.normalizationCache.set(key, token)
81-
}
82-
if (!Array.isArray(orama.data.positions[id][propName][token])) {
83-
orama.data.positions[id][propName][token] = []
84-
}
8574
const start = regExResult.index
8675
const length = regExResult[0].length
87-
orama.data.positions[id][propName][token].push({ start, length })
76+
// A matched word can yield more than one token: CJK text has no word
77+
// spaces, so a whole run is one word here that the tokenizer splits into
78+
// many. Record every token instead of only the first one, otherwise the
79+
// other tokens have no position and cannot be highlighted even though
80+
// search matches them. This mirrors searchWithHighlight, which already
81+
// iterates the full token array.
82+
const tokens = orama.tokenizer.tokenize(word)
83+
for (const token of tokens) {
84+
if (!token) continue
85+
if (!Array.isArray(orama.data.positions[id][propName][token])) {
86+
orama.data.positions[id][propName][token] = []
87+
}
88+
orama.data.positions[id][propName][token].push({ start, length })
89+
}
8890
}
8991
}
9092
}

packages/plugin-match-highlight/test/index.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { create, insert } from '@orama/orama'
1+
import { create, insert, Tokenizer } from '@orama/orama'
22
import t from 'tap'
33
import {
44
afterInsert,
@@ -143,3 +143,46 @@ t.test('should correctly save and load data with positions', async (t) => {
143143
text: { hello: [{ start: 0, length: 5 }], world: [{ start: 6, length: 5 }] }
144144
})
145145
})
146+
147+
// A minimal word-granularity CJK tokenizer, equivalent to @orama/tokenizers/mandarin.
148+
// CJK text has no word spaces, so a whole run is matched as a single word by the
149+
// position indexer and the tokenizer splits it into several tokens.
150+
function createCjkTokenizer(): Tokenizer {
151+
const segmenter = new Intl.Segmenter('zh-CN', { granularity: 'word' })
152+
return {
153+
language: 'mandarin',
154+
normalizationCache: new Map(),
155+
tokenize(input: string): string[] {
156+
if (typeof input !== 'string') return [input]
157+
const tokens: string[] = []
158+
for (const segment of segmenter.segment(input)) {
159+
if (segment.isWordLike) tokens.push(segment.segment)
160+
}
161+
return tokens
162+
}
163+
}
164+
}
165+
166+
t.test('it should record a position for every token of a multi-token word (CJK)', async (t) => {
167+
const tokenizer = createCjkTokenizer()
168+
const db = create({
169+
schema: { text: 'string' } as const,
170+
components: { tokenizer },
171+
plugins: [{ name: 'highlight', afterInsert }]
172+
})
173+
174+
const text = '我喜欢编程'
175+
const expected = new Set(tokenizer.tokenize(text))
176+
t.ok(expected.size > 1, 'the tokenizer splits the run into multiple tokens')
177+
178+
const id = await insert(db, { text })
179+
const recorded = (db as OramaWithHighlight<typeof db>).data.positions[id].text
180+
181+
t.same(new Set(Object.keys(recorded)), expected, 'every token has a recorded position')
182+
183+
// a token other than the first one is found by search and can be highlighted
184+
const lastToken = [...expected][expected.size - 1]
185+
const results = await searchWithHighlight(db, { term: lastToken })
186+
t.ok(results.hits.length > 0, 'search finds the document')
187+
t.ok(Array.isArray(results.hits[0].positions.text[lastToken]), 'a non-leading token is highlightable')
188+
})

0 commit comments

Comments
 (0)