Skip to content

Commit f350490

Browse files
authored
feat(languages): real Czech & Slovenian stemmers + language-support consistency (#1033)
1 parent 1731ac0 commit f350490

13 files changed

Lines changed: 1475 additions & 29 deletions

File tree

packages/orama/src/components/tokenizer/languages.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,42 @@
1-
export const STEMMERS: Record<string, string> = {
1+
// Maps each supported language to its BCP-47 locale tag.
2+
// The keys are the canonical language names accepted by Orama (they define
3+
// `SUPPORTED_LANGUAGES` and the `Language` type); the values are passed to
4+
// `String.prototype.localeCompare` for locale-aware string sorting (see `getLocale`).
5+
// Note: these locale tags are intentionally decoupled from the file-id codes used by
6+
// `@orama/stemmers` / `@orama/stopwords` (e.g. Danish stems live in `dk.js` but the
7+
// locale is `da`), so keep them as valid BCP-47 primary subtags.
8+
export const SUPPORTED_LANGUAGE_LOCALES: Record<string, string> = {
29
arabic: 'ar',
3-
armenian: 'am',
10+
armenian: 'hy',
411
bulgarian: 'bg',
5-
czech: 'cz',
6-
danish: 'dk',
12+
czech: 'cs',
13+
danish: 'da',
714
dutch: 'nl',
815
english: 'en',
916
finnish: 'fi',
1017
french: 'fr',
1118
german: 'de',
12-
greek: 'gr',
19+
greek: 'el',
1320
hungarian: 'hu',
14-
indian: 'in',
21+
indian: 'hi',
1522
indonesian: 'id',
16-
irish: 'ie',
23+
irish: 'ga',
1724
italian: 'it',
1825
lithuanian: 'lt',
19-
nepali: 'np',
26+
nepali: 'ne',
2027
norwegian: 'no',
2128
portuguese: 'pt',
2229
romanian: 'ro',
2330
russian: 'ru',
24-
serbian: 'rs',
25-
slovenian: 'ru',
31+
serbian: 'sr',
32+
slovenian: 'sl',
2633
spanish: 'es',
27-
swedish: 'se',
34+
swedish: 'sv',
2835
tamil: 'ta',
2936
turkish: 'tr',
3037
ukrainian: 'uk',
3138
vietnamese: 'vi',
32-
sanskrit: 'sk'
39+
sanskrit: 'sa'
3340
}
3441

3542
export const SPLITTERS: Record<Language, RegExp> = {
@@ -66,10 +73,12 @@ export const SPLITTERS: Record<Language, RegExp> = {
6673
czech: /[^A-Z0-9a-zěščřžýáíéúůóťďĚŠČŘŽÝÁÍÉÓÚŮŤĎ-]+/gim
6774
}
6875

69-
export const SUPPORTED_LANGUAGES = Object.keys(STEMMERS)
76+
export const SUPPORTED_LANGUAGES = Object.keys(SUPPORTED_LANGUAGE_LOCALES)
7077

7178
export function getLocale(language: string | undefined) {
72-
return language !== undefined && SUPPORTED_LANGUAGES.includes(language) ? STEMMERS[language] : undefined
79+
return language !== undefined && SUPPORTED_LANGUAGES.includes(language)
80+
? SUPPORTED_LANGUAGE_LOCALES[language]
81+
: undefined
7382
}
7483

7584
export type Language = (typeof SUPPORTED_LANGUAGES)[number]
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import t from 'tap'
2+
3+
import { getLocale, SUPPORTED_LANGUAGES, SUPPORTED_LANGUAGE_LOCALES } from '../src/components/tokenizer/languages.js'
4+
5+
t.test('language locales', async (t) => {
6+
t.test('every supported language maps to a canonical, well-formed BCP-47 locale', async (t) => {
7+
for (const language of SUPPORTED_LANGUAGES) {
8+
const locale = getLocale(language)
9+
10+
t.type(locale, 'string', `getLocale('${language}') returns a locale`)
11+
12+
let canonical: string[] = []
13+
t.doesNotThrow(() => {
14+
canonical = Intl.getCanonicalLocales(locale)
15+
}, `'${locale}' (${language}) is a well-formed BCP-47 tag`)
16+
t.strictSame(canonical, [locale], `'${locale}' (${language}) is already in canonical form`)
17+
}
18+
})
19+
20+
t.test('specific language to locale mappings', async (t) => {
21+
const expectedLocales = {
22+
czech: 'cs',
23+
slovenian: 'sl',
24+
danish: 'da',
25+
greek: 'el',
26+
swedish: 'sv',
27+
serbian: 'sr',
28+
armenian: 'hy',
29+
sanskrit: 'sa',
30+
indian: 'hi',
31+
irish: 'ga',
32+
nepali: 'ne'
33+
}
34+
35+
for (const [language, locale] of Object.entries(expectedLocales)) {
36+
t.equal(getLocale(language), locale, `getLocale('${language}') returns '${locale}'`)
37+
}
38+
})
39+
40+
t.test('supported languages include czech and slovenian', async (t) => {
41+
t.ok(SUPPORTED_LANGUAGES.includes('czech'))
42+
t.ok(SUPPORTED_LANGUAGES.includes('slovenian'))
43+
t.strictSame(SUPPORTED_LANGUAGES, Object.keys(SUPPORTED_LANGUAGE_LOCALES))
44+
})
45+
46+
t.test('unknown or missing languages return undefined', async (t) => {
47+
t.equal(getLocale(undefined), undefined)
48+
t.equal(getLocale('klingon'), undefined)
49+
})
50+
})

packages/orama/tests/tokenizer.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import t from 'tap'
22

33
import { stemmer as bulgarianStemmer, language as bulgarianLanguage } from '@orama/stemmers/bulgarian'
4+
import { stemmer as czechStemmer, language as czechLanguage } from '@orama/stemmers/czech'
45
import { stemmer as danishStemmer, language as danishLanguage } from '@orama/stemmers/danish'
56
import { stemmer as dutchStemmer, language as dutchLanguage } from '@orama/stemmers/dutch'
67
import { stemmer as finnishStemmer, language as finnishLanguage } from '@orama/stemmers/finnish'
@@ -10,12 +11,14 @@ import { stemmer as italianStemmer, language as italianLanguage } from '@orama/s
1011
import { stemmer as norwegianStemmer, language as norwegianLanguage } from '@orama/stemmers/norwegian'
1112
import { stemmer as portugueseStemmer, language as portugueseLanguage } from '@orama/stemmers/portuguese'
1213
import { stemmer as russianStemmer, language as russianLanguage } from '@orama/stemmers/russian'
14+
import { stemmer as slovenianStemmer, language as slovenianLanguage } from '@orama/stemmers/slovenian'
1315
import { stemmer as spanishStemmer, language as spanishLanguage } from '@orama/stemmers/spanish'
1416
import { stemmer as swedishStemmer, language as swedishLanguage } from '@orama/stemmers/swedish'
1517
import { stemmer as ukrainianStemmer, language as ukrainianLanguage } from '@orama/stemmers/ukrainian'
1618
import { stemmer as tamilStemmer, language as tamilLanguage } from '@orama/stemmers/tamil'
1719
import { stemmer as vietnameseStemmer, language as vietnameseLanguage } from '@orama/stemmers/vietnamese'
1820

21+
import { stopwords as czechStopwords } from '@orama/stopwords/czech'
1922
import { stopwords as danishStopwords } from '@orama/stopwords/danish'
2023
import { stopwords as dutchStopwords } from '@orama/stopwords/dutch'
2124
import { stopwords as englishStopwords } from '@orama/stopwords/english'
@@ -26,6 +29,7 @@ import { stopwords as italianStopwords } from '@orama/stopwords/italian'
2629
import { stopwords as norwegianStopwords } from '@orama/stopwords/norwegian'
2730
import { stopwords as portugueseStopwords } from '@orama/stopwords/portuguese'
2831
import { stopwords as russianStopwords } from '@orama/stopwords/russian'
32+
import { stopwords as slovenianStopwords } from '@orama/stopwords/slovenian'
2933
import { stopwords as spanishStopwords } from '@orama/stopwords/spanish'
3034
import { stopwords as swedishStopwords } from '@orama/stopwords/swedish'
3135
import { stopwords as ukrainianStopwords } from '@orama/stopwords/ukrainian'
@@ -368,6 +372,40 @@ t.test('Tokenizer', async (t) => {
368372
t.strictSame(O2, ['има', 'първ', 'вероятност', 'да', 'се', 'случ', 'нещ', 'неочакван', 'док', 'изпълняват', 'тест'])
369373
})
370374

375+
t.test('should tokenize and stem correctly in czech', async (t) => {
376+
const tokenizer = await createTokenizer({
377+
language: czechLanguage,
378+
stemmer: czechStemmer,
379+
stopWords: czechStopwords
380+
})
381+
382+
const I1 = 'Upekla jsem nějaké koláče'
383+
const I2 = 'žáci četli knihy ve škole'
384+
385+
const O1 = tokenizer.tokenize(I1)
386+
const O2 = tokenizer.tokenize(I2)
387+
388+
t.strictSame(O1, ['upekl', 'nejak', 'kolak'])
389+
t.strictSame(O2, ['zak', 'cetl', 'knih', 'skol'])
390+
})
391+
392+
t.test('should tokenize and stem correctly in slovenian', async (t) => {
393+
const tokenizer = await createTokenizer({
394+
language: slovenianLanguage,
395+
stemmer: slovenianStemmer,
396+
stopWords: slovenianStopwords
397+
})
398+
399+
const I1 = 'Spekla sem nekaj tort'
400+
const I2 = 'otroci berejo knjige v mestih'
401+
402+
const O1 = tokenizer.tokenize(I1)
403+
const O2 = tokenizer.tokenize(I2)
404+
405+
t.strictSame(O1, ['spekl', 'tort'])
406+
t.strictSame(O2, ['otroc', 'ber', 'knjig', 'mest'])
407+
})
408+
371409
t.test('disable stemming', async (t) => {
372410
const tokenizer = await createTokenizer({ language: 'english', stemming: false, stopWords: englishStopwords })
373411

@@ -393,6 +431,50 @@ t.test('Tokenizer', async (t) => {
393431
})
394432
})
395433

434+
t.test('Czech and Slovenian stemming', async (t) => {
435+
t.test('czech inflected forms collapse to a single stem', async (t) => {
436+
for (const word of ['žák', 'žáci', 'žáky', 'žákům', 'žácích']) {
437+
t.equal(czechStemmer(word), 'žák', `${word} stems to žák`)
438+
}
439+
440+
for (const word of ['kniha', 'knihy', 'knihám', 'knihách']) {
441+
t.equal(czechStemmer(word), 'knih', `${word} stems to knih`)
442+
}
443+
444+
for (const word of ['malý', 'malá', 'malé', 'malému', 'malých']) {
445+
t.equal(czechStemmer(word), 'mal', `${word} stems to mal`)
446+
}
447+
})
448+
449+
t.test('czech short words are left unchanged', async (t) => {
450+
t.equal(czechStemmer('e'), 'e')
451+
t.equal(czechStemmer('zi'), 'zi')
452+
})
453+
454+
t.test('slovenian inflected forms collapse to a single stem', async (t) => {
455+
for (const word of ['mesto', 'mesta', 'mestu', 'mestom', 'mest', 'mestih']) {
456+
t.equal(slovenianStemmer(word), 'mest', `${word} stems to mest`)
457+
}
458+
459+
for (const word of ['hiša', 'hiše', 'hiši', 'hišo']) {
460+
t.equal(slovenianStemmer(word), 'hiš', `${word} stems to hiš`)
461+
}
462+
463+
for (const word of ['velik', 'velika', 'veliko', 'velikega', 'velikih']) {
464+
t.equal(slovenianStemmer(word), 'velik', `${word} stems to velik`)
465+
}
466+
467+
for (const word of ['delati', 'delam', 'delaš', 'dela', 'delamo', 'delajo']) {
468+
t.equal(slovenianStemmer(word), 'del', `${word} stems to del`)
469+
}
470+
})
471+
472+
t.test('slovenian stemming keeps distinct words distinct', async (t) => {
473+
t.not(slovenianStemmer('mesto'), slovenianStemmer('meso'), 'mesto and meso must not collapse')
474+
t.not(slovenianStemmer('letalo'), slovenianStemmer('leto'), 'letalo and leto must not collapse')
475+
})
476+
})
477+
396478
t.test('Custom stop-words rules', async (t) => {
397479
t.test('custom array of stop-words', async (t) => {
398480
const tokenizer = await createTokenizer({

packages/stemmers/README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
Orama can analyze the input and perform a `stemming` operation, which allows the engine to perform more optimized queries, as well as save indexing space.
44

5-
Right now, Orama supports 30 languages and stemmers out of the box:
5+
<!-- LANGUAGES:START -->
6+
Right now, Orama supports 31 languages and stemmers out of the box:
67

78
- Arabic
89
- Armenian
910
- Bulgarian
10-
- Chinese (Mandarin - stemmer not supported)
11+
- Czech
1112
- Danish
1213
- Dutch
1314
- English
@@ -20,7 +21,7 @@ Right now, Orama supports 30 languages and stemmers out of the box:
2021
- Indonesian
2122
- Irish
2223
- Italian
23-
- Japanese
24+
- Lithuanian
2425
- Nepali
2526
- Norwegian
2627
- Portuguese
@@ -34,6 +35,10 @@ Right now, Orama supports 30 languages and stemmers out of the box:
3435
- Tamil
3536
- Turkish
3637
- Ukrainian
38+
- Vietnamese
39+
<!-- LANGUAGES:END -->
40+
41+
Chinese (Mandarin) and Japanese are supported through dedicated tokenizers (`@orama/tokenizers`) and stop-word removal (`@orama/stopwords`), not through stemming.
3742

3843
```js
3944
import { create } from '@orama/orama'

0 commit comments

Comments
 (0)