Skip to content

Commit d184b20

Browse files
dayongkrthatjuan
andauthored
feat(tokenizers): add experimental Korean tokenizer (#1018)
Co-authored-by: thatjuan <juan@cxdmail.com>
1 parent 6af0f8b commit d184b20

5 files changed

Lines changed: 190 additions & 17 deletions

File tree

packages/tokenizers/README.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,28 @@
33
This package provides support for additional tokenizers for the Orama Search Engine.
44

55
Available tokenizers:
6+
67
- Chinese (Mandarin, experimental)
78
- Japanese (experimental)
8-
- Korean (work in progress)
9+
- Korean (experimental)
910

1011
Usage:
1112

1213
```js
13-
import { create } from '@orama/orama'
14-
import { createTokenizer } from '@orama/tokenizers/mandarin'
14+
import { create } from "@orama/orama";
15+
import { createTokenizer } from "@orama/tokenizers/mandarin";
1516

1617
const db = await create({
1718
schema: {
18-
myProperty: 'string',
19-
anotherProperty: 'number'
19+
myProperty: "string",
20+
anotherProperty: "number",
2021
},
2122
components: {
22-
tokenizer: await createTokenizer()
23-
}
24-
})
23+
tokenizer: await createTokenizer(),
24+
},
25+
});
2526
```
2627

2728
# License
28-
[Apache 2.0](/LICENSE.md)
29+
30+
[Apache 2.0](/LICENSE.md)

packages/tokenizers/package.json

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@
2525
"default": "./dist/commonjs/mandarin.js"
2626
}
2727
},
28+
"./korean": {
29+
"import": {
30+
"types": "./dist/esm/korean.d.ts",
31+
"default": "./dist/esm/korean.js"
32+
},
33+
"require": {
34+
"types": "./dist/commonjs/korean.d.ts",
35+
"default": "./dist/commonjs/korean.js"
36+
}
37+
},
2838
"./package.json": "./package.json"
2939
},
3040
"dependencies": {
@@ -42,7 +52,7 @@
4252
},
4353
"scripts": {
4454
"build": "tshy",
45-
"test": "tsx ./tests/japanese.test.ts && tsx ./tests/mandarin.test.ts"
55+
"test": "tsx ./tests/japanese.test.ts && tsx ./tests/mandarin.test.ts && tsx ./tests/korean.test.ts"
4656
},
4757
"keywords": [
4858
"full-text search",
@@ -54,7 +64,8 @@
5464
"stemming",
5565
"tokenizers",
5666
"mandarin",
57-
"chinese"
67+
"chinese",
68+
"korean"
5869
],
5970
"author": {
6071
"name": "Michele Riva",
@@ -74,6 +85,7 @@
7485
"exports": {
7586
"./japanese": "./src/japanese.ts",
7687
"./mandarin": "./src/mandarin.ts",
88+
"./korean": "./src/korean.ts",
7789
"./package.json": "./package.json"
7890
}
7991
},
@@ -82,4 +94,4 @@
8294
"tshy": "^3.0.2",
8395
"tsx": "^4.19.2"
8496
}
85-
}
97+
}

packages/tokenizers/src/index.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { createTokenizer as createJapaneseTokenizer } from "./japanese.js";
2-
import { createTokenizer as createMandarinTokenizer } from "./mandarin.js";
1+
import { createTokenizer as createJapaneseTokenizer } from './japanese.js'
2+
import { createTokenizer as createKoreanTokenizer } from './korean.js'
3+
import { createTokenizer as createMandarinTokenizer } from './mandarin.js'
34

45
export default {
5-
japanese: createJapaneseTokenizer,
6-
mandarin: createMandarinTokenizer,
7-
}
6+
japanese: createJapaneseTokenizer,
7+
korean: createKoreanTokenizer,
8+
mandarin: createMandarinTokenizer
9+
}

