Нечіткий пошук за назвами

Добридень. Проблема пошуку, послуг або продукту виникає на переважній більшості сайтів. І в основному реалізація такої можливості обмежуються пошуком за точним словом, яке ввели в пошуковому рядку.

Якщо є час, і замовник хоче трохи більшого, то гуглять реалізацію найпопулярнішого алгоритму (яким є «відстань Левенштейна») і вписують його.

У цій статті, я опишу сильно доопрацьований алгоритм, заснований, щоправда, на відстані Левенштейна, і наведу приклади коду на C# нечіткого пошуку за назвами, наприклад: кафе, ресторанів чи деяких сервісів… Загалом все, що можна перерахувати і має від одного до одного кількох слів у своєму складі:

"Яндекс", "Mail", "ProjectArmata", "world of tanks", "world of warships", "world of warplanes" і т.д. Тим, хто незнайомий з алгоритмом, я рекомендую прочитати спочатку статті "Реалізація нечіткого пошуку", "Нечіткий пошук у тексті та словнику" або його опис у моєму викладі під слайдером.

Відстань Левенштейна є найбільш популярним алгоритмом мережі для знаходження ступеня відмінності між двома словами. А саме яку мінімальну кількість дій необхідно зробити для отримання з першого рядка другого.

Таких дій лише три:

• Видалення • Вставка • Заміна

Наприклад, для 2-х рядків "CONNECT" і "CONEHEAD" можна побудувати наступну таблицю перетворень:

слова

Теоретично, ціни операцій можуть залежати від виду операції (вставка, видалення, заміна) та/або від символів, що беруть участь у ній. Але в загальному випадку:

w(a, ε) — ціна видалення символу «a», дорівнює 1 w(ε, b) — ціна вставки символу «b», дорівнює 1 w(a, b) — ціна заміни символу «a» » на символ «b», дорівнює 1

Скрізь ставлять одиниці.

А з точкизору математики розрахунок виглядає так.

Нехай S1 і S2 - два рядки (довжиною M і N відповідно) над деяким алфавітом, тоді редакційну відстань (відстань Левенштейна) d(S1, S2) можна підрахувати за наступною формулою рекурентної:

нечіткий

"Дефолтну реалізацію" алгоритму Левенштейна зробили два математики Вангер і Фішер [не плутати з шахістом].

І виглядає це на С# так:

А візуально робота алгоритму для слів «Арештант» та «Дагестан» представляється так:

назвами

Крайній правий-нижній кут матриці показує, як сильно відрізняються слова. З матриці, що в даному конкретному випадку різниця між словами складає 3 умовні папуги.

Якщо незрозуміло, то подивимося на ці слова в іншому поданні:

_ АРЄ С Т А Н Т Д АГЕ С Т А Н _

Таким чином, виходить, щоб перетворити слова "Арештант" і "Дагестан", потрібно одну літеру "Д" додати, одну літеру "Р" замінити на "Г" та літеру "Т" видалити. Т.к. всі дії мають вагу 1, то виходить відмінність у словах становить 3 папуги.

Якщо слова повністю збігаються, то відстань буде 0. Ось і вся теорія все геніальне просто.

І здавалося б – ось воно! Все придумано за нас і робити взагалі нічого не треба, але є проблема.

1) Брати ваговий коефіцієнт 1 завжди – не зовсім правильно, бо ймовірність друкарської помилки залежить від кількох конкретних факторів: відстані клавіш на клавіатурі, фонетичні групи, та й навіть банальної швидкості набору символів. 2) Ідея Левенштейна призначена для «знаходження відмінностей між словами», а не частини слів, що для динамічного результатів, що видаються, у міру введення літер критично. 3) Назви сервісів мають у своєму складі більше одного слова, то людина може просто непам'ятати, в якому порядку вони йдуть.

Також врахуємо ще кілька факторів, типу:

• розкладка клавіатури іншою мовою • транслітерації символів

Саме ці проблеми ми намагатимемося вирішити у цій статті.

Для початку, давайте домовимося, що всі слова ми будемо приводить до одного єдиного регістру. У своїй версії коду я вибрав нижній регістр, це буде відображено в довідках, які нам знадобляться (дані довідники будуть наведені в ході опису). У самій статті я вдаватимуся з різним стилям написання, наприклад, до CamelCase - ProjectArmata, але це зроблено виключно для зручності сприйняття людиною, з точки зору аналізу регістр один (нижній). І ще за основу ми візьмемо не класичний, а оптимізований варіант коду для знаходження відстані Левенштейна звідси:

Дещо скоригуємо його, прибравши зміни порядку слів. Для алгоритму Левенштейна порядок слів немає значення, але він буде важливий з інших причин. У результаті отримали таке:

Почнемо покращення алгоритму пошуку із зміни вагових коефіцієнтів. Для початку зробимо, коефіцієнти вставки та видалення рівним 2.

Тобто. поміняються рядки:

