@cachly-dev/mcp-server 0.9.17 → 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/search.ts ADDED
@@ -0,0 +1,1781 @@
1
+ import { Redis } from 'ioredis';
2
+
3
+ // ── Search Engine (BM25+ with enhancements, works without embedding) ──────────
4
+ //
5
+ // Features beyond standard BM25:
6
+ // • BM25+ (delta=1) — fixes BM25's "long document penalty" bug
7
+ // • Bigram proximity boost — adjacent query terms in doc score 2× more
8
+ // • Recency boost — newer entries rank higher (exponential decay, 7-day half-life)
9
+ // • Multi-query splitting — numbered lists, semicolons, conjunctions
10
+ // • Fuzzy matching with Levenshtein distance ≤ 2
11
+ // • Multilingual stopwords (EN, DE, FR, ES, IT, PT, ZH, JA, KO, AR, HE)
12
+ // • CJK character bigram extraction — handles Chinese, Japanese, Korean
13
+ // • RTL language support (Arabic, Hebrew) — word tokenization + light stemming
14
+ // • Romanization matching — katakana → romaji tokens for romaji queries
15
+ // • Cross-language retrieval — tech term synonyms EN↔JA↔ZH↔KO↔AR↔HE
16
+ // • Pipeline Redis reads for performance
17
+ //
18
+
19
+ /**
20
+ * Stopwords — filtered out during tokenization.
21
+ * Covers: English, German, French, Spanish, Italian, Portuguese,
22
+ * Chinese (Simplified + Traditional), Japanese, Korean.
23
+ * Keeps the index small and scores meaningful.
24
+ */
25
+ const STOPWORDS = new Set([
26
+ // ── English ──
27
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
28
+ 'have', 'has', 'had', 'having', 'do', 'does', 'did', 'doing',
29
+ 'will', 'would', 'could', 'should', 'may', 'might', 'shall', 'can',
30
+ 'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as',
31
+ 'into', 'through', 'during', 'before', 'after', 'above', 'below',
32
+ 'between', 'about', 'against', 'among', 'around', 'without', 'within',
33
+ 'along', 'across', 'behind', 'beyond', 'upon', 'toward', 'towards',
34
+ 'and', 'but', 'or', 'not', 'no', 'nor', 'so', 'if', 'then', 'than',
35
+ 'too', 'very', 'quite', 'rather', 'just', 'also', 'only', 'even',
36
+ 'it', 'its', 'this', 'that', 'these', 'those', 'here', 'there',
37
+ 'my', 'your', 'his', 'her', 'our', 'their', 'mine', 'yours', 'ours',
38
+ 'what', 'which', 'who', 'whom', 'whose', 'how', 'when', 'where', 'why',
39
+ 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'much', 'many',
40
+ 'other', 'some', 'such', 'own', 'same', 'any', 'either', 'neither',
41
+ 'been', 'being', 'because', 'until', 'while', 'once', 'again', 'further',
42
+ 'already', 'always', 'never', 'sometimes', 'often', 'still', 'yet',
43
+ // ── German ──
44
+ 'der', 'die', 'das', 'den', 'dem', 'des', 'ein', 'eine', 'einen',
45
+ 'einem', 'einer', 'eines', 'und', 'oder', 'aber', 'denn', 'weil',
46
+ 'ist', 'sind', 'war', 'waren', 'sein', 'wird', 'werden', 'wurde',
47
+ 'hat', 'haben', 'hatte', 'hatten', 'kann', 'können', 'konnte',
48
+ 'soll', 'sollen', 'sollte', 'muss', 'müssen', 'musste', 'darf',
49
+ 'mag', 'möchte', 'wollen', 'wollte', 'würde', 'könnte', 'sollte',
50
+ 'mit', 'für', 'auf', 'von', 'aus', 'bei', 'nach', 'über', 'unter',
51
+ 'vor', 'hinter', 'neben', 'zwischen', 'durch', 'gegen', 'ohne',
52
+ 'um', 'bis', 'seit', 'während', 'wegen', 'trotz', 'statt',
53
+ 'wie', 'was', 'wer', 'wen', 'wem', 'wessen', 'wo', 'wann', 'warum',
54
+ 'nicht', 'noch', 'auch', 'schon', 'nur', 'sehr', 'mehr', 'viel',
55
+ 'alle', 'jeder', 'jede', 'jedes', 'dieser', 'diese', 'dieses',
56
+ 'jener', 'jene', 'jenes', 'mein', 'dein', 'sein', 'ihr', 'unser',
57
+ 'euer', 'kein', 'keine', 'sich', 'mir', 'dir', 'ihm', 'uns', 'euch',
58
+ 'ich', 'du', 'er', 'sie', 'es', 'wir', 'ihr', 'man',
59
+ 'hier', 'dort', 'da', 'dann', 'also', 'doch', 'mal', 'eben', 'ganz',
60
+ // ── French ──
61
+ 'le', 'la', 'les', 'un', 'une', 'des', 'du', 'de', 'au', 'aux',
62
+ 'et', 'ou', 'mais', 'donc', 'car', 'ni', 'que', 'qui', 'quoi',
63
+ 'est', 'sont', 'était', 'ont', 'avoir', 'être', 'fait', 'faire',
64
+ 'pour', 'par', 'avec', 'dans', 'sur', 'sous', 'entre', 'vers',
65
+ 'chez', 'sans', 'avant', 'après', 'pendant', 'depuis', 'contre',
66
+ 'ce', 'cette', 'ces', 'mon', 'ton', 'son', 'notre', 'votre', 'leur',
67
+ 'je', 'tu', 'il', 'elle', 'nous', 'vous', 'ils', 'elles', 'on',
68
+ 'ne', 'pas', 'plus', 'très', 'bien', 'aussi', 'tout', 'tous', 'toute',
69
+ 'même', 'autre', 'quel', 'quelle', 'comment', 'quand', 'où', 'pourquoi',
70
+ // ── Spanish ──
71
+ 'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas', 'del', 'al',
72
+ 'lo', 'que', 'en', 'es', 'por', 'con', 'para', 'como', 'pero', 'más',
73
+ 'fue', 'ser', 'hay', 'está', 'han', 'son', 'tiene', 'había', 'era',
74
+ 'su', 'sus', 'este', 'esta', 'estos', 'estas', 'ese', 'esa', 'esos',
75
+ 'mi', 'tu', 'yo', 'él', 'ella', 'nosotros', 'ellos', 'ellas', 'usted',
76
+ 'no', 'ya', 'sí', 'sin', 'sobre', 'entre', 'hasta', 'desde', 'donde',
77
+ 'muy', 'todo', 'toda', 'todos', 'cada', 'otro', 'otra', 'otros',
78
+ 'cuando', 'porque', 'aunque', 'también', 'solo', 'después', 'antes',
79
+ // ── Italian ──
80
+ 'il', 'lo', 'la', 'li', 'le', 'gli', 'uno', 'una', 'dei', 'del',
81
+ 'che', 'di', 'da', 'per', 'con', 'tra', 'fra', 'sul', 'nel', 'al',
82
+ 'è', 'sono', 'ha', 'hanno', 'era', 'essere', 'fare', 'fatto', 'stato',
83
+ 'suo', 'sua', 'suoi', 'questo', 'questa', 'questi', 'quello', 'quella',
84
+ 'io', 'tu', 'lui', 'lei', 'noi', 'voi', 'loro', 'ci', 'si',
85
+ 'non', 'più', 'molto', 'anche', 'solo', 'tutto', 'tutti', 'ogni',
86
+ 'come', 'dove', 'quando', 'perché', 'ancora', 'già', 'sempre', 'mai',
87
+ // ── Portuguese ──
88
+ 'um', 'uma', 'uns', 'umas', 'do', 'da', 'dos', 'das', 'no', 'na',
89
+ 'ao', 'aos', 'em', 'por', 'com', 'para', 'sem', 'sob', 'sobre',
90
+ 'que', 'se', 'mas', 'ou', 'como', 'mais', 'entre', 'até', 'desde',
91
+ 'é', 'são', 'foi', 'tem', 'ser', 'ter', 'estar', 'fazer', 'havia',
92
+ 'seu', 'sua', 'seus', 'suas', 'este', 'esta', 'esse', 'essa', 'aquele',
93
+ 'eu', 'tu', 'ele', 'ela', 'nós', 'eles', 'elas', 'você', 'vocês',
94
+ 'não', 'já', 'sim', 'bem', 'muito', 'também', 'ainda', 'sempre',
95
+ 'todo', 'toda', 'todos', 'cada', 'outro', 'outra', 'quando', 'porque',
96
+ // ── Chinese (Simplified + Traditional) — high-frequency function characters ──
97
+ '的', '了', '是', '在', '不', '和', '我', '他', '这', '中', '大', '为',
98
+ '上', '个', '国', '以', '要', '就', '出', '说', '们', '有', '来', '到',
99
+ '时', '地', '年', '得', '着', '那', '过', '后', '还', '与', '也', '可',
100
+ '于', '从', '但', '而', '被', '把', '让', '使', '对', '很', '都', '一',
101
+ '会', '没', '人', '它', '这个', '那个', '什么', '如果', '因为', '所以',
102
+ '已经', '可以', '这些', '那些', '我们', '他们', '她们', '它们',
103
+ // ── Japanese — common hiragana particles and auxiliary verbs ──
104
+ 'の', 'は', 'が', 'を', 'に', 'で', 'と', 'も', 'や', 'か', 'な', 'ね',
105
+ 'よ', 'わ', 'て', 'い', 'う', 'え', 'お', 'き', 'く', 'け', 'こ', 'さ',
106
+ 'し', 'す', 'せ', 'そ', 'た', 'ち', 'つ', 'ぬ', 'ん', 'から', 'まで',
107
+ 'より', 'へ', 'です', 'ます', 'ない', 'ある', 'いる', 'する', 'こと',
108
+ 'もの', 'ので', 'では', 'には', 'との', 'への', 'から', 'まで',
109
+ // ── Korean — common particles and auxiliary forms ──
110
+ '이', '가', '은', '는', '을', '를', '의', '에', '와', '과', '도',
111
+ '로', '에서', '한', '하', '있', '없', '것', '수', '않', '들',
112
+ '이다', '하다', '이고', '이며', '라고', '에게', '에서', '으로',
113
+ // ── Arabic — high-frequency function words & particles ──
114
+ 'في', 'من', 'إلى', 'على', 'عن', 'مع', 'هذا', 'هذه', 'ذلك', 'تلك',
115
+ 'الذي', 'التي', 'الذين', 'اللواتي', 'هو', 'هي', 'هم', 'هن', 'نحن',
116
+ 'أنت', 'أنتم', 'أنا', 'كان', 'كانت', 'كانوا', 'يكون', 'تكون',
117
+ 'أن', 'إن', 'لأن', 'لكن', 'أو', 'بل', 'ثم', 'حتى', 'إذا', 'كي',
118
+ 'قد', 'لقد', 'لم', 'لن', 'ما', 'لا', 'ليس', 'غير', 'بعض', 'كل',
119
+ 'جميع', 'أي', 'كيف', 'متى', 'أين', 'لماذا', 'ماذا', 'هل', 'ال',
120
+ 'و', 'ف', 'ب', 'ل', 'ك', 'يا', 'أم', 'إلا', 'عند', 'بين',
121
+ // ── Hebrew — common particles, pronouns, conjunctions ──
122
+ 'של', 'את', 'אל', 'על', 'עם', 'לא', 'הוא', 'היא', 'הם', 'הן',
123
+ 'אני', 'אתה', 'אנחנו', 'אתם', 'כי', 'אם', 'אבל', 'גם', 'כבר',
124
+ 'רק', 'עוד', 'יש', 'אין', 'מה', 'זה', 'זאת', 'אלה', 'כל', 'כן',
125
+ 'לו', 'לה', 'להם', 'בו', 'בה', 'בהם', 'שם', 'כך', 'כן', 'מי',
126
+ 'אשר', 'אחרי', 'לפני', 'תחת', 'בין', 'מאז', 'עד', 'כמו', 'אז',
127
+ // ── Farsi/Persian — common function words, prepositions, pronouns ──
128
+ 'در', 'از', 'به', 'با', 'که', 'این', 'آن', 'را', 'است',
129
+ 'بود', 'باشد', 'شد', 'شده', 'می', 'نه', 'نیست', 'هم', 'هر',
130
+ 'یک', 'اما', 'و', 'یا', 'تا', 'اگر', 'برای', 'چون', 'چه',
131
+ 'کجا', 'کی', 'چطور', 'وقتی', 'بعد', 'قبل', 'مثل', 'همه', 'بعضی',
132
+ 'هیچ', 'آیا', 'خود', 'چند', 'دیگر', 'هنوز', 'همان', 'آنها', 'اینها',
133
+ 'ما', 'شما', 'آنان', 'من', 'تو', 'او', 'آنان', 'ایشان',
134
+ 'روی', 'زیر', 'بین', 'پیش', 'پس', 'طرف', 'داخل', 'خارج', 'کنار',
135
+ 'همچنین', 'مگر', 'ولی', 'وگرنه', 'چرا', 'چگونه', 'کدام',
136
+ // ── Hindi — common particles, postpositions, pronouns, auxiliary verbs ──
137
+ 'है', 'में', 'से', 'को', 'का', 'की', 'के', 'पर', 'और', 'या',
138
+ 'नहीं', 'यह', 'वह', 'एक', 'इस', 'उस', 'भी', 'हो', 'था', 'थी',
139
+ 'थे', 'हैं', 'हूँ', 'लिए', 'तक', 'साथ', 'बाद', 'पहले', 'जो',
140
+ 'जब', 'कैसे', 'क्यों', 'क्या', 'कहाँ', 'कौन', 'हम', 'आप', 'वे',
141
+ 'मैं', 'तुम', 'उन', 'इन', 'ने', 'बहुत', 'सब', 'कुछ', 'फिर',
142
+ 'अब', 'तो', 'ही', 'तरह', 'जैसे', 'करना', 'होना', 'रहा', 'रही',
143
+ 'रहे', 'गया', 'गई', 'गए', 'किया', 'किए', 'कर', 'हुआ', 'हुई',
144
+ // ── Russian — common prepositions, pronouns, auxiliary verbs ───────────────
145
+ 'в', 'на', 'не', 'с', 'и', 'а', 'но', 'по', 'за', 'из', 'от', 'до',
146
+ 'к', 'у', 'о', 'об', 'во', 'при', 'под', 'над', 'без', 'для',
147
+ 'что', 'как', 'это', 'все', 'так', 'уже', 'или', 'же', 'ли',
148
+ 'если', 'то', 'да', 'нет', 'был', 'была', 'были', 'быть', 'есть',
149
+ 'его', 'её', 'их', 'этот', 'эта', 'эти', 'который', 'которая',
150
+ 'которые', 'который', 'мой', 'моя', 'мои', 'ваш', 'ваша', 'ваши',
151
+ 'он', 'она', 'они', 'мы', 'вы', 'я', 'ты', 'тот', 'та', 'те',
152
+ 'очень', 'тоже', 'также', 'когда', 'где', 'как', 'почему', 'зачем',
153
+ 'сейчас', 'здесь', 'там', 'потому', 'поэтому', 'чтобы', 'тут',
154
+ // ── Turkish — common particles, postpositions, copulas ─────────────────────
155
+ 'bir', 'bu', 'şu', 'o', 've', 'ile', 'de', 'da', 'için', 'gibi',
156
+ 'ama', 'fakat', 'ancak', 'ya', 'ne', 'ki', 'mi', 'mı', 'mu', 'mü',
157
+ 'var', 'yok', 'olan', 'oldu', 'olur', 'olarak', 'ise', 'daha',
158
+ 'çok', 'en', 'her', 'hiç', 'bazı', 'tüm', 'bütün', 'hem', 'veya',
159
+ 'bu', 'şu', 'ben', 'sen', 'biz', 'siz', 'onlar', 'benim', 'senin',
160
+ 'nasıl', 'neden', 'niçin', 'nerede', 'ne', 'hangi', 'kaç',
161
+ // ── Polish — common prepositions, pronouns, particles ─────────────────────
162
+ 'w', 'na', 'z', 'do', 'się', 'że', 'to', 'jest', 'i', 'a', 'nie',
163
+ 'tak', 'jak', 'czy', 'po', 'o', 'ale', 'go', 'mu', 'jej', 'ich',
164
+ 'tego', 'tej', 'te', 'ten', 'ta', 'są', 'był', 'była', 'było', 'byli',
165
+ 'będzie', 'będą', 'ma', 'mam', 'masz', 'mają', 'ze', 'co', 'już',
166
+ 'przez', 'przy', 'za', 'bez', 'nad', 'pod', 'przed', 'po', 'między',
167
+ 'kiedy', 'gdzie', 'dlaczego', 'który', 'która', 'które', 'tylko', 'też',
168
+ // ── Czech — common words ──────────────────────────────────────────────────
169
+ 'v', 'na', 'z', 'do', 'se', 'že', 'to', 'je', 'a', 'ne', 'pro',
170
+ 'ale', 'jak', 'by', 'byl', 'být', 'jsem', 'jsou', 'má', 'mám',
171
+ 'ten', 'ta', 'ty', 'tato', 'jeho', 'její', 'jejich', 'také', 'jen',
172
+ 'kde', 'kdy', 'proč', 'který', 'která', 'které', 'co', 'při', 'bez',
173
+ // ── Bengali — common particles, pronouns, verbs ───────────────────────────
174
+ 'এই', 'এটি', 'এটা', 'এর', 'তার', 'তারা', 'আমি', 'তুমি', 'সে', 'আমরা',
175
+ 'এবং', 'কিন্তু', 'বা', 'না', 'হ্যাঁ', 'যে', 'কি', 'কে', 'কী', 'থেকে',
176
+ 'দিয়ে', 'জন্য', 'সাথে', 'মধ্যে', 'উপর', 'নিচে', 'আছে', 'ছিল', 'হয়',
177
+ 'করা', 'করে', 'করেছে', 'হবে', 'হয়েছে', 'একটি', 'একটা', 'অনেক', 'সব',
178
+ // ── Vietnamese — common particles, pronouns, auxiliary words ─────────────
179
+ 'tôi', 'bạn', 'anh', 'chị', 'em', 'họ', 'chúng', 'ta', 'mình',
180
+ 'là', 'có', 'không', 'và', 'với', 'của', 'cho', 'trong', 'về',
181
+ 'từ', 'đến', 'để', 'khi', 'nếu', 'thì', 'mà', 'nhưng', 'vì',
182
+ 'đây', 'đó', 'này', 'kia', 'rất', 'cũng', 'đã', 'sẽ', 'đang',
183
+ 'được', 'bị', 'những', 'các', 'một', 'hai', 'ba', 'nhiều', 'ít',
184
+ 'nào', 'ai', 'gì', 'đâu', 'sao', 'bao', 'giờ', 'lúc', 'sau',
185
+ ]);
186
+
187
+
188
+ // CJK Unicode ranges used for bigram extraction
189
+ const CJK_RE = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]/;
190
+ const SEGMENT_RE = /([\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+|[^\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uac00-\ud7af]+)/g;
191
+
192
+ // RTL Unicode ranges: Arabic (U+0600–U+06FF + extended), Hebrew (U+0590–U+05FF)
193
+ const RTL_RE = /[\u0590-\u05ff\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb1d-\ufb4f\ufb50-\ufdff\ufe70-\ufeff]/;
194
+ // Distinguish Arabic from Hebrew within RTL segments
195
+ const HEBREW_CHAR_RE = /[\u0590-\u05ff\ufb1d-\ufb4f]/;
196
+ const ARABIC_CHAR_RE = /[\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff\ufb50-\ufdff\ufe70-\ufeff]/;
197
+ // Persian/Farsi: exclusive chars not in Arabic (U+067E=پ, U+0686=چ, U+0698=ژ, U+06AF=گ, U+06CC=ی)
198
+ const FARSI_CHAR_RE = /[\u067e\u0686\u0698\u06af\u06cc]/;
199
+ // Devanagari (Hindi, Sanskrit, Marathi …) — U+0900–U+097F + extended
200
+ const DEVANAGARI_RE = /[\u0900-\u097f]/;
201
+ // Cyrillic (Russian, Bulgarian, Ukrainian, Serbian …) — U+0400–U+04FF
202
+ const CYRILLIC_RE = /[\u0400-\u04ff]/;
203
+ // Turkish uses Latin alphabet but has unique chars (ğ, ş, ı, ö, ü, ç) — detected by these
204
+ const TURKISH_CHAR_RE = /[\u011f\u015f\u0131\u0130\u00e7]/; // ğ ş ı İ ç
205
+ // Bengali (Bangla) — U+0980–U+09FF
206
+ const BENGALI_RE = /[\u0980-\u09ff]/;
207
+ // Vietnamese uses Latin + Latin Extended Additional (U+1EA0–U+1EF9) for tone marks
208
+ // e.g. ắ ặ ầ ổ ợ ụ ừ — detected by chars exclusive to Vietnamese diacritics
209
+ const VIETNAMESE_CHAR_RE = /[\u1ea0-\u1ef9]/;
210
+
211
+ // ── Katakana → Romaji conversion table (Hepburn system) ──────────────────────
212
+ // Digraphs must be listed before single chars so they match first.
213
+ const KATA_ROMAJI_TABLE: [string, string][] = [
214
+ // Special combinations for loanwords
215
+ ['ファ', 'fa'], ['フィ', 'fi'], ['フェ', 'fe'], ['フォ', 'fo'],
216
+ ['ティ', 'ti'], ['ディ', 'di'], ['トゥ', 'tu'], ['ドゥ', 'du'],
217
+ ['ウィ', 'wi'], ['ウェ', 'we'], ['ウォ', 'wo'],
218
+ ['チェ', 'che'], ['ジェ', 'je'], ['シェ', 'she'],
219
+ ['イェ', 'ye'], ['ヴァ', 'va'], ['ヴィ', 'vi'], ['ヴェ', 've'], ['ヴォ', 'vo'],
220
+ // Digraphs (2-char → romaji)
221
+ ['キャ', 'kya'], ['キュ', 'kyu'], ['キョ', 'kyo'],
222
+ ['シャ', 'sha'], ['シュ', 'shu'], ['ショ', 'sho'],
223
+ ['チャ', 'cha'], ['チュ', 'chu'], ['チョ', 'cho'],
224
+ ['ニャ', 'nya'], ['ニュ', 'nyu'], ['ニョ', 'nyo'],
225
+ ['ヒャ', 'hya'], ['ヒュ', 'hyu'], ['ヒョ', 'hyo'],
226
+ ['ミャ', 'mya'], ['ミュ', 'myu'], ['ミョ', 'myo'],
227
+ ['リャ', 'rya'], ['リュ', 'ryu'], ['リョ', 'ryo'],
228
+ ['ギャ', 'gya'], ['ギュ', 'gyu'], ['ギョ', 'gyo'],
229
+ ['ジャ', 'ja'], ['ジュ', 'ju'], ['ジョ', 'jo'],
230
+ ['ビャ', 'bya'], ['ビュ', 'byu'], ['ビョ', 'byo'],
231
+ ['ピャ', 'pya'], ['ピュ', 'pyu'], ['ピョ', 'pyo'],
232
+ // Single chars
233
+ ['ア', 'a'], ['イ', 'i'], ['ウ', 'u'], ['エ', 'e'], ['オ', 'o'],
234
+ ['カ', 'ka'], ['キ', 'ki'], ['ク', 'ku'], ['ケ', 'ke'], ['コ', 'ko'],
235
+ ['サ', 'sa'], ['シ', 'shi'], ['ス', 'su'], ['セ', 'se'], ['ソ', 'so'],
236
+ ['タ', 'ta'], ['チ', 'chi'], ['ツ', 'tsu'], ['テ', 'te'], ['ト', 'to'],
237
+ ['ナ', 'na'], ['ニ', 'ni'], ['ヌ', 'nu'], ['ネ', 'ne'], ['ノ', 'no'],
238
+ ['ハ', 'ha'], ['ヒ', 'hi'], ['フ', 'fu'], ['ヘ', 'he'], ['ホ', 'ho'],
239
+ ['マ', 'ma'], ['ミ', 'mi'], ['ム', 'mu'], ['メ', 'me'], ['モ', 'mo'],
240
+ ['ヤ', 'ya'], ['ユ', 'yu'], ['ヨ', 'yo'],
241
+ ['ラ', 'ra'], ['リ', 'ri'], ['ル', 'ru'], ['レ', 're'], ['ロ', 'ro'],
242
+ ['ワ', 'wa'], ['ヲ', 'wo'], ['ン', 'n'],
243
+ // Voiced
244
+ ['ガ', 'ga'], ['ギ', 'gi'], ['グ', 'gu'], ['ゲ', 'ge'], ['ゴ', 'go'],
245
+ ['ザ', 'za'], ['ジ', 'ji'], ['ズ', 'zu'], ['ゼ', 'ze'], ['ゾ', 'zo'],
246
+ ['ダ', 'da'], ['ヂ', 'ji'], ['ヅ', 'zu'], ['デ', 'de'], ['ド', 'do'],
247
+ ['バ', 'ba'], ['ビ', 'bi'], ['ブ', 'bu'], ['ベ', 'be'], ['ボ', 'bo'],
248
+ ['パ', 'pa'], ['ピ', 'pi'], ['プ', 'pu'], ['ペ', 'pe'], ['ポ', 'po'],
249
+ ['ヴ', 'v'],
250
+ // Long vowel / small chars
251
+ ['ー', ''], ['ァ', 'a'], ['ィ', 'i'], ['ゥ', 'u'], ['ェ', 'e'], ['ォ', 'o'],
252
+ ['ッ', ''], // handled separately (doubles next consonant)
253
+ ];
254
+
255
+ const KATA_MAP = new Map<string, string>(KATA_ROMAJI_TABLE);
256
+
257
+ /**
258
+ * Convert a katakana string to Hepburn romaji.
259
+ * Handles digraphs, geminate consonants (ッ), and long vowel marks (ー).
260
+ */
261
+ function katakanaToRomaji(kata: string): string {
262
+ let result = '';
263
+ let i = 0;
264
+ while (i < kata.length) {
265
+ // Geminate consonant: ッ doubles the following consonant
266
+ if (kata[i] === 'ッ' && i + 1 < kata.length) {
267
+ const next = KATA_MAP.get(kata[i + 1]) ?? KATA_MAP.get(kata[i + 1] + kata[i + 2]) ?? '';
268
+ if (next.length > 0) result += next[0]; // double first consonant
269
+ i++;
270
+ continue;
271
+ }
272
+ // Try 2-char digraph first
273
+ if (i + 1 < kata.length) {
274
+ const two = kata[i] + kata[i + 1];
275
+ const r2 = KATA_MAP.get(two);
276
+ if (r2 !== undefined) { result += r2; i += 2; continue; }
277
+ }
278
+ // Single char
279
+ const r1 = KATA_MAP.get(kata[i]);
280
+ if (r1 !== undefined) result += r1;
281
+ i++;
282
+ }
283
+ return result;
284
+ }
285
+
286
+ /**
287
+ * Arabic light stemmer — strips definite article and common prefix particles.
288
+ * Handles: ال (al-), و (wa-), ب (bi-), ل (li-), ف (fa-), ك (ka-).
289
+ * Runs iteratively (max 3 passes) so compound prefixes like فال- are fully
290
+ * resolved: فالخطأ → الخطأ → خطأ.
291
+ * Only strips when the result is still ≥ 3 chars to avoid over-stemming.
292
+ */
293
+ function arabicLightStem(word: string): string {
294
+ let result = word;
295
+ for (let i = 0; i < 3; i++) {
296
+ const prev = result;
297
+ if (result.startsWith('ال') && result.length > 4) {
298
+ result = result.slice(2);
299
+ } else if (result.length > 3 && 'وبلفك'.includes(result[0]) && RTL_RE.test(result[1])) {
300
+ result = result.slice(1);
301
+ }
302
+ if (result === prev) break; // stable — no more prefixes to strip
303
+ }
304
+ return result;
305
+ }
306
+
307
+ /**
308
+ * Hebrew light stemmer — strips the definite article and common prefix particles
309
+ * that attach directly to words (no space) in Hebrew.
310
+ *
311
+ * Handles:
312
+ * ה (ha-) — definite article: הפריסה → פריסה
313
+ * ו (ve-/u-) — conjunction “and”: ופריסה → פריסה
314
+ * ב (be-/bi-) — preposition “in”: בסביבה → סביבה
315
+ * ל (le-/li-) — preposition “for”: לשרת → שרת
316
+ * מ (mi-/me-) — preposition “from”: מהשרת → השרת → שרת
317
+ * כ (ke-/ki-) — preposition “like”: כשרת → שרת
318
+ *
319
+ * Iterative (max 3 passes): מהפריסה → הפריסה → פריסה.
320
+ * Only strips when result is still ≥ 3 chars.
321
+ */
322
+ function hebrewLightStem(word: string): string {
323
+ let result = word;
324
+ for (let i = 0; i < 3; i++) {
325
+ const prev = result;
326
+ if (result.length > 3 && 'הובלמכ'.includes(result[0]) && HEBREW_CHAR_RE.test(result[1])) {
327
+ result = result.slice(1);
328
+ }
329
+ if (result === prev) break;
330
+ }
331
+ return result;
332
+ }
333
+
334
+ /**
335
+ * Farsi/Persian light stemmer — handles Persian morphology.
336
+ * Strips:
337
+ * \u0647\u0627 / \u0647\u0627\u06cc (ha/haye) — plural suffixes: \u0633\u0631\u0648\u0631\u0647\u0627 \u2192 \u0633\u0631\u0648\u0631
338
+ * \u0645\u06cc\u200c / \u0645\u06cc (mi-) — present-tense prefix: \u0645\u06cc\u200c\u06a9\u0646\u062f \u2192 \u06a9\u0646\u062f
339
+ * \u0646\u0645\u06cc\u200c (nami-) — negated present: \u0646\u0645\u06cc\u200c\u0634\u0648\u062f \u2192 \u0634\u0648\u062f
340
+ * Only strips when the result is still \u2265 3 chars.
341
+ */
342
+ function farsiLightStem(word: string): string {
343
+ let result = word;
344
+ // Suffixes first (longest first)
345
+ if (result.endsWith('\u0647\u0627\u06cc') && result.length > 5) result = result.slice(0, -3);
346
+ else if (result.endsWith('\u0647\u0627') && result.length > 4) result = result.slice(0, -2);
347
+ // Prefixes
348
+ if (result.startsWith('\u0646\u0645\u06cc\u200c') && result.length > 5) result = result.slice(4);
349
+ else if (result.startsWith('\u0645\u06cc\u200c') && result.length > 4) result = result.slice(3);
350
+ else if (result.startsWith('\u0645\u06cc') && result.length > 4) result = result.slice(2);
351
+ return result;
352
+ }
353
+
354
+ /**
355
+ * Hindi light stemmer — strips the most common inflectional suffixes.
356
+ * Handles basic verb forms and oblique plural markers.
357
+ * Avoids over-stemming loanwords (most tech terms in Hindi are English loanwords).
358
+ * Only strips when result is still \u2265 3 chars.
359
+ */
360
+ function hindiLightStem(word: string): string {
361
+ const result = word;
362
+ // Infinitive / verb suffixes (longest first to avoid partial match)
363
+ if (result.endsWith('\u0928\u093e') && result.length > 4) return result.slice(0, -2); // \u0928\u093e (nā) infinitive
364
+ if (result.endsWith('\u0928\u0947') && result.length > 4) return result.slice(0, -2); // \u0928\u0947 (ne) ergative
365
+ if (result.endsWith('\u0928\u0940') && result.length > 4) return result.slice(0, -2); // \u0928\u0940 (nī) fem infinitive
366
+ if (result.endsWith('\u0924\u093e') && result.length > 4) return result.slice(0, -2); // \u0924\u093e (tā) m present participle
367
+ if (result.endsWith('\u0924\u0940') && result.length > 4) return result.slice(0, -2); // \u0924\u0940 (tī) f present participle
368
+ if (result.endsWith('\u0915\u0930') && result.length > 4) return result.slice(0, -2); // \u0915\u0930 (kar) conjunctive
369
+ if (result.endsWith('\u0913\u0902') && result.length > 4) return result.slice(0, -2); // \u0913\u0902 (oṃ) oblique plural
370
+ if (result.endsWith('\u0907\u092f\u093e\u0902') && result.length > 5) return result.slice(0, -4); // \u0907\u092f\u093e\u0902 (iyāṃ) f plural
371
+ if (result.endsWith('\u0940\u092f\u093e\u0902') && result.length > 5) return result.slice(0, -4); // \u0940\u092f\u093e\u0102 (īyāṃ)
372
+ return result;
373
+ }
374
+
375
+ /**
376
+ * Russian light stemmer — strips common inflectional suffixes.
377
+ * Handles the most frequent verb/noun/adjective endings to improve recall.
378
+ * Only strips when result is still ≥ 3 chars.
379
+ *
380
+ * Covers:
381
+ * ошибки/ошибка → ошибк (noun plural/genitive)
382
+ * развёртывание → развёртыва (gerund → stem)
383
+ * установить → установ (infinitive)
384
+ * настройки → настройк (genitive plural)
385
+ */
386
+ function russianLightStem(word: string): string {
387
+ let w = word;
388
+ // Longest first to avoid partial stripping
389
+ // Verb infinitives / gerunds
390
+ if (w.endsWith('ывание') && w.length > 7) return w.slice(0, -6);
391
+ if (w.endsWith('ивание') && w.length > 7) return w.slice(0, -6);
392
+ if (w.endsWith('ование') && w.length > 7) return w.slice(0, -6);
393
+ if (w.endsWith('вание') && w.length > 6) return w.slice(0, -5);
394
+ if (w.endsWith('ение') && w.length > 5) return w.slice(0, -4);
395
+ if (w.endsWith('ание') && w.length > 5) return w.slice(0, -4);
396
+ if (w.endsWith('ить') && w.length > 4) return w.slice(0, -3);
397
+ if (w.endsWith('ать') && w.length > 4) return w.slice(0, -3);
398
+ if (w.endsWith('еть') && w.length > 4) return w.slice(0, -3);
399
+ if (w.endsWith('уть') && w.length > 4) return w.slice(0, -3);
400
+ // Noun plural/genitive
401
+ if (w.endsWith('ки') && w.length > 4) return w.slice(0, -2);
402
+ if (w.endsWith('ги') && w.length > 4) return w.slice(0, -2);
403
+ if (w.endsWith('ов') && w.length > 4) return w.slice(0, -2);
404
+ if (w.endsWith('ев') && w.length > 4) return w.slice(0, -2);
405
+ if (w.endsWith('ей') && w.length > 4) return w.slice(0, -2);
406
+ if (w.endsWith('ий') && w.length > 4) return w.slice(0, -2);
407
+ if (w.endsWith('ый') && w.length > 4) return w.slice(0, -2);
408
+ if (w.endsWith('ая') && w.length > 4) return w.slice(0, -2);
409
+ if (w.endsWith('ое') && w.length > 4) return w.slice(0, -2);
410
+ if (w.endsWith('ую') && w.length > 4) return w.slice(0, -2);
411
+ // Verb present tense
412
+ if (w.endsWith('ет') && w.length > 4) return w.slice(0, -2);
413
+ if (w.endsWith('ют') && w.length > 4) return w.slice(0, -2);
414
+ if (w.endsWith('ут') && w.length > 4) return w.slice(0, -2);
415
+ if (w.endsWith('ят') && w.length > 4) return w.slice(0, -2);
416
+ return w;
417
+ }
418
+
419
+ /**
420
+ * Turkish light stemmer — strips common agglutinative suffixes.
421
+ * Turkish is highly agglutinative; this covers the most common inflectional ends.
422
+ *
423
+ * Covers:
424
+ * hatalar → hata (-lar/-ler plural)
425
+ * sunucuda → sunucu (-da/-de locative)
426
+ * dağıtımı → dağıtım (-ı/-i/-u/-ü accusative)
427
+ * yüklemek → yükle (-mek/-mak infinitive)
428
+ */
429
+ function turkishLightStem(word: string): string {
430
+ let w = word;
431
+ // Suffixes longest-first
432
+ if (w.endsWith('lardaki') && w.length > 7) return w.slice(0, -7);
433
+ if (w.endsWith('lerdeki') && w.length > 7) return w.slice(0, -7);
434
+ if (w.endsWith('ların') && w.length > 6) return w.slice(0, -5);
435
+ if (w.endsWith('lerin') && w.length > 6) return w.slice(0, -5);
436
+ if (w.endsWith('larda') && w.length > 6) return w.slice(0, -5);
437
+ if (w.endsWith('lerde') && w.length > 6) return w.slice(0, -5);
438
+ if (w.endsWith('ları') && w.length > 5) return w.slice(0, -4);
439
+ if (w.endsWith('leri') && w.length > 5) return w.slice(0, -4);
440
+ if (w.endsWith('mek') && w.length > 4) return w.slice(0, -3);
441
+ if (w.endsWith('mak') && w.length > 4) return w.slice(0, -3);
442
+ if (w.endsWith('ler') && w.length > 4) return w.slice(0, -3);
443
+ if (w.endsWith('lar') && w.length > 4) return w.slice(0, -3);
444
+ if (w.endsWith('nın') && w.length > 4) return w.slice(0, -3);
445
+ if (w.endsWith('nin') && w.length > 4) return w.slice(0, -3);
446
+ if (w.endsWith('nun') && w.length > 4) return w.slice(0, -3);
447
+ if (w.endsWith('nün') && w.length > 4) return w.slice(0, -3);
448
+ if (w.endsWith('da') && w.length > 4) return w.slice(0, -2);
449
+ if (w.endsWith('de') && w.length > 4) return w.slice(0, -2);
450
+ if (w.endsWith('ta') && w.length > 4) return w.slice(0, -2);
451
+ if (w.endsWith('te') && w.length > 4) return w.slice(0, -2);
452
+ if (w.endsWith('dan') && w.length > 4) return w.slice(0, -3);
453
+ if (w.endsWith('den') && w.length > 4) return w.slice(0, -3);
454
+ if (w.endsWith('tan') && w.length > 4) return w.slice(0, -3);
455
+ if (w.endsWith('ten') && w.length > 4) return w.slice(0, -3);
456
+ if (w.endsWith('ın') && w.length > 4) return w.slice(0, -2);
457
+ if (w.endsWith('in') && w.length > 4) return w.slice(0, -2);
458
+ if (w.endsWith('un') && w.length > 4) return w.slice(0, -2);
459
+ if (w.endsWith('ün') && w.length > 4) return w.slice(0, -2);
460
+ if (w.endsWith('ı') && w.length > 4) return w.slice(0, -1);
461
+ if (w.endsWith('i') && w.length > 4) return w.slice(0, -1);
462
+ if (w.endsWith('u') && w.length > 4) return w.slice(0, -1);
463
+ if (w.endsWith('ü') && w.length > 4) return w.slice(0, -1);
464
+ return w;
465
+ }
466
+
467
+ /**
468
+ * Bengali (Bangla) light stemmer — strips common verbal and nominal suffixes.
469
+ * Bengali is an Indo-Aryan language with moderate morphological complexity.
470
+ * Only strips when result is still ≥ 2 chars.
471
+ *
472
+ * Covers:
473
+ * করছে/করেছে → কর (progressive/perfect aspect)
474
+ * সার্ভারগুলো → সার্ভার (-গুলো plural)
475
+ * সমস্যাটি → সমস্যা (-টি singular marker)
476
+ */
477
+ function bengaliLightStem(word: string): string {
478
+ let w = word;
479
+ // Plural / collective markers (longest first)
480
+ if (w.endsWith('\u0997\u09c1\u09b2\u09cb') && w.length > 5) return w.slice(0, -4); // গুলো (-gulo plural)
481
+ if (w.endsWith('\u0997\u09c1\u09b2\u09bf') && w.length > 5) return w.slice(0, -4); // গুলি (-guli plural)
482
+ if (w.endsWith('\u09a6\u09c7\u09b0') && w.length > 4) return w.slice(0, -3); // দের (genitive plural)
483
+ if (w.endsWith('\u09a6\u09bf\u0997\u09c7') && w.length > 5) return w.slice(0, -4); // দিগে
484
+ // Definiteness / case markers
485
+ if (w.endsWith('\u099f\u09bf') && w.length > 3) return w.slice(0, -2); // টি (singular definite)
486
+ if (w.endsWith('\u099f\u09be') && w.length > 3) return w.slice(0, -2); // টা (singular definite)
487
+ if (w.endsWith('\u0996\u09be\u09a8\u09be') && w.length > 5) return w.slice(0, -4); // খানা
488
+ // Verbal suffixes
489
+ if (w.endsWith('\u099b\u09c7') && w.length > 3) return w.slice(0, -2); // ছে (progressive)
490
+ if (w.endsWith('\u099b\u09bf\u09b2') && w.length > 4) return w.slice(0, -3); // ছিল (past progressive)
491
+ if (w.endsWith('\u09af\u09be\u09ac\u09c7') && w.length > 5) return w.slice(0, -4); // যাবে (future)
492
+ if (w.endsWith('\u0995\u09b0\u09be') && w.length > 4) return w.slice(0, -3); // করা (infinitive)
493
+ if (w.endsWith('\u0995\u09b0\u09c7') && w.length > 4) return w.slice(0, -3); // করে (present)
494
+ if (w.endsWith('\u09b9\u09df') && w.length > 3) return w.slice(0, -2); // হয় (is/becomes)
495
+ if (w.endsWith('\u09b9\u09ac\u09c7') && w.length > 4) return w.slice(0, -3); // হবে (will be)
496
+ return w;
497
+ }
498
+
499
+ /**
500
+ * Pre-process text BEFORE tokenization to split code identifiers.
501
+ * Handles camelCase, PascalCase, snake_case, and kebab-case.
502
+ *
503
+ * Examples:
504
+ * deployServer → deploy Server (then lowercased → deploy server)
505
+ * HTMLParser → HTML Parser
506
+ * my_api_key → my api key
507
+ * get-user-by-id → get user by id (hyphens preserved in regex, spaces also fine)
508
+ * BackendServiceImpl → Backend Service Impl
509
+ */
510
+ function preprocessText(text: string): string {
511
+ return text
512
+ // camelCase boundary: lowercase/digit → uppercase
513
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
514
+ // Consecutive uppercase → uppercase+lowercase boundary: HTMLParser → HTML Parser
515
+ .replace(/([A-Z]{2,})([A-Z][a-z])/g, '$1 $2')
516
+ // snake_case: replace underscores with spaces
517
+ .replace(/_/g, ' ');
518
+ }
519
+
520
+
521
+ // Maps tokens (English, Japanese katakana, Chinese, Korean, Arabic, Hebrew)
522
+ // to their equivalents in other languages. Used to expand tokens at search time
523
+ // so "deploy" finds documents containing "デプロイ" / "部署" / "배포", and vice versa.
524
+ //
525
+ // Keys must be lowercase. CJK values will be bigram-expanded at use time.
526
+ const CROSS_LINGUAL_MAP = new Map<string, string[]>([
527
+
528
+ // ── Tech / Ecosystem synonyms (language-agnostic) ─────────────────────────
529
+ ['javascript', ['js', 'node', 'nodejs', 'ecmascript']],
530
+ ['js', ['javascript', 'node', 'nodejs']],
531
+ ['typescript', ['ts', 'javascript', 'js', 'tsc']],
532
+ ['ts', ['typescript', 'javascript', 'js']],
533
+ ['python', ['py', 'pip', 'django', 'flask', 'fastapi']],
534
+ ['py', ['python', 'pip']],
535
+ ['golang', ['go', 'gopher', 'goroutine']],
536
+ ['go', ['golang', 'goroutine']],
537
+ ['rust', ['cargo', 'rustlang', 'crates']],
538
+ ['cargo', ['rust', 'rustlang']],
539
+ ['java', ['jvm', 'maven', 'gradle', 'spring']],
540
+ ['kotlin', ['jvm', 'android', 'coroutine']],
541
+ ['dotnet', ['csharp', 'asp', 'nuget', 'aspnet']],
542
+ ['postgresql', ['postgres', 'pg', 'psql']],
543
+ ['postgres', ['postgresql', 'pg', 'psql']],
544
+ ['mysql', ['mariadb', 'sql']],
545
+ ['mongodb', ['mongo', 'nosql', 'bson', 'mongoose']],
546
+ ['mongo', ['mongodb', 'nosql', 'bson']],
547
+ ['elasticsearch', ['elastic', 'opensearch', 'lucene', 'kibana']],
548
+ ['elastic', ['elasticsearch', 'opensearch']],
549
+ ['kubernetes', ['k8s', 'kube', 'kubectl', 'helm', 'k3s']],
550
+ ['k8s', ['kubernetes', 'kube', 'kubectl', 'helm']],
551
+ ['helm', ['kubernetes', 'k8s', 'chart']],
552
+ ['terraform', ['tf', 'hcl', 'iac', 'opentofu']],
553
+ ['ansible', ['playbook', 'automation']],
554
+ ['grafana', ['dashboard', 'visualization', 'metrics']],
555
+ ['prometheus', ['metrics', 'alert', 'scrape', 'grafana']],
556
+ ['nginx', ['proxy', 'webserver', 'ingress']],
557
+ ['traefik', ['proxy', 'ingress', 'router']],
558
+ ['rabbitmq', ['amqp', 'queue', 'broker', 'messaging']],
559
+ ['kafka', ['messaging', 'stream', 'broker']],
560
+ ['grpc', ['protobuf', 'proto', 'rpc']],
561
+ ['graphql', ['gql', 'resolver', 'schema']],
562
+ ['react', ['jsx', 'hooks', 'component', 'redux']],
563
+ ['nextjs', ['next', 'react', 'ssr', 'vercel']],
564
+ ['vue', ['vuejs', 'vite', 'nuxt']],
565
+ ['angular', ['ng', 'rxjs', 'typescript']],
566
+ ['aws', ['amazon', 'ec2', 's3', 'lambda', 'cloudwatch', 'ecs', 'eks']],
567
+ ['gcp', ['google cloud', 'gke', 'bigquery']],
568
+ ['azure', ['microsoft cloud', 'aks', 'devops']],
569
+ ['s3', ['bucket', 'object storage', 'aws']],
570
+ ['lambda', ['serverless', 'function', 'faas', 'aws']],
571
+ ['jwt', ['token', 'bearer', 'oauth', 'auth']],
572
+ ['oauth', ['oidc', 'auth', 'token', 'sso']],
573
+ ['webhook', ['callback', 'event', 'trigger']],
574
+ ['cron', ['schedule', 'job', 'timer']],
575
+ ['yaml', ['yml', 'config', 'manifest']],
576
+ ['dotenv', ['env', 'environment', 'envfile']],
577
+ ['github', ['git', 'actions', 'repo', 'ci']],
578
+ ['gitlab', ['git', 'pipeline', 'runner']],
579
+ ['wireguard', ['vpn', 'tunnel', 'wg']],
580
+ ['prisma', ['orm', 'database', 'schema', 'migration']],
581
+ ['npm', ['node', 'package', 'registry', 'yarn', 'pnpm']],
582
+ ['yarn', ['npm', 'package', 'node']],
583
+ ['pip', ['python', 'package', 'pypi']],
584
+ ['brew', ['homebrew', 'macos', 'package']],
585
+ // English → all
586
+ ['deploy', ['デプロイ', '部署', '배포', 'deployment', 'deploying', 'نشر', 'פריסה', 'استقرار', 'तैनाती', 'bereitstellen', 'bereitstellung', 'déployer', 'déploiement', 'desplegar', 'despliegue', 'distribuire', 'distribuzione', 'implantar', 'implantação', 'развёртывание', 'развертывание', 'dağıtım', 'dağıtmak', 'triển khai']],
587
+ ['deployment', ['デプロイ', '部署', '배포', 'deploy', 'نشر', 'פריסה', 'استقرار', 'तैनाती', 'bereitstellung', 'déploiement', 'despliegue', 'distribuzione', 'implantação', 'развёртывание', 'dağıtım', 'triển khai']],
588
+ ['container', ['コンテナ', '容器', '컨테이너', 'docker']],
589
+ ['server', ['サーバー', 'サーバ', '服务器', '서버', 'سيرفر', 'שרת', 'سرور', 'सर्वर', 'сервер', 'sunucu', 'máy chủ']],
590
+ ['database', ['データベース', '数据库', '데이터베이스', 'db', 'قاعدة البيانات', 'datenbank', 'پایگاه داده', 'डेटाबेس', 'base de données', 'base de datos', 'banco de dados']],
591
+ ['cache', ['キャッシュ', '缓存', '캐시', 'كاش', 'מטמון', 'کش', 'कैश', 'кэш', 'önbellek', 'bộ đệm']],
592
+ ['error', ['エラー', '错误', '오류', 'خطأ', 'שגיאה', 'خطا', 'त्रुटि', 'गलती', 'exception', 'err', 'fehler', 'erreur', 'fallo', 'errore', 'erro', 'ошибка', 'hata', 'hatalı', 'lỗi']],
593
+ ['bug', ['バグ', '缺陷', '버그', 'issue', 'defect', 'خلل', 'اشکال', 'बग']],
594
+ ['fix', ['修正', '修复', '수정', 'bugfix', 'patch', 'hotfix', 'إصلاح', 'תיקון', 'رفع', 'सुधार', 'beheben', 'behoben', 'réparer', 'correction', 'arreglar', 'corrección', 'correggere', 'corrigir', 'исправить', 'исправление', 'düzeltmek', 'düzeltme']],
595
+ ['build', ['ビルド', '构建', '빌드', 'بناء', 'בנייה', 'ساخت', 'निर्माण', 'bauen', 'construire', 'construir', 'costruire', 'сборка', 'собрать', 'derleme', 'derlemek']],
596
+ ['test', ['テスト', '测试', '테스트', 'اختبار', 'בדיקה', 'آزمایش', 'परीक्षण', 'testen', 'tester', 'probar', 'testare', 'testar', 'тест', 'тестирование', 'test', 'testlemek']],
597
+ ['auth', ['認証', '认证', '인증', 'authentication', 'login', 'oauth', 'مصادقة', 'אימות', 'احراز هویت', 'प्रमाणीकरण', 'authentifizierung', 'authentification', 'autenticación', 'autenticazione', 'autenticação', 'аутентификация', 'авторизация', 'kimlik doğrulama', 'yetkilendirme']],
598
+ ['authentication', ['auth', 'مصادقة', 'אימות', 'احراز هویت', 'प्रमाणीकरण', '認証', '认证', '인증', 'login', 'oauth', 'аутентификация', 'авторизация', 'kimlik doğrulama']],
599
+ ['environment', ['環境', '环境', '환경', 'env', 'بيئة', 'סביבה', 'umgebung', 'environnement', 'entorno', 'ambiente', 'среда', 'окружение', 'ortam']],
600
+ ['secret', ['key', 'token', 'password', 'jwt', 'مفتاح', 'מפתח', 'geheimnis', 'schlüssel', 'clave', 'clé', 'chiave', 'chave', 'секрет', 'ключ', 'gizli', 'anahtar']],
601
+ ['problem', ['error', 'issue', 'bug', 'failure', 'مشكلة', '問題', '오류', 'fehler', 'problème', 'problema', 'проблема', 'ошибка', 'sorun', 'problem']],
602
+ ['kubernetes', ['クベルネテス', 'k8s', 'kube']],
603
+ ['network', ['ネットワーク', '网络', '네트워크', 'شبكة', 'רשת', 'شبکه', 'नेटवर्क', 'netzwerk', 'réseau', 'red', 'rete', 'rede', 'сеть', 'ağ']],
604
+ ['timeout', ['タイムアウト', '超时', '타임아웃', 'مهلة']],
605
+ ['memory', ['メモリ', '内存', '메모리', 'ram', 'ذاكرة', 'זיכרון', 'حافظه', 'मेमोरी', 'speicher', 'mémoire', 'memoria', 'memória', 'память', 'bellek', 'hafıza']],
606
+ ['config', ['設定', '配置', '설정', 'configuration', 'settings', 'conf', 'إعدادات', 'הגדרות', 'پیکربندی', 'कॉन्फ़िग', 'konfiguration', 'einstellungen', 'configuración', 'configurazione', 'configuração', 'конфигурация', 'настройки', 'yapılandırma', 'ayarlar']],
607
+ ['install', ['インストール', '安装', '설치', 'تثبيت', 'התקנה', 'نصب', 'इंस्टॉल', 'installieren', 'installer', 'instalar', 'installare', 'установить', 'установка', 'yüklemek', 'kurulum']],
608
+ ['update', ['アップデート', '更新', '업데이트', 'upgrade', 'تحديث', 'עדכון', 'به‌روزرسانی', 'अपडेट', 'aktualisieren', 'aktualisierung', 'actualizar', 'aggiornare', 'atualizar', 'обновление', 'обновить', 'güncelleme']],
609
+ ['log', ['ログ', '日志', '로그', 'logging', 'سجل', 'יומן', 'ثبت', 'लॉग', 'protokoll', 'protokolle', 'journal', 'registro', 'журнал', 'лог', 'günlük']],
610
+ ['port', ['ポート', '端口', '포트', 'منفذ', 'פורט']],
611
+ ['file', ['ファイル', '文件', '파일', 'ملف', 'קובץ']],
612
+ ['image', ['イメージ', '镜像', '이미지', 'صورة', 'תמונה', 'abbild', 'imagen', 'imagem']],
613
+ ['volume', ['ボリューム', '卷', '볼륨']],
614
+ ['cluster', ['クラスター', '集群', '클러스터']],
615
+ ['node', ['ノード', '节点', '노드']],
616
+ ['service', ['サービス', '服务', '서비스', 'svc', 'خدمة', 'שירות', 'dienst', 'servicio', 'servizio', 'serviço', 'сервис', 'служба', 'servis', 'hizmet']],
617
+ ['certificate', ['証明書', '证书', '인증서', 'cert', 'ssl', 'tls', 'شهادة', 'תעודה']],
618
+ ['password', ['パスワード', '密码', '비밀번호', 'passwd', 'pwd', 'كلمة المرور', 'סיסמה']],
619
+ ['token', ['トークン', '令牌', '토큰', 'jwt', 'secret', 'رمز', 'אסימון']],
620
+ ['health', ['ヘルス', '健康', '헬스', 'healthcheck', 'probe', 'صحة']],
621
+ ['migration', ['マイグレーション', '迁移', '마이그레이션', 'migrate', 'هجرة']],
622
+ ['backup', ['バックアップ', '备份', '백업', 'نسخ احتياطي', 'גיבוי', 'پشتیبان‌گیری', 'बैकअप', 'sicherung', 'sauvegarde', 'respaldo', 'cópia de segurança', 'резервная копия', 'резерв', 'yedekleme']],
623
+ ['monitor', ['モニター', '监控', '모니터링', 'monitoring', 'metrics', 'alert', 'مراقبة', 'ניטור', 'نظارت', 'निगरानी', 'überwachung', 'surveiller', 'monitoreo', 'monitorare', 'monitorar', 'мониторинг', 'наблюдение', 'izleme']],
624
+ ['performance', ['パフォーマンス', '性能', '성능', 'latency', 'throughput', 'عملکرد', 'प्रदर्शन', 'leistung', 'performances', 'rendimiento', 'prestazioni', 'desempenho', 'производительность', 'быстродействие', 'performans']],
625
+ ['connection', ['接続', '连接', '연결', 'conn', 'socket', 'اتصال', 'חיבור', 'اتصال', 'कनेक्शन', 'verbindung', 'connexion', 'conexión', 'connessione', 'conexão']],
626
+ ['queue', ['キュー', '队列', '큐', 'طابور', 'warteschlange', 'file d\'attente', 'cola', 'coda', 'fila']],
627
+ ['redis', ['レディス', '레디스']],
628
+ ['nginx', ['エンジンエックス']],
629
+ ['linux', ['リナックス', 'لينكس']],
630
+ ['api', ['エーピーアイ', '接口', 'endpoint', 'واجهة برمجية', 'ממשק']],
631
+ ['healthcheck', ['ヘルスチェック', 'health', 'probe', 'فحص الصحة']],
632
+ ['loadbalancer',['ロードバランサー', '负载均衡', '로드밸런서', 'lb']],
633
+ ['ssl', ['tls', 'https', 'certificate', 'cert']],
634
+ ['tls', ['ssl', 'https', 'certificate', 'cert']],
635
+ ['docker', ['container', 'コンテナ', '容器', '컨테이너', 'دوكر']],
636
+ ['git', ['version control', 'repo', 'repository', 'commit', 'push', 'pull']],
637
+ ['ci', ['pipeline', 'github actions', 'gitlab', 'jenkins', 'build']],
638
+ ['cd', ['deploy', 'deployment', 'release']],
639
+ ['debug', ['デバッグ', '调试', '디버그', 'debugging', 'breakpoint', 'تصحيح', 'איתור באגים', 'اشکال‌زدایی', 'डीबग', 'debuggen', 'déboguer', 'depurar', 'отладка', 'дебаг', 'hata ayıklama']],
640
+ ['crash', ['クラッシュ', '崩溃', '크래시', 'panic', 'segfault', 'انهيار', 'קריסה', 'خرابی', 'क्रैश', 'absturz', 'panne', 'plantage', 'caída', 'falha', 'сбой', 'крэш', 'çökme', 'çöküş']],
641
+ ['restart', ['再起動', '重启', '재시작', 'reboot', 'إعادة تشغيل', 'הפעלה מחדש', 'راه‌اندازی مجدد', 'पुنरारंभ', 'neustart', 'redémarrer', 'reiniciar', 'riavviare']],
642
+ ['permission', ['権限', '权限', '권한', 'access', 'acl', 'chmod', 'صلاحية', 'הרשאה', 'مجوز', 'अनुमति', 'berechtigung', 'berechtigungen', 'permiso', 'permesso', 'permissão', 'разрешение', 'доступ', 'izin', 'yetki']],
643
+ // Japanese katakana → English
644
+ ['デプロイ', ['deploy', 'deployment']],
645
+ ['コンテナ', ['container', 'docker']],
646
+ ['サーバー', ['server']],
647
+ ['サーバ', ['server']],
648
+ ['データベース',['database', 'db']],
649
+ ['キャッシュ', ['cache']],
650
+ ['エラー', ['error', 'err']],
651
+ ['バグ', ['bug', 'issue']],
652
+ ['ビルド', ['build']],
653
+ ['テスト', ['test']],
654
+ ['認証', ['auth', 'authentication']],
655
+ ['設定', ['config', 'configuration']],
656
+ ['インストール',['install']],
657
+ ['アップデート',['update', 'upgrade']],
658
+ ['ログ', ['log', 'logs']],
659
+ ['ポート', ['port']],
660
+ ['ファイル', ['file']],
661
+ ['イメージ', ['image']],
662
+ ['クラスター', ['cluster']],
663
+ ['ノード', ['node']],
664
+ ['サービス', ['service']],
665
+ ['パスワード', ['password']],
666
+ ['トークン', ['token']],
667
+ ['バックアップ',['backup']],
668
+ ['モニター', ['monitor', 'monitoring']],
669
+ ['接続', ['connection']],
670
+ ['キュー', ['queue']],
671
+ ['ヘルスチェック',['healthcheck', 'health check', 'health']],
672
+ ['ロードバランサー',['loadbalancer', 'load balancer']],
673
+ ['デバッグ', ['debug']],
674
+ ['クラッシュ', ['crash', 'panic']],
675
+ ['再起動', ['restart', 'reboot']],
676
+ ['権限', ['permission', 'access']],
677
+ // Chinese → English
678
+ ['部署', ['deploy', 'deployment']],
679
+ ['容器', ['container', 'docker']],
680
+ ['服务器', ['server']],
681
+ ['数据库', ['database']],
682
+ ['缓存', ['cache']],
683
+ ['错误', ['error']],
684
+ ['修复', ['fix', 'bugfix']],
685
+ ['构建', ['build']],
686
+ ['测试', ['test']],
687
+ ['认证', ['auth']],
688
+ ['配置', ['config']],
689
+ ['安装', ['install']],
690
+ ['更新', ['update']],
691
+ ['日志', ['log']],
692
+ ['端口', ['port']],
693
+ ['镜像', ['image']],
694
+ ['集群', ['cluster']],
695
+ ['节点', ['node']],
696
+ ['服务', ['service']],
697
+ ['密码', ['password']],
698
+ ['令牌', ['token']],
699
+ ['备份', ['backup']],
700
+ ['监控', ['monitor']],
701
+ ['连接', ['connection']],
702
+ ['队列', ['queue']],
703
+ ['性能', ['performance']],
704
+ ['内存', ['memory']],
705
+ ['调试', ['debug']],
706
+ ['崩溃', ['crash']],
707
+ ['重启', ['restart']],
708
+ ['权限', ['permission']],
709
+ // Korean → English
710
+ ['배포', ['deploy', 'deployment']],
711
+ ['컨테이너', ['container']],
712
+ ['서버', ['server']],
713
+ ['데이터베이스',['database']],
714
+ ['캐시', ['cache']],
715
+ ['오류', ['error']],
716
+ ['수정', ['fix']],
717
+ ['빌드', ['build']],
718
+ ['테스트', ['test']],
719
+ ['인증', ['auth']],
720
+ ['설정', ['config']],
721
+ ['설치', ['install']],
722
+ ['업데이트', ['update']],
723
+ ['로그', ['log']],
724
+ ['포트', ['port']],
725
+ ['이미지', ['image']],
726
+ ['클러스터', ['cluster']],
727
+ ['노드', ['node']],
728
+ ['서비스', ['service']],
729
+ ['토큰', ['token']],
730
+ ['백업', ['backup']],
731
+ ['모니터링', ['monitor']],
732
+ ['연결', ['connection']],
733
+ ['큐', ['queue']],
734
+ ['성능', ['performance']],
735
+ ['메모리', ['memory']],
736
+ ['디버그', ['debug']],
737
+ ['크래시', ['crash']],
738
+ ['재시작', ['restart']],
739
+ ['권한', ['permission']],
740
+ // Arabic → English
741
+ ['خطأ', ['error']],
742
+ ['إصلاح', ['fix']],
743
+ ['بناء', ['build']],
744
+ ['اختبار', ['test']],
745
+ ['مصادقة', ['auth', 'authentication']],
746
+ ['إعدادات', ['config', 'configuration']],
747
+ ['تثبيت', ['install']],
748
+ ['تحديث', ['update', 'upgrade']],
749
+ ['سجل', ['log']],
750
+ ['صورة', ['image']],
751
+ ['خدمة', ['service']],
752
+ ['نسخ احتياطي', ['backup']],
753
+ ['مراقبة', ['monitor', 'monitoring']],
754
+ ['اتصال', ['connection']],
755
+ ['ذاكرة', ['memory']],
756
+ ['تصحيح', ['debug', 'debugging']],
757
+ ['شبكة', ['network']],
758
+ // Additional Arabic → English (covering blog post examples + common terms)
759
+ ['نشر', ['deploy', 'deployment']],
760
+ ['انهيار', ['crash', 'panic']],
761
+ ['طابور', ['queue']],
762
+ ['رمز', ['token', 'secret']],
763
+ ['كاش', ['cache']],
764
+ ['منفذ', ['port']],
765
+ ['سيرفر', ['server']],
766
+ ['قاعدة البيانات', ['database', 'db']],
767
+ ['إعادة تشغيل', ['restart', 'reboot']],
768
+ ['صلاحية', ['permission', 'access']],
769
+ ['تطبيق', ['application', 'app']],
770
+ ['مفتاح', ['key', 'token', 'secret']],
771
+ ['واجهة برمجية', ['api', 'interface']],
772
+ ['فحص الصحة', ['healthcheck', 'health']],
773
+ ['حاوية', ['container', 'docker']],
774
+ ['عقدة', ['node']],
775
+ ['سرعة', ['performance', 'speed']],
776
+ ['قرص', ['disk', 'storage']],
777
+ ['خادم', ['server']],
778
+ ['برنامج', ['application', 'software', 'app']],
779
+ // Arabic terms from blog examples
780
+ ['مشكلة', ['error', 'issue', 'bug', 'problem', 'failure']],
781
+ ['بيئة', ['environment', 'env']],
782
+ ['متغيرات', ['variables', 'env', 'environment']],
783
+ ['إنتاج', ['production', 'prod']],
784
+ ['سري', ['secret', 'key', 'token']],
785
+ ['مفتاح سري', ['secret', 'key']],
786
+ // Hebrew → English
787
+ ['שגיאה', ['error']],
788
+ ['תיקון', ['fix']],
789
+ ['בנייה', ['build']],
790
+ ['בדיקה', ['test']],
791
+ ['אימות', ['auth', 'authentication']],
792
+ ['הגדרות', ['config', 'configuration']],
793
+ ['התקנה', ['install']],
794
+ ['עדכון', ['update', 'upgrade']],
795
+ ['יומן', ['log']],
796
+ ['שרת', ['server']],
797
+ ['שירות', ['service']],
798
+ ['גיבוי', ['backup']],
799
+ ['ניטור', ['monitor', 'monitoring']],
800
+ ['חיבור', ['connection']],
801
+ ['זיכרון', ['memory']],
802
+ ['רשת', ['network']],
803
+ ['תמונה', ['image']],
804
+ ['סיסמה', ['password']],
805
+ ['הרשאה', ['permission', 'access']],
806
+ // Additional Hebrew → English (covering blog post examples + common terms)
807
+ ['קריסה', ['crash', 'panic']],
808
+ ['תור', ['queue']],
809
+ ['מטמון', ['cache']],
810
+ ['אסימון', ['token', 'secret']],
811
+ ['פורט', ['port']],
812
+ ['איתור באגים', ['debug', 'debugging']],
813
+ ['הפעלה מחדש', ['restart', 'reboot']],
814
+ ['מיכל', ['container', 'docker']],
815
+ ['יישום', ['application', 'app']],
816
+ ['מפתח', ['key', 'token', 'secret']],
817
+ ['בדיקת תקינות', ['healthcheck', 'health']],
818
+ ['ממשק תכנות', ['api', 'interface']],
819
+ ['ביצועים', ['performance']],
820
+ ['אחסון', ['storage', 'disk']],
821
+ // Hebrew terms from blog examples
822
+ ['פריסה', ['deploy', 'deployment']],
823
+ ['בעיה', ['error', 'issue', 'bug', 'problem']],
824
+ ['סביבה', ['environment', 'env']],
825
+ ['ייצור', ['production', 'prod']],
826
+ ['סודי', ['secret', 'key', 'token']],
827
+ ['מפתח סודי', ['secret', 'key']],
828
+
829
+ // ── Farsi/Persian → English ───────────────────────────────────────────────
830
+ ['استقرار', ['deploy', 'deployment']],
831
+ ['خطا', ['error', 'err']], // Farsi: خطا vs Arabic: خطأ
832
+ ['اشکال', ['bug', 'issue', 'error']],
833
+ ['رفع اشکال', ['fix', 'debug', 'debugging']],
834
+ ['ساخت', ['build']],
835
+ ['آزمایش', ['test']],
836
+ ['احراز هویت', ['auth', 'authentication']],
837
+ ['پیکربندی', ['config', 'configuration']],
838
+ ['نصب', ['install']],
839
+ ['به‌روزرسانی', ['update', 'upgrade']],
840
+ ['ثبت', ['log']],
841
+ ['سرور', ['server']],
842
+ ['پایگاه داده', ['database', 'db']],
843
+ ['شبکه', ['network']], // Farsi شبکه vs Arabic شبكة
844
+ ['حافظه', ['memory', 'ram']], // Farsi حافظه vs Arabic ذاكرة
845
+ ['کش', ['cache']],
846
+ ['اشکال‌زدایی', ['debug', 'debugging']],
847
+ ['خرابی', ['crash', 'failure', 'error']],
848
+ ['راه‌اندازی مجدد', ['restart', 'reboot']],
849
+ ['مجوز', ['permission', 'access']],
850
+ ['پشتیبان‌گیری', ['backup']],
851
+ ['عملکرد', ['performance']],
852
+ ['مشکل', ['problem', 'issue', 'error']],
853
+ ['سرویس', ['service']],
854
+ ['کلید', ['key', 'token', 'secret']],
855
+ ['محیط', ['environment', 'env']],
856
+ ['تولید', ['production', 'prod']],
857
+ ['رمز', ['secret', 'key', 'token']],
858
+ ['اتصال', ['connection', 'conn']],
859
+
860
+ // ── Hindi (Devanagari) → English ──────────────────────────────────────────
861
+ ['तैनाती', ['deploy', 'deployment']],
862
+ ['तैना', ['deploy', 'deployment']], // stemmed form
863
+ ['त्रुटि', ['error', 'err']],
864
+ ['गलती', ['error', 'bug', 'issue']],
865
+ ['सुधार', ['fix', 'bugfix']],
866
+ ['निर्माण', ['build']],
867
+ ['परीक्षण', ['test']],
868
+ ['प्रमाणीकरण', ['auth', 'authentication']],
869
+ ['विन्यास', ['config', 'configuration']],
870
+ ['कॉन्फ़िगरेशन', ['config', 'configuration']],
871
+ ['इंस्टॉल', ['install']],
872
+ ['अपडेट', ['update', 'upgrade']],
873
+ ['लॉग', ['log']],
874
+ ['सर्वर', ['server']],
875
+ ['डेटाबेस', ['database', 'db']],
876
+ ['नेटवर्क', ['network']],
877
+ ['मेमोरी', ['memory', 'ram']],
878
+ ['कैश', ['cache']],
879
+ ['डीबग', ['debug', 'debugging']],
880
+ ['क्रैश', ['crash', 'panic']],
881
+ ['पुनरारंभ', ['restart', 'reboot']],
882
+ ['अनुमति', ['permission', 'access']],
883
+ ['बैकअप', ['backup']],
884
+ ['प्रदर्शन', ['performance']],
885
+ ['समस्या', ['problem', 'issue', 'error']],
886
+ ['सेवा', ['service']],
887
+ ['कनेक्शन', ['connection', 'conn']],
888
+ ['पासवर्ड', ['password']],
889
+ ['टोकन', ['token', 'secret']],
890
+ ['वातावरण', ['environment', 'env']],
891
+ ['उत्पादन', ['production', 'prod']],
892
+
893
+ // ── European languages → English ─────────────────────────────────────────
894
+ // German (Deutsch) → English
895
+ ['fehler', ['error', 'err']],
896
+ ['absturz', ['crash', 'panic']],
897
+ ['bug', ['bug', 'issue']], // same word, keep for completeness
898
+ ['beheben', ['fix', 'bugfix']],
899
+ ['behoben', ['fix', 'fixed']],
900
+ ['lösung', ['solution', 'fix']],
901
+ ['bauen', ['build']],
902
+ ['testen', ['test']],
903
+ ['authentifizierung',['auth', 'authentication']],
904
+ ['konfiguration', ['config', 'configuration']],
905
+ ['einstellungen', ['config', 'settings']],
906
+ ['installieren', ['install']],
907
+ ['installiert', ['install', 'installed']],
908
+ ['aktualisieren', ['update', 'upgrade']],
909
+ ['aktualisierung', ['update', 'upgrade']],
910
+ ['protokoll', ['log']],
911
+ ['protokolle', ['log', 'logs']],
912
+ ['abbild', ['image']],
913
+ ['knoten', ['node']],
914
+ ['dienst', ['service']],
915
+ ['sicherung', ['backup']],
916
+ ['überwachung', ['monitor', 'monitoring']],
917
+ ['verbindung', ['connection', 'conn']],
918
+ ['warteschlange', ['queue']],
919
+ ['leistung', ['performance']],
920
+ ['speicher', ['memory', 'storage']],
921
+ ['debuggen', ['debug', 'debugging']],
922
+ ['neustart', ['restart', 'reboot']],
923
+ ['berechtigung', ['permission', 'access']],
924
+ ['berechtigungen', ['permission', 'access', 'acl']],
925
+ ['netzwerk', ['network']],
926
+ ['datenbank', ['database', 'db']],
927
+ ['bereitstellen', ['deploy', 'deployment']],
928
+ ['bereitstellung', ['deploy', 'deployment']],
929
+ // 'container' and 'server' are the same in German/French/Spanish/Italian/Portuguese
930
+ ['kaputt', ['broken', 'error', 'crash']],
931
+ ['funktioniert', ['works', 'working']],
932
+ ['geht nicht', ['not working', 'broken', 'error']],
933
+ ['schlüssel', ['key', 'token', 'secret']],
934
+ ['zertifikat', ['certificate', 'cert', 'ssl', 'tls']],
935
+ ['umgebung', ['environment', 'env']],
936
+ ['variablen', ['variables', 'env', 'environment']],
937
+ ['aufgabe', ['task', 'job']],
938
+ ['wartung', ['maintenance']],
939
+ ['speicherleck', ['memory leak', 'memory']],
940
+ ['schnittstelle', ['interface', 'api']],
941
+
942
+ // French (Français) → English
943
+ ['erreur', ['error', 'err']],
944
+ ['réparer', ['fix']],
945
+ ['correction', ['fix', 'bugfix']],
946
+ ['construire', ['build']],
947
+ ['tester', ['test']],
948
+ ['authentification',['auth', 'authentication']],
949
+ ['configuration', ['config', 'configuration']],
950
+ ['installer', ['install']],
951
+ ['installation', ['install']],
952
+ ['mettre', ['update', 'upgrade']],
953
+ ['journal', ['log', 'logs']],
954
+ ['nœud', ['node']],
955
+ ['sauvegarde', ['backup']],
956
+ ['surveiller', ['monitor', 'monitoring']],
957
+ ['connexion', ['connection']],
958
+ ['mémoire', ['memory']],
959
+ ['déboguer', ['debug']],
960
+ ['redémarrer', ['restart', 'reboot']],
961
+ ['réseau', ['network']],
962
+ ['base de données', ['database', 'db']],
963
+ ['déployer', ['deploy']],
964
+ ['déploiement', ['deploy', 'deployment']],
965
+ ['clé', ['key', 'token']],
966
+ ['certificat', ['certificate', 'cert', 'ssl']],
967
+ ['environnement', ['environment', 'env']],
968
+ ['panne', ['crash', 'outage', 'failure']],
969
+ ['plantage', ['crash', 'panic']],
970
+ ['interface', ['interface', 'api']],
971
+ ['performances', ['performance']],
972
+ ['stockage', ['storage', 'disk']],
973
+ ['file attente', ['queue']],
974
+
975
+ // Spanish (Español) → English
976
+ ['arreglar', ['fix']],
977
+ ['corrección', ['fix', 'bugfix']],
978
+ ['construir', ['build']],
979
+ ['probar', ['test']],
980
+ ['autenticación', ['auth', 'authentication']],
981
+ ['configuración', ['config', 'configuration']],
982
+ ['instalar', ['install']],
983
+ ['actualizar', ['update', 'upgrade']],
984
+ ['actualización', ['update', 'upgrade']],
985
+ ['registro', ['log', 'registry']],
986
+ ['nodo', ['node']],
987
+ ['servicio', ['service']],
988
+ ['contraseña', ['password']],
989
+ ['copia de seguridad',['backup']],
990
+ ['respaldo', ['backup']],
991
+ ['monitorear', ['monitor']],
992
+ ['monitoreo', ['monitoring']],
993
+ ['conexión', ['connection']],
994
+ ['rendimiento', ['performance']],
995
+ ['memoria', ['memory']],
996
+ ['depurar', ['debug']],
997
+ ['fallo', ['crash', 'failure', 'error']],
998
+ ['caída', ['crash', 'outage']],
999
+ ['reiniciar', ['restart', 'reboot']],
1000
+ ['permiso', ['permission']],
1001
+ ['permisos', ['permission', 'access']],
1002
+ ['red', ['network']],
1003
+ ['base de datos', ['database', 'db']],
1004
+ ['desplegar', ['deploy']],
1005
+ ['despliegue', ['deploy', 'deployment']],
1006
+ ['clave', ['key', 'token', 'secret']],
1007
+ ['certificado', ['certificate', 'cert', 'ssl']],
1008
+ ['entorno', ['environment', 'env']],
1009
+ ['cola', ['queue']],
1010
+ ['interfaz', ['interface', 'api']],
1011
+ ['imagen', ['image']],
1012
+
1013
+ // Italian (Italiano) → English
1014
+ ['errore', ['error', 'err']],
1015
+ ['correggere', ['fix']],
1016
+ ['costruire', ['build']],
1017
+ ['testare', ['test']],
1018
+ ['autenticazione', ['auth', 'authentication']],
1019
+ ['configurazione', ['config', 'configuration']],
1020
+ ['installare', ['install']],
1021
+ ['aggiornare', ['update', 'upgrade']],
1022
+ ['aggiornamento', ['update', 'upgrade']],
1023
+ ['registro', ['log']],
1024
+ ['nodo', ['node']],
1025
+ ['servizio', ['service']],
1026
+ ['password', ['password']],
1027
+ ['backup', ['backup']],
1028
+ ['monitorare', ['monitor']],
1029
+ ['connessione', ['connection']],
1030
+ ['prestazioni', ['performance']],
1031
+ ['memoria', ['memory']],
1032
+ ['debug', ['debug']],
1033
+ ['arresto anomalo', ['crash']],
1034
+ ['riavviare', ['restart']],
1035
+ ['permesso', ['permission']],
1036
+ ['rete', ['network']],
1037
+ ['database', ['database', 'db']],
1038
+ ['distribuire', ['deploy']],
1039
+ ['distribuzione', ['deploy', 'deployment']],
1040
+ ['chiave', ['key', 'token', 'secret']],
1041
+ ['certificato', ['certificate', 'cert']],
1042
+ ['ambiente', ['environment', 'env']],
1043
+ ['coda', ['queue']],
1044
+ ['immagine', ['image']],
1045
+
1046
+ // Portuguese (Português) → English
1047
+ ['erro', ['error', 'err']],
1048
+ ['corrigir', ['fix']],
1049
+ ['construir', ['build']],
1050
+ ['testar', ['test']],
1051
+ ['autenticação', ['auth', 'authentication']],
1052
+ ['configuração', ['config', 'configuration']],
1053
+ ['instalar', ['install']],
1054
+ ['atualizar', ['update', 'upgrade']],
1055
+ ['atualização', ['update', 'upgrade']],
1056
+ ['registro', ['log']],
1057
+ ['nó', ['node']],
1058
+ ['serviço', ['service']],
1059
+ ['senha', ['password']],
1060
+ ['cópia de segurança',['backup']],
1061
+ ['monitorar', ['monitor']],
1062
+ ['conexão', ['connection']],
1063
+ ['desempenho', ['performance']],
1064
+ ['memória', ['memory']],
1065
+ ['depurar', ['debug']],
1066
+ ['falha', ['crash', 'failure', 'error']],
1067
+ ['reiniciar', ['restart']],
1068
+ ['permissão', ['permission']],
1069
+ ['rede', ['network']],
1070
+ ['banco de dados', ['database', 'db']],
1071
+ ['implantar', ['deploy']],
1072
+ ['implantação', ['deploy', 'deployment']],
1073
+ ['chave', ['key', 'token', 'secret']],
1074
+ ['certificado', ['certificate', 'cert']],
1075
+ ['ambiente', ['environment', 'env']],
1076
+ ['fila', ['queue']],
1077
+ ['imagem', ['image']],
1078
+
1079
+ // ── Russian (Cyrillic) → English ──────────────────────────────────────────
1080
+ ['ошибка', ['error', 'err']],
1081
+ ['ошибки', ['error', 'err']],
1082
+ ['сбой', ['crash', 'failure', 'error']],
1083
+ ['крэш', ['crash', 'panic']],
1084
+ ['баг', ['bug', 'issue']],
1085
+ ['исправить', ['fix', 'bugfix']],
1086
+ ['исправление', ['fix', 'patch']],
1087
+ ['сборка', ['build']],
1088
+ ['собрать', ['build']],
1089
+ ['тест', ['test']],
1090
+ ['тестирование', ['test', 'testing']],
1091
+ ['аутентификация', ['auth', 'authentication']],
1092
+ ['авторизация', ['auth', 'authorization']],
1093
+ ['конфигурация', ['config', 'configuration']],
1094
+ ['настройки', ['config', 'settings']],
1095
+ ['установить', ['install']],
1096
+ ['установка', ['install']],
1097
+ ['обновление', ['update', 'upgrade']],
1098
+ ['обновить', ['update', 'upgrade']],
1099
+ ['журнал', ['log']],
1100
+ ['лог', ['log', 'logs']],
1101
+ ['сервер', ['server']],
1102
+ ['база данных', ['database', 'db']],
1103
+ ['сеть', ['network']],
1104
+ ['память', ['memory', 'ram']],
1105
+ ['кэш', ['cache']],
1106
+ ['отладка', ['debug', 'debugging']],
1107
+ ['перезапуск', ['restart', 'reboot']],
1108
+ ['перезагрузка', ['restart', 'reboot']],
1109
+ ['разрешение', ['permission', 'access']],
1110
+ ['доступ', ['permission', 'access']],
1111
+ ['резерв', ['backup']],
1112
+ ['производительность', ['performance']],
1113
+ ['проблема', ['problem', 'issue', 'error']],
1114
+ ['сервис', ['service']],
1115
+ ['служба', ['service']],
1116
+ ['подключение', ['connection', 'conn']],
1117
+ ['соединение', ['connection', 'conn']],
1118
+ ['развёртывание', ['deploy', 'deployment']],
1119
+ ['развертывание', ['deploy', 'deployment']],
1120
+ ['развёрт', ['deploy', 'deployment']], // stemmed form
1121
+ ['разверт', ['deploy', 'deployment']], // stemmed form
1122
+ ['контейнер', ['container', 'docker']],
1123
+ ['образ', ['image']],
1124
+ ['кластер', ['cluster']],
1125
+ ['узел', ['node']],
1126
+ ['секрет', ['secret', 'key', 'token']],
1127
+ ['ключ', ['key', 'token', 'secret']],
1128
+ ['пароль', ['password']],
1129
+ ['токен', ['token', 'secret']],
1130
+ ['мониторинг', ['monitor', 'monitoring']],
1131
+ ['наблюдение', ['monitor', 'monitoring']],
1132
+ ['мигрировать', ['migrate', 'migration']],
1133
+ ['миграция', ['migrate', 'migration']],
1134
+
1135
+ // ── Turkish (Latin + special chars ğ/ş/ı) → English ─────────────────────
1136
+ ['hata', ['error', 'err']],
1137
+ ['hatalı', ['error', 'err']],
1138
+ ['çökme', ['crash', 'panic']],
1139
+ ['çöküş', ['crash', 'failure']],
1140
+ ['düzeltmek', ['fix', 'bugfix']],
1141
+ ['düzeltme', ['fix', 'patch']],
1142
+ ['derleme', ['build']],
1143
+ ['derlemek', ['build']],
1144
+ ['test', ['test']],
1145
+ ['testlemek', ['test', 'testing']],
1146
+ ['kimlik doğrulama',['auth', 'authentication']],
1147
+ ['yetkilendirme', ['auth', 'authorization']],
1148
+ ['yapılandırma', ['config', 'configuration']],
1149
+ ['ayarlar', ['config', 'settings']],
1150
+ ['yüklemek', ['install']],
1151
+ ['kurulum', ['install']],
1152
+ ['güncelleme', ['update', 'upgrade']],
1153
+ ['günlük', ['log']],
1154
+ ['sunucu', ['server']],
1155
+ ['veritabanı', ['database', 'db']],
1156
+ ['ağ', ['network']],
1157
+ ['bellek', ['memory', 'ram']],
1158
+ ['önbellek', ['cache']],
1159
+ ['hata ayıklama', ['debug', 'debugging']],
1160
+ ['yeniden başlatma',['restart', 'reboot']],
1161
+ ['izin', ['permission', 'access']],
1162
+ ['yetki', ['permission', 'access', 'acl']],
1163
+ ['yedekleme', ['backup']],
1164
+ ['performans', ['performance']],
1165
+ ['sorun', ['problem', 'issue', 'error']],
1166
+ ['servis', ['service']],
1167
+ ['hizmet', ['service']],
1168
+ ['bağlantı', ['connection', 'conn']],
1169
+ ['dağıtım', ['deploy', 'deployment']],
1170
+ ['dağıtmak', ['deploy']],
1171
+ ['konteyner', ['container', 'docker']],
1172
+ ['küme', ['cluster']],
1173
+ ['düğüm', ['node']],
1174
+ ['gizli', ['secret', 'key']],
1175
+ ['anahtar', ['key', 'token', 'secret']],
1176
+ ['şifre', ['password']],
1177
+ ['izleme', ['monitor', 'monitoring']],
1178
+ ['göç', ['migrate', 'migration']],
1179
+
1180
+ // ── Vietnamese (Latin + Latin Extended Additional U+1EA0–U+1EF9) → English ─
1181
+ ['triển khai', ['deploy', 'deployment']],
1182
+ ['triển', ['deploy', 'deployment']], // split token form
1183
+ ['lỗi', ['error', 'err']],
1184
+ ['sửa lỗi', ['fix', 'bugfix', 'debug']],
1185
+ ['xây dựng', ['build']],
1186
+ ['kiểm tra', ['test']],
1187
+ ['xác thực', ['auth', 'authentication']],
1188
+ ['cấu hình', ['config', 'configuration']],
1189
+ ['cài đặt', ['install']],
1190
+ ['cập nhật', ['update', 'upgrade']],
1191
+ ['nhật ký', ['log']],
1192
+ ['máy chủ', ['server']],
1193
+ ['cơ sở dữ liệu', ['database', 'db']],
1194
+ ['mạng', ['network']],
1195
+ ['bộ nhớ', ['memory', 'ram']],
1196
+ ['bộ đệm', ['cache']],
1197
+ ['gỡ lỗi', ['debug', 'debugging']],
1198
+ ['sự cố', ['crash', 'failure', 'error']],
1199
+ ['khởi động lại', ['restart', 'reboot']],
1200
+ ['quyền', ['permission', 'access']],
1201
+ ['sao lưu', ['backup']],
1202
+ ['hiệu suất', ['performance']],
1203
+ ['vấn đề', ['problem', 'issue', 'error']],
1204
+ ['dịch vụ', ['service']],
1205
+ ['kết nối', ['connection', 'conn']],
1206
+ ['vùng chứa', ['container', 'docker']],
1207
+ ['nút', ['node']],
1208
+ ['khóa', ['key', 'token', 'secret']],
1209
+ ['mật khẩu', ['password']],
1210
+ ['giám sát', ['monitor', 'monitoring']],
1211
+ ['di chuyển', ['migrate', 'migration']],
1212
+ ]);
1213
+
1214
+ /**
1215
+ * Expand a token to its cross-lingual synonyms.
1216
+ * Returns synonyms to be added to the token stream (pre-tokenized).
1217
+ * CJK synonyms are NOT pre-bigram'd here — caller handles that.
1218
+ */
1219
+ function expandCrossLingual(token: string): string[] {
1220
+ return CROSS_LINGUAL_MAP.get(token) ?? [];
1221
+ }
1222
+
1223
+ /**
1224
+ * Tokenize text into meaningful keywords.
1225
+ * Handles accented Latin, RTL (Arabic, Hebrew), and CJK (Chinese, Japanese, Korean).
1226
+ *
1227
+ * Features:
1228
+ * • CJK → character bigrams
1229
+ * • Katakana → additionally emits Hepburn romaji tokens (enables romaji queries)
1230
+ * • Arabic → word tokenization + iterative prefix stemming (ال/و/ب/ل/ف/ك)
1231
+ * • Hebrew → word tokenization + iterative prefix stemming (ה/ו/ב/ל/מ/כ)
1232
+ * • Farsi → word tokenization + suffix/prefix stemming (ها/های plural, می prefix)
1233
+ * • Hindi → Devanagari word tokenization + light suffix stemming
1234
+ * • All scripts → cross-lingual synonym expansion (EN↔JA↔ZH↔KO↔AR↔HE↔FA↔HI↔DE↔FR↔ES↔IT↔PT)
1235
+ */
1236
+ function tokenize(text: string): string[] {
1237
+ const tokens: string[] = [];
1238
+ // Pre-process: split camelCase/PascalCase identifiers and snake_case before lowercasing
1239
+ const lower = preprocessText(text).toLowerCase();
1240
+
1241
+ for (const [seg] of lower.matchAll(SEGMENT_RE)) {
1242
+ if (CJK_RE.test(seg)) {
1243
+ // CJK segment — extract overlapping character bigrams
1244
+ for (let i = 0; i < seg.length - 1; i++) {
1245
+ const bigram = seg[i] + seg[i + 1];
1246
+ if (!STOPWORDS.has(seg[i]) && !STOPWORDS.has(bigram)) {
1247
+ tokens.push(bigram);
1248
+ }
1249
+ }
1250
+ // Trailing single character (for 1-char CJK terms)
1251
+ if (seg.length >= 1) {
1252
+ const last = seg[seg.length - 1];
1253
+ if (!STOPWORDS.has(last)) tokens.push(last);
1254
+ }
1255
+ // Whole-segment cross-lingual lookup (for known tech terms like デプロイ, 部署, 배포)
1256
+ // This ensures CJK full-words map to their English equivalents
1257
+ const segSyns = expandCrossLingual(seg);
1258
+ for (const syn of segSyns) {
1259
+ if (!CJK_RE.test(syn)) {
1260
+ syn.split(/\s+/).filter(w => w.length > 1 && !STOPWORDS.has(w)).forEach(w => tokens.push(w));
1261
+ }
1262
+ }
1263
+ // Romanization: extract katakana-only portion and emit romaji tokens.
1264
+ // This allows users to search "kontena" or "depuroi" to find katakana docs.
1265
+ const kataOnly = seg.replace(/[^\u30a0-\u30ff]/g, '');
1266
+ if (kataOnly.length >= 2) {
1267
+ const romaji = katakanaToRomaji(kataOnly);
1268
+ if (romaji.length >= 2 && romaji.length <= 40 && !STOPWORDS.has(romaji)) {
1269
+ tokens.push(romaji);
1270
+ }
1271
+ }
1272
+ } else if (RTL_RE.test(seg)) {
1273
+ // RTL segment — 3-way branch: Farsi / Hebrew / Arabic
1274
+ const words = seg.trim().split(/\s+/);
1275
+ let stemFn: (w: string) => string;
1276
+ if (FARSI_CHAR_RE.test(seg)) stemFn = farsiLightStem;
1277
+ else if (HEBREW_CHAR_RE.test(seg)) stemFn = hebrewLightStem;
1278
+ else stemFn = arabicLightStem;
1279
+ for (const w of words) {
1280
+ if (w.length <= 1 || STOPWORDS.has(w)) continue;
1281
+ const stemmed = stemFn(w);
1282
+ if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) tokens.push(stemmed);
1283
+ }
1284
+ } else if (DEVANAGARI_RE.test(seg)) {
1285
+ // Devanagari segment (Hindi / Marathi) — naturally space-separated
1286
+ const words = seg.trim().split(/[\s\u200c\u200d]+/); // handle zero-width joiners
1287
+ for (const w of words) {
1288
+ const deva = w.replace(/[^\u0900-\u097f0-9]/g, '');
1289
+ if (deva.length <= 1 || STOPWORDS.has(deva)) continue;
1290
+ const stemmed = hindiLightStem(deva);
1291
+ if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) tokens.push(stemmed);
1292
+ }
1293
+ } else if (BENGALI_RE.test(seg)) {
1294
+ // Bengali (Bangla) segment — space-separated words, Bengali script
1295
+ const words = seg.trim().split(/[\s\u200c\u200d]+/);
1296
+ for (const w of words) {
1297
+ const bn = w.replace(/[^\u0980-\u09ff0-9]/g, '');
1298
+ if (bn.length <= 1 || STOPWORDS.has(bn)) continue;
1299
+ const stemmed = bengaliLightStem(bn);
1300
+ if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) tokens.push(stemmed);
1301
+ }
1302
+ } else if (CYRILLIC_RE.test(seg)) {
1303
+ // Cyrillic segment (Russian, Bulgarian, Ukrainian, Serbian …)
1304
+ const words = seg.trim().split(/\s+/);
1305
+ for (const w of words) {
1306
+ const cyr = w.replace(/[^\u0400-\u04ff]/g, '');
1307
+ if (cyr.length <= 1 || STOPWORDS.has(cyr)) continue;
1308
+ const stemmed = russianLightStem(cyr);
1309
+ if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) tokens.push(stemmed);
1310
+ }
1311
+ } else {
1312
+ // Latin / other — includes Turkish (ğ/ş/ı), Polish (ą/ę/ł/ń/ó/ś/ź/ż/ć),
1313
+ // Czech (č/š/ž/ě/ů/ř), Hungarian (ő/ű), Romanian (ș/ț), Vietnamese (ắặầổợụừ…)
1314
+ const words = seg
1315
+ .replace(/[^a-záàâãäåæçéèêëíìîïñóòôõöúùûüýÿßœğşıİąćęłńśźżčšžěůřőűșț\u1ea0-\u1ef90-9:\-./]/g, ' ')
1316
+ .split(/\s+/)
1317
+ .filter(w => w.length > 1 && !STOPWORDS.has(w));
1318
+ // Turkish: apply light stemmer to words with Turkish-specific chars
1319
+ for (const w of words) {
1320
+ if (TURKISH_CHAR_RE.test(w)) {
1321
+ const stemmed = turkishLightStem(w);
1322
+ if (stemmed !== w && stemmed.length > 1 && !STOPWORDS.has(stemmed)) {
1323
+ tokens.push(stemmed);
1324
+ }
1325
+ }
1326
+ tokens.push(w);
1327
+ }
1328
+ }
1329
+ }
1330
+
1331
+ // ── Cross-lingual expansion ──────────────────────────────────────────────
1332
+ // For each token, look up synonyms in other languages and add them.
1333
+ // CJK synonyms are bigram-expanded inline; Latin/RTL synonyms added as-is.
1334
+ const expansions: string[] = [];
1335
+ const seen = new Set(tokens);
1336
+ for (const tok of tokens) {
1337
+ const syns = expandCrossLingual(tok);
1338
+ for (const syn of syns) {
1339
+ if (seen.has(syn)) continue;
1340
+ seen.add(syn);
1341
+ if (CJK_RE.test(syn)) {
1342
+ // CJK synonym → expand to character bigrams
1343
+ for (let i = 0; i < syn.length - 1; i++) {
1344
+ const bg = syn[i] + syn[i + 1];
1345
+ if (!STOPWORDS.has(syn[i]) && !STOPWORDS.has(bg)) expansions.push(bg);
1346
+ }
1347
+ if (syn.length >= 1) {
1348
+ const last = syn[syn.length - 1];
1349
+ if (!STOPWORDS.has(last)) expansions.push(last);
1350
+ }
1351
+ } else if (RTL_RE.test(syn)) {
1352
+ // RTL synonym — apply correct stemmer per language
1353
+ const rtlStem = FARSI_CHAR_RE.test(syn) ? farsiLightStem
1354
+ : HEBREW_CHAR_RE.test(syn) ? hebrewLightStem
1355
+ : arabicLightStem;
1356
+ const stemmed = rtlStem(syn);
1357
+ if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) expansions.push(stemmed);
1358
+ } else if (DEVANAGARI_RE.test(syn)) {
1359
+ // Devanagari synonym (Hindi)
1360
+ const stemmed = hindiLightStem(syn);
1361
+ if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) expansions.push(stemmed);
1362
+ } else if (BENGALI_RE.test(syn)) {
1363
+ // Bengali synonym
1364
+ const stemmed = bengaliLightStem(syn);
1365
+ if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) expansions.push(stemmed);
1366
+ } else if (CYRILLIC_RE.test(syn)) {
1367
+ // Cyrillic synonym (Russian)
1368
+ const cyr = syn.replace(/[^\u0400-\u04ff]/g, '');
1369
+ const stemmed = russianLightStem(cyr);
1370
+ if (stemmed.length > 1 && !STOPWORDS.has(stemmed)) expansions.push(stemmed);
1371
+ } else {
1372
+ // Latin synonym (includes Turkish) — split in case of multi-word
1373
+ const parts = syn.split(/\s+/).filter(w => {
1374
+ if (w.length <= 1 || STOPWORDS.has(w)) return false;
1375
+ return true;
1376
+ });
1377
+ for (const p of parts) {
1378
+ expansions.push(p);
1379
+ // Apply Turkish stemmer if it has Turkish-specific characters
1380
+ if (TURKISH_CHAR_RE.test(p)) {
1381
+ const stemmed = turkishLightStem(p);
1382
+ if (stemmed !== p && stemmed.length > 1 && !STOPWORDS.has(stemmed)) {
1383
+ expansions.push(stemmed);
1384
+ }
1385
+ }
1386
+ }
1387
+ }
1388
+ }
1389
+ }
1390
+ tokens.push(...expansions);
1391
+
1392
+ return tokens;
1393
+ }
1394
+
1395
+ /**
1396
+ * Split a multi-topic query into sub-queries.
1397
+ * Detects numbered lists, semicolons, "and also", line breaks, etc.
1398
+ *
1399
+ * Example: "deploy API fix routing and also check auth" →
1400
+ * ["deploy API fix routing", "check auth"]
1401
+ *
1402
+ * Example: "1. deploy 2. fix routing 3. auth" →
1403
+ * ["deploy", "fix routing", "auth"]
1404
+ */
1405
+ function splitMultiQuery(query: string): string[] {
1406
+ // Numbered list: "1. foo 2. bar 3. baz"
1407
+ const numberedParts = query.split(/\d+[.)]\s*/g).filter(s => s.trim().length > 2);
1408
+ if (numberedParts.length >= 2) return numberedParts.map(s => s.trim());
1409
+
1410
+ // Semicolons or newlines
1411
+ const semiParts = query.split(/[;\n]+/).filter(s => s.trim().length > 2);
1412
+ if (semiParts.length >= 2) return semiParts.map(s => s.trim());
1413
+
1414
+ // Conjunctions (EN + DE + FR + ES + IT + PT + RU + TR)
1415
+ const conjParts = query.split(
1416
+ /\b(?:and also|also noch|außerdem|plus|additionally|furthermore|de plus|además|inoltre|além disso|а также|и также|кроме того|плюс|ayrıca|bunun yanı sıra|همچنین|وأيضاً|وكذلك|وهمچنین|और भी|इसके अलावा)\b/i
1417
+ ).filter(s => s.trim().length > 2);
1418
+ if (conjParts.length >= 2) return conjParts.map(s => s.trim());
1419
+
1420
+ // Comma-separated with 3+ parts (likely a list)
1421
+ const commaParts = query.split(/,\s*/).filter(s => s.trim().length > 2);
1422
+ if (commaParts.length >= 3) return commaParts.map(s => s.trim());
1423
+
1424
+ // Single query
1425
+ return [query];
1426
+ }
1427
+
1428
+ // ── BM25+ Scoring ─────────────────────────────────────────────────────────────
1429
+ //
1430
+ // BM25+ fixes the "long document under-scoring" bug in classic BM25.
1431
+ // Paper: Lv & Zhai (2011) "Lower-Bounding Term Frequency Normalization"
1432
+ //
1433
+ // Improvements over standard BM25:
1434
+ // 1. δ=1 additive term → guarantees TF>0 terms always contribute positively
1435
+ // 2. Bigram proximity boost → adjacent query terms in doc get 2× weight
1436
+ // 3. Recency boost → entries with timestamps get exp-decay bonus (7d half-life)
1437
+ // 4. Levenshtein fuzzy match → typo-tolerant (distance ≤ 2)
1438
+ //
1439
+
1440
+ /** BM25+ parameters */
1441
+ const BM25_K1 = 1.2; // term frequency saturation
1442
+ const BM25_B = 0.75; // length normalization
1443
+ const BM25_DELTA = 1.0; // BM25+ lower-bound guarantee (0 = classic BM25)
1444
+
1445
+ /** Recency boost: half-life in days. Entry from 7 days ago gets 0.5× boost. */
1446
+ const RECENCY_HALF_LIFE_DAYS = 7;
1447
+
1448
+ /**
1449
+ * Zero-results query log — in-memory FIFO ring (max 500 entries).
1450
+ * Exposed via /health for observability. Never persisted.
1451
+ */
1452
+ interface ZeroResultEntry { query: string; ts: number; }
1453
+ const ZERO_RESULTS_LOG: ZeroResultEntry[] = [];
1454
+ const ZERO_RESULTS_MAX = 500;
1455
+ let zeroResultsTotal = 0;
1456
+
1457
+ function logZeroResult(query: string): void {
1458
+ zeroResultsTotal++;
1459
+ if (ZERO_RESULTS_LOG.length >= ZERO_RESULTS_MAX) ZERO_RESULTS_LOG.shift();
1460
+ ZERO_RESULTS_LOG.push({ query, ts: Date.now() });
1461
+ }
1462
+
1463
+ /** Global index vocabulary — rebuilt on each keywordSearch call from the doc set. */
1464
+ let _indexVocab: Set<string> = new Set();
1465
+
1466
+ interface DocEntry {
1467
+ key: string;
1468
+ content: string;
1469
+ tokens: string[];
1470
+ tokenFreq: Map<string, number>; // term → count in this doc
1471
+ bigrams: Set<string>; // "term1|term2" adjacency pairs
1472
+ keyTokens: Set<string>; // tokens from key/title only (for title boost)
1473
+ timestamp?: number; // epoch ms, extracted from content if present
1474
+ }
1475
+
1476
+ interface KeywordMatch {
1477
+ key: string;
1478
+ content: string;
1479
+ score: number;
1480
+ matchedWords: string[];
1481
+ subQuery?: string; // which sub-query matched (for multi-topic)
1482
+ }
1483
+
1484
+ /**
1485
+ * Levenshtein distance — edit distance between two strings.
1486
+ * Used for typo-tolerant fuzzy matching (distance ≤ 2 = match).
1487
+ * O(n*m) but strings are short (tokens), so this is fast.
1488
+ */
1489
+ function levenshtein(a: string, b: string): number {
1490
+ if (a === b) return 0;
1491
+ if (a.length === 0) return b.length;
1492
+ if (b.length === 0) return a.length;
1493
+ // Skip if length diff > 2 (can't be ≤ 2 edits)
1494
+ if (Math.abs(a.length - b.length) > 2) return 3;
1495
+
1496
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
1497
+ for (let i = 1; i <= a.length; i++) {
1498
+ let corner = i - 1;
1499
+ prev[0] = i;
1500
+ for (let j = 1; j <= b.length; j++) {
1501
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
1502
+ const cur = Math.min(prev[j] + 1, prev[j - 1] + 1, corner + cost);
1503
+ corner = prev[j];
1504
+ prev[j] = cur;
1505
+ }
1506
+ }
1507
+ return prev[b.length];
1508
+ }
1509
+
1510
+ /** Extract a timestamp from JSON content (looks for "ts" or "created" fields). */
1511
+ function extractTimestamp(content: string): number | undefined {
1512
+ // Fast regex for ISO dates in JSON: "ts":"2026-04-17T..." or "created":"..."
1513
+ const match = content.match(/"(?:ts|created|created_at|timestamp)"\s*:\s*"([^"]+)"/);
1514
+ if (match) {
1515
+ const ms = Date.parse(match[1]);
1516
+ if (!isNaN(ms)) return ms;
1517
+ }
1518
+ return undefined;
1519
+ }
1520
+
1521
+ /** Recency multiplier: 1.0 for now, 0.5 after half-life days, exponential decay. */
1522
+ function recencyBoost(timestampMs: number | undefined): number {
1523
+ if (!timestampMs) return 1.0; // no timestamp → neutral
1524
+ const ageDays = (Date.now() - timestampMs) / (1000 * 60 * 60 * 24);
1525
+ if (ageDays <= 0) return 1.5; // future/just-now → max boost
1526
+ return Math.pow(0.5, ageDays / RECENCY_HALF_LIFE_DAYS) + 0.5; // range: [0.5, 1.5]
1527
+ }
1528
+
1529
+ /**
1530
+ * BM25+ with Bigram Proximity, Recency Boost, and Fuzzy Matching.
1531
+ *
1532
+ * Architecture:
1533
+ * ┌─────────────────────────────────────────────────────────────────┐
1534
+ * │ Query: "deploy API fix routing; check auth" │
1535
+ * │ ↓ splitMultiQuery │
1536
+ * │ Sub-queries: ["deploy API fix routing", "check auth"] │
1537
+ * │ ↓ for each sub-query │
1538
+ * │ Tokenize → BM25+ score per doc → Bigram boost → Fuzzy match │
1539
+ * │ ↓ merge & deduplicate │
1540
+ * │ Recency boost → Sort → Top-K │
1541
+ * └─────────────────────────────────────────────────────────────────┘
1542
+ */
1543
+ async function keywordSearch(
1544
+ redis: Redis,
1545
+ patterns: string[],
1546
+ query: string,
1547
+ topK = 10,
1548
+ ): Promise<KeywordMatch[]> {
1549
+ // ── Step 1: Collect all matching keys — patterns scanned in parallel ──
1550
+ const keyBatches = await Promise.all(patterns.map(pattern => {
1551
+ const keys: string[] = [];
1552
+ const stream = redis.scanStream({ match: pattern, count: 500 });
1553
+ return new Promise<string[]>((resolve, reject) => {
1554
+ stream.on('data', (batch: string[]) => {
1555
+ keys.push(...batch.filter((k: string) => !k.endsWith(':meta')));
1556
+ });
1557
+ stream.on('end', () => resolve(keys));
1558
+ stream.on('error', reject);
1559
+ });
1560
+ }));
1561
+ // Deduplicate across patterns (a key may match multiple globs)
1562
+ const allKeys = [...new Set(keyBatches.flat())];
1563
+
1564
+ if (allKeys.length === 0) return [];
1565
+
1566
+ // Pipeline GET for speed
1567
+ const pipeline = redis.pipeline();
1568
+ for (const key of allKeys) pipeline.get(key);
1569
+ const results = await pipeline.exec();
1570
+
1571
+ const docs: DocEntry[] = [];
1572
+ let totalTokens = 0;
1573
+
1574
+ for (let i = 0; i < allKeys.length; i++) {
1575
+ const content = results?.[i]?.[1] as string | null;
1576
+ if (!content) continue;
1577
+
1578
+ const tokens = tokenize(`${allKeys[i]} ${content}`);
1579
+ if (tokens.length === 0) continue;
1580
+
1581
+ // Term frequency map
1582
+ const tokenFreq = new Map<string, number>();
1583
+ for (const t of tokens) {
1584
+ tokenFreq.set(t, (tokenFreq.get(t) ?? 0) + 1);
1585
+ }
1586
+
1587
+ // Bigrams — adjacent token pairs for proximity detection
1588
+ const bigrams = new Set<string>();
1589
+ for (let j = 0; j < tokens.length - 1; j++) {
1590
+ bigrams.add(`${tokens[j]}|${tokens[j + 1]}`);
1591
+ }
1592
+
1593
+ const timestamp = extractTimestamp(content);
1594
+ // Key tokens for title-boost: terms appearing in the Redis key get extra weight
1595
+ const keyTokens = new Set(tokenize(allKeys[i]));
1596
+ docs.push({ key: allKeys[i], content, tokens, tokenFreq, bigrams, keyTokens, timestamp });
1597
+ totalTokens += tokens.length;
1598
+ }
1599
+
1600
+ if (docs.length === 0) return [];
1601
+ const avgDL = totalTokens / docs.length;
1602
+
1603
+ // ── Step 2: IDF (inverse document frequency) ──
1604
+ const docFreq = new Map<string, number>();
1605
+ for (const doc of docs) {
1606
+ const seen = new Set<string>();
1607
+ for (const t of doc.tokens) {
1608
+ if (!seen.has(t)) {
1609
+ docFreq.set(t, (docFreq.get(t) ?? 0) + 1);
1610
+ seen.add(t);
1611
+ }
1612
+ }
1613
+ }
1614
+ const N = docs.length;
1615
+
1616
+ function idf(term: string): number {
1617
+ const df = docFreq.get(term) ?? 0;
1618
+ return Math.log((N - df + 0.5) / (df + 0.5) + 1);
1619
+ }
1620
+
1621
+ /**
1622
+ * BM25+ score for a term in a document.
1623
+ * Classic BM25: TF_norm = tf*(k1+1) / (tf + k1*(1 - b + b*dl/avgdl))
1624
+ * BM25+ adds: + δ (guarantees long docs with the term still score positively)
1625
+ */
1626
+ function bm25PlusTerm(term: string, doc: DocEntry): number {
1627
+ const tf = doc.tokenFreq.get(term) ?? 0;
1628
+ if (tf === 0) return 0;
1629
+ const dl = doc.tokens.length;
1630
+ const tfNorm = (tf * (BM25_K1 + 1)) / (tf + BM25_K1 * (1 - BM25_B + BM25_B * (dl / avgDL)));
1631
+ return idf(term) * (tfNorm + BM25_DELTA);
1632
+ }
1633
+
1634
+ /**
1635
+ * Fuzzy match: tries exact → prefix → substring → Levenshtein ≤ 2.
1636
+ * Returns [matchedTerm, weight] or null.
1637
+ *
1638
+ * Weights:
1639
+ * 1.0 — exact match
1640
+ * 0.85 — doc token starts with query (e.g. "dockerf" → "dockerfile")
1641
+ * 0.75 — query starts with doc token (e.g. "kubernetes" → "kube")
1642
+ * 0.6 — substring (either direction)
1643
+ * 0.4 — Levenshtein ≤ 2 (typo tolerance)
1644
+ */
1645
+ function fuzzyMatch(qt: string, docTermSet: Set<string>): [string, number] | null {
1646
+ // Exact
1647
+ if (docTermSet.has(qt)) return [qt, 1.0];
1648
+ // Prefix: query is prefix of a doc token (user typed partial word)
1649
+ if (qt.length >= 4) {
1650
+ for (const dt of docTermSet) {
1651
+ if (dt.length > qt.length && dt.startsWith(qt)) return [dt, 0.85];
1652
+ }
1653
+ }
1654
+ // Reverse-prefix: doc token is prefix of query (doc has abbreviated form)
1655
+ if (qt.length >= 4) {
1656
+ for (const dt of docTermSet) {
1657
+ if (dt.length >= 4 && dt.length < qt.length && qt.startsWith(dt)) return [dt, 0.75];
1658
+ }
1659
+ }
1660
+ // Substring (partial)
1661
+ for (const dt of docTermSet) {
1662
+ if (dt.length > 3 && qt.length > 3 && (dt.includes(qt) || qt.includes(dt))) {
1663
+ return [dt, 0.6];
1664
+ }
1665
+ }
1666
+ // Levenshtein ≤ 2 (typo tolerance) — only for tokens ≥ 4 chars
1667
+ if (qt.length >= 4) {
1668
+ for (const dt of docTermSet) {
1669
+ if (dt.length >= 4 && levenshtein(qt, dt) <= 2) {
1670
+ return [dt, 0.4];
1671
+ }
1672
+ }
1673
+ }
1674
+ return null;
1675
+ }
1676
+
1677
+ // ── Step 3: Score each sub-query independently ──
1678
+ const subQueries = splitMultiQuery(query);
1679
+ const allMatches = new Map<string, KeywordMatch>();
1680
+
1681
+ for (const sq of subQueries) {
1682
+ const queryTokens = tokenize(sq);
1683
+ if (queryTokens.length === 0) continue;
1684
+
1685
+ // Pre-compute query bigrams for proximity boost
1686
+ const queryBigrams = new Set<string>();
1687
+ for (let j = 0; j < queryTokens.length - 1; j++) {
1688
+ queryBigrams.add(`${queryTokens[j]}|${queryTokens[j + 1]}`);
1689
+ }
1690
+
1691
+ for (const doc of docs) {
1692
+ let score = 0;
1693
+ const matchedWords: string[] = [];
1694
+ const docTermSet = new Set(doc.tokens);
1695
+
1696
+ for (const qt of queryTokens) {
1697
+ // Try exact BM25+ first
1698
+ const exactScore = bm25PlusTerm(qt, doc);
1699
+ if (exactScore > 0) {
1700
+ score += exactScore;
1701
+ matchedWords.push(qt);
1702
+ continue;
1703
+ }
1704
+ // Fuzzy match (substring or Levenshtein)
1705
+ const fuzz = fuzzyMatch(qt, docTermSet);
1706
+ if (fuzz) {
1707
+ score += bm25PlusTerm(fuzz[0], doc) * fuzz[1];
1708
+ matchedWords.push(`~${qt}`);
1709
+ }
1710
+ }
1711
+
1712
+ // Bigram proximity boost: +50% for each adjacent query term pair found in doc
1713
+ if (queryBigrams.size > 0) {
1714
+ let bigramHits = 0;
1715
+ for (const bg of queryBigrams) {
1716
+ if (doc.bigrams.has(bg)) bigramHits++;
1717
+ }
1718
+ if (bigramHits > 0) {
1719
+ score *= 1 + 0.5 * (bigramHits / queryBigrams.size);
1720
+ }
1721
+ }
1722
+
1723
+ // Phrase-match boost: if the raw (lowercased) query appears verbatim in content, 2× boost
1724
+ // This rewards docs where the exact phrase exists (vs scattered tokens).
1725
+ if (sq.length >= 4 && doc.content.toLowerCase().includes(sq.toLowerCase())) {
1726
+ score *= 2.0;
1727
+ }
1728
+
1729
+ // Key/title boost: if query terms appear in the Redis key (=title), 1.5× boost
1730
+ // Keys often encode the primary topic (e.g. "deploy:api:server"), so key hits are high-precision.
1731
+ if (score > 0) {
1732
+ let keyHits = 0;
1733
+ for (const qt of queryTokens) {
1734
+ if (doc.keyTokens.has(qt)) keyHits++;
1735
+ }
1736
+ if (keyHits > 0) {
1737
+ score *= 1 + 0.5 * (keyHits / queryTokens.length);
1738
+ }
1739
+ }
1740
+
1741
+ // Recency boost
1742
+ score *= recencyBoost(doc.timestamp);
1743
+
1744
+ if (score > 0) {
1745
+ const existing = allMatches.get(doc.key);
1746
+ if (!existing || score > existing.score) {
1747
+ allMatches.set(doc.key, {
1748
+ key: doc.key,
1749
+ content: doc.content,
1750
+ score,
1751
+ matchedWords: [...new Set(matchedWords)],
1752
+ subQuery: subQueries.length > 1 ? sq : undefined,
1753
+ });
1754
+ }
1755
+ }
1756
+ }
1757
+ }
1758
+
1759
+ // ── Step 4: Sort by score, return top-K ──
1760
+ const sorted = [...allMatches.values()].sort((a, b) => b.score - a.score);
1761
+ const topResults = sorted.slice(0, topK);
1762
+
1763
+ // ── Step 5: Zero-results logging + Did-You-Mean ───────────────────────────
1764
+ if (topResults.length === 0) {
1765
+ logZeroResult(query);
1766
+ // Rebuild index vocab for Did-You-Mean suggestions
1767
+ _indexVocab = new Set<string>();
1768
+ for (const doc of docs) for (const t of doc.tokens) _indexVocab.add(t);
1769
+ }
1770
+
1771
+ return topResults;
1772
+ }
1773
+
1774
+ // ── Exported for testing ──────────────────────────────────────────────────────
1775
+ export { tokenize, splitMultiQuery, levenshtein, recencyBoost, extractTimestamp, STOPWORDS,
1776
+ katakanaToRomaji, arabicLightStem, expandCrossLingual, CROSS_LINGUAL_MAP };
1777
+
1778
+ // ── Exported for use in index.ts ──────────────────────────────────────────────
1779
+ export type { KeywordMatch };
1780
+ export { keywordSearch, ZERO_RESULTS_LOG, logZeroResult, _indexVocab as indexVocab, zeroResultsTotal };
1781
+