packages/tokenizers/src/korean.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { DefaultTokenizer, DefaultTokenizerConfig } from '@orama/orama'
2+
import { normalizeToken } from '@orama/orama/internals'
3+
4+
const tokenizerLanguage = 'korean'
5+
6+
type TLanguage = typeof tokenizerLanguage
7+
8+
type KoreanTokenizerConfig = DefaultTokenizerConfig & {
9+
language: TLanguage
10+
}
11+
12+
const defaultConfig: KoreanTokenizerConfig = {
13+
language: tokenizerLanguage
14+
}
15+
16+
const segmenter = new Intl.Segmenter('ko', { granularity: 'word' })
17+
18+
/* c8 ignore next 10 */
19+
function trim(text: string[]): string[] {
20+
while (text[text.length - 1] === '') {
21+
text.pop()
22+
}
23+
while (text[0] === '') {
24+
text.shift()
25+
}
26+
return text
27+
}
28+
29+
function tokenize(text: string): string[] {
30+
const segments = segmenter.segment(text)
31+
32+
const tokens: string[] = []
33+
for (const segment of segments) {
34+
if (segment.isWordLike) {
35+
tokens.push(segment.segment)
36+
}
37+
}
38+
39+
return tokens
40+
}
41+
42+
function tokenizeInternal(this: DefaultTokenizer, input: string, language?: TLanguage, prop?: string): string[] {
43+
/* c8 ignore next 3 */
44+
if (typeof input !== 'string') {
45+
return [input]
46+
}
47+
48+
let tokens: string[]
49+
if (prop && this?.tokenizeSkipProperties?.has(prop)) {
50+
// @ts-ignore
51+
tokens = [this?.normalizeToken?.bind(this, prop ?? '')(input)]
52+
} else {
53+
tokens = tokenize(input)
54+
}
55+
56+
const trimTokens = trim(tokens)
57+
58+
if (!this.allowDuplicates) {
59+
return Array.from(new Set(trimTokens))
60+
}
61+
62+
return trimTokens
63+
}
64+
65+
export function createTokenizer(config: KoreanTokenizerConfig = defaultConfig): DefaultTokenizer {
66+
const tokenizerConfig = {
67+
tokenize: tokenizeInternal,
68+
language: config.language,
69+
stemmerSkipProperties: new Set(config.stemmerSkipProperties ? [config.stemmerSkipProperties].flat() : []),
70+
tokenizeSkipProperties: new Set(config.tokenizeSkipProperties ? [config.tokenizeSkipProperties].flat() : []),
71+
stopWords: config.stopWords as string[] | undefined,
72+
allowDuplicates: Boolean(config.allowDuplicates),
73+
normalizeToken,
74+
normalizationCache: new Map()
75+
}
76+
77+
// @ts-ignore
78+
tokenizerConfig.tokenize = tokenizeInternal.bind(tokenizeInternal)
79+
80+
return tokenizerConfig
81+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import t from 'tap'
2+
import { create, insert, search } from '@orama/orama'
3+
import { createTokenizer } from '../src/korean.js'
4+
5+
const db = create({
6+
schema: {
7+
name: 'string'
8+
},
9+
components: {
10+
tokenizer: createTokenizer()
11+
}
12+
})
13+
14+
function getHitsNames(hits) {
15+
return hits.map((hit) => hit.document.name)
16+
}
17+
18+
t.test('Korean tokenizer', async (t) => {
19+
await insert(db, { name: '서울' }) // Seoul
20+
await insert(db, { name: '부산' }) // Busan
21+
await insert(db, { name: '대구' }) // Daegu
22+
await insert(db, { name: '인천' }) // Incheon
23+
await insert(db, { name: '광주' }) // Gwangju
24+
await insert(db, { name: '수원' }) // Suwon
25+
await insert(db, { name: '포항' }) // Pohang
26+
await insert(db, { name: '서울대학교' }) // Seoul National University
27+
await insert(db, { name: '부산대학교' }) // Pusan National University
28+
await insert(db, { name: '포항공과대학교' }) // Pohang University of Science and Technology
29+
await insert(db, { name: '대전 울산 세종' }) // space-separated multi-word string (Daejeon Ulsan Sejong)
30+
31+
// Intl.Segmenter('ko', { granularity: 'word' }) segments Korean on whitespace (UAX#29), so
32+
// space-less compounds such as '서울대학교' stay a single token and are NOT split into '서울' + '대학교'
33+
// (unlike the Japanese/Mandarin tokenizers, where dictionary segmentation splits e.g. '東京大学' into
34+
// '東京' + '大学'). This is an experimental limitation of the Korean tokenizer. The '서울'/'부산'/'포항'
35+
// searches below still return the compound because Orama matches the radix index by prefix
36+
// (default exact: false): '서울' is a prefix of the '서울대학교' token.
37+
const resultsSeoul = await search(db, { term: '서울', threshold: 0 })
38+
39+
t.equal(resultsSeoul.count, 2)
40+
t.equal(getHitsNames(resultsSeoul.hits).join(', '), '서울, 서울대학교')
41+
42+
const resultsBusan = await search(db, { term: '부산', threshold: 0 })
43+
44+
t.equal(resultsBusan.count, 2)
45+
t.equal(getHitsNames(resultsBusan.hits).join(', '), '부산, 부산대학교')
46+
47+
const resultsGwangju = await search(db, { term: '광주', threshold: 0 })
48+
49+
t.equal(resultsGwangju.count, 1)
50+
t.equal(getHitsNames(resultsGwangju.hits).join(', '), '광주')
51+
52+
const resultsIncheon = await search(db, { term: '인천', threshold: 0 })
53+
54+
t.equal(resultsIncheon.count, 1)
55+
t.equal(getHitsNames(resultsIncheon.hits).join(', '), '인천')
56+
57+
const resultsPohang = await search(db, { term: '포항', threshold: 0 })
58+
59+
t.equal(resultsPohang.count, 2)
60+
t.equal(getHitsNames(resultsPohang.hits).join(', '), '포항, 포항공과대학교')
61+
62+
// Searching the shared suffix '대학교' (university) returns nothing: the compounds are indexed as
63+
// whole tokens ('서울대학교', '부산대학교', '포항공과대학교'), so the suffix is not a prefix of any token.
64+
// Contrast japanese.test.ts, where '大学' matches all three universities because the Japanese
65+
// tokenizer dictionary-splits the compounds. This assertion locks in the no-segmentation behavior.
66+
const resultsUniversity = await search(db, { term: '대학교', threshold: 0 })
67+
68+
t.equal(resultsUniversity.count, 0)
69+
70+
// Whitespace word segmentation works: the space-separated '대전 울산 세종' is tokenized into
71+
// '대전' / '울산' / '세종', so searching the middle word finds that document.
72+
const resultsUlsan = await search(db, { term: '울산', threshold: 0 })
73+
74+
t.equal(resultsUlsan.count, 1)
75+
t.equal(getHitsNames(resultsUlsan.hits).join(', '), '대전 울산 세종')
76+
})

0 commit comments

Comments
 (0)