А ось рядок розрахунку коефіцієнта заміни символів, ми, перетворивши на функцію CostDistanceSymbol:

І враховуватимемо два фактори:

1) Клавіатурна відстань 2) Фонетичні групи

У зв'язку з цим, для зручнішої роботи з об'єктами source і target ми перетворимо їх на об'єкт:

Для цього знадобляться такі допоміжні довідники:

Співвідношення кодів клавіш українськомовної клавіатури:

Співвідношення кодів клавіш для англомовної клавіатури

Завдяки цим двом довідникам, говорячи математичною мовою, ми можемоперетворити два різні простори символів в один єдиний універсальний.

І на ньому будуть справедливі наступні співвідношення:

private static SortedDictionary DistanceCodeKey = New SortedDictionary Ремарка

Оптимізований код, який ми взяли за основу, має наступний вид ініціалізації матриці відстані: var distance = new int[2, m + 1];

Тому ця ділянка коду «distance[(i — 2) % 3, …», працювати в поточному вигляді не буде, правильний варіант функції я наведу в кінці статті.

Таким чином, ми виконали перший пункт з доопрацювання коду. Переходимо до другого пункту. Нагадаємо, що ідея Левенштейна призначена для «знаходження відмінностей між словами», а не частини слів, що для динамічного результатів, що видаються, у міру введення букв, є критичним.

Наприклад, у довіднику є два слова:

Ввівши в рядок пошуку запит «Pro» ми отримаємо на виході «Як» як більш пріоритетне, тому що заміна двох символів і видалення одного складе всього 3 папуги (з урахуванням одиничних коефіцієнтів та класичного алгоритму Левенштейна, без наших доробок), а додавання частини слова «jectArmata» 10 папуг.

Найбільш логічним у цій ситуації є порівняння не лише слова, а лише частини шуканого слова із веденим рядком.

Т.к. пошуковий запит складається з трьох символом Pro, ми візьмемо перші три символи від звіряється слова ProjectArmata, тобто. "Pro" і отримаємо 100% збіг. Для цього конкретного випадку - ідеально. Але розглянемо ще кілька варіантів. Допустимо, у нас в базі, є наступний набір слів:

• «Комуналка» • «Конвеєр» • «Колонія» • «Косметика»

Пошуковий запит виглядатиме так «Ком». Як наслідок слова матимуть такі коефіцієнтизбіги:

Комуналка - 0 Конвеєр - 1 Колонія - 1 Косметика - 1

Якщо зі словом «Комуналка» все нормально, то решта трьох слів виглядає якось однаково… Хоча видно, що, можливо, людина описалася в одній літері і вона шукає «Косметика», а не Конвеєр чи Колонія. А наше завдання полягає в тому, щоб видавати йому найкращий результат, а не просто все поспіль. Тим більше, як сказав Дамерау – більшість помилок це перестановки літер.

Для усунення подібної помилки, робимо невелике доопрацювання:

Візьмемо не перші «n» літер, а «n+1» літер, де n – кількість літер у запиті. І тоді коефіцієнти при запиті «Ком» будуть такими:

Комуналка - 1 Косметика - 1 Конвеєр - 2 Колонія - 2

«Косметику» поправили, але «Комуналка» поїхала… Але цей варіант мені подобатися більше з якихось причин. Зазвичай, якщо потрібно пошук, то спочатку інформацію показують користувачеві у вигляді списку, що випадає, під пошуковим рядком, у міру введення літер. Довжину цього список, що випадає, обмежують за розмірами 3-7 записів. Як наслідок, якщо записи всього три, то в другому варіанті з'являться: «Комуналка», «Косметика» та «Конвеєр» [т.к. він тупий перший у видачі через guid-а або просто через дати створення]. А в першому випадку буде «Комуналка», «Конвеєр», «Колонія» та жодної «Косметика», т.к. їй не пощастило з інших причин.

Звичайно, у цієї проблеми є й інші шляхи вирішення. Наприклад, сортувати спочатку, за «n» літерами, а потім взяти ті групи слів у кого збіглися індекси і додатково пересортувати «n+1» літер і можна ще раз, і ще раз… Тут вже вирішується індивідуально залежно від даних у базі , Розв'язуване завдання, обчислювальної потужності.

Давайте не будемо зараз звертати увагу навирішенні вищеописаної проблеми… Ми тільки в середині шляху і мені ще є що розповісти.

Наступний нюанс у правильній пошуковій видачі до нас приходить із часів СРСР. Тоді любили робити назви, що складаються з кількох слів, об'єднаних в одне. Та й зараз це актуально:

Споживспілка ГазПромБанк Россільгоспбанк ProjectArmata БанкУРАЛСИБ І т.д.

[П.С. я знаю, що деякі з назв пишуться через прогалину, але мені потрібно було підібрати яскраві приклади]

Але, якщо слідувати нашому алгоритму, ми завжди беремо перші «n+1» букв із слова. І якщо людина набере слово «Банк», вона не отримає адекватної видачі. Т.к. порівнювати ми будемо рядки:

Щоб знаходити слово «банк» незалежно від позиції, нам потрібно буде зробити плаваюче вікно за фразою та повертати найменший коефіцієнт:

Як можна побачити, до помилки, отриманої після підрахунку відстані Левенштейна, ми додаємо ще значення (i * 2 / 10.0). Якщо з «i*2» — все ясно, це помилка вставки букв на початку слова, як і класичному алгоритмі знаходження відстані Левенштейна, але навіщо ми її ділимо на 10? Суть у тому, що якщо ми просто залишимо «i*2», то в позовну видачу потраплять слова, які тупі менші за довжиною і ми знову втечемо від назви банків. І тому коефіцієнт доводиться ділити на 10, що скоротити це усунення. Чому саме на 10? Для нашої бази він підходив більш-менш нормально, але я не виключаю, що можна ділити на більше. Все заздрості від довжини слів і від того, наскільки слова схожі. Розрахунок найбільш відповідного коефіцієнта я наведу трохи згодом.

Зі словами, як і пошуковою одиницею, начебто розібралися, тепер переходимо до фраз. І спочатку знову кілька прикладів:

• warface • world of tanks • world of warplanes • world ofships

Давайте зрозуміємо, що нам, в принципі, потрібно від пошуку на фразі. Нам потрібно, щоб відсутні слова додавалися, зайві фрази видалялися, а також, щоб вони мінялися місцями. Десь я таке вже говорив… А, точно, згадав… На початку статті, коли розповідав про слова та функції відстані Левенштейна. Щоб не витрачати ваш час, одразу скажу, що в чистому вигляді її використовувати не вдалося, але надихнувшись їй вдалося написати код, який застосовується до фраз.

Як і за реалізації функції відстані Левенштейна, якщо одне з фраз порожня, ми повертаємо величину помилки рівне вставці чи видалення [залежно від цього, з якого боку прийшла порожня фраза] всіх букв.

Тобто. ми підсумовуємо кількість букв у фразі помножуємо на 2 (це коефіцієнт був обраний ще на початку статті для букв) і ще множимо на 100. Ось ці 100 - найсуперечливіший коефіцієнт у всьому алгоритмі. Для чого він необхідний буде більш наочно представлено трохи нижче, а потім я поясню, що в теорії його необхідно вирахувати, а не просто купувати зі стелі.

У цьому коді ми зіставляємо кожне слово з запису в базі з кожним з пошукового запиту і беремо найменші коефіцієнти для кожного слова. Сумарним коефіцієнтом для фрази є таке:

result += minRangeWord * 100 + (Math.Abs(i - minIndex) / 10.0);

Як можна бачити, вся логіка та сама, як і описана раніше, мінімальний коефіцієнт для слова minRangeWord помножуємо на наші 100 і підсумовуємо з коефіцієнтом, що показує на скільки близько слово стоїть до його найбільш підходящої позиції (Math.Abs(i — minIndex) / 10.0).

Примноження на 100 застосовується для компенсації додаткового коефіцієнта, який може виникнути при пошуку найбільш підходящої позиції пошукового слова на попередньому кроці. Як наслідок, данийкоефіцієнт можна вирахувати як найбільший між фразою в пошуковому рядку та всіма словами в базі. Чи не фразами, а саме словами. Але для цього доведеться «порожньо» виміряти відстань Левенштейна з нашими доробками, що дуже марнотратно за ресурсами.

Тобто. прогнати функцію GetRangeWord і взяти максимальне значення відхилення фрази «i*2» від місця, що найбільше підходить. І після взяти найбільше значення довести його до найближчого десятикратного числа (10, 100, 1000, 10000, 100000 і т.д.). Таким чином ми отримаємо два значення:

Перше, це значення, на яке потрібно ділити змішане слово у функції GetRangeWord. Друге, значення, яке потрібно помножити minRangeWord для компенсації попереднього зміщення. Таким чином, ми отримаємо точні показники схожості фраз. Але на практиці можна знехтувати великими відхиленнями і приблизно прикинути середнє… Що я власне й зробив.

У принципі, все. Основні питання я розібрав, що залишилося лише невелике доопрацювання «транслітерації символів». Різниця між вище описаним пошуком і пошуком по транслітерації полягає в тому, що функції CostDistanceSymbol ми не коригуватимемо значення відповіді по відстанях клавіш, т.к. видача у разі буде коректна.

Також можна відзначити, що адекватна видача результату починається з 3 символів у пошуковому рядку, якщо символом менше, краще використовувати більш незграбний метод з точним зіставленням рядків.

Далі я наведу найповніший код всього вище описаного, але спочатку:

1) Цей код написаний мною особисто у вільний від роботи час. 2) Алгоритм продуманий мною особисто у вільний від роботи час.

Дякую всім, хто прочитав. Вітається критика та поради.