Keyword Search
Elavult dokumentáció
Ezen szócikk tartalma a dokumentáció régi formájából lett átemelve, és jelenlegi állapotában nem felel meg a szekción belüli konvencióknak.
A szócikk előtt mindenképp olvassuk el a TF-IDF szócikket. Jelen oldal annak folytatása jelenlegi formájában.
Most, hogy tisztában vagyunk a kulcsszavas keresés fogalmával, és ismerjük a használt algoritmust, rátérünk a gyakorlati implementáció részleteire.
A fő interfész
Az implementáció központjában a BM25Seach osztály áll; ez végzi a korpusz (= szöveghalmaz) kezelését és a modell illesztését.
Az osztály 4 fő műveletet valósít meg:
- dokumentum hozzáadása
- dokumentum eltávolítása
- modellillesztés
- keresés
Minden műveletnek megvan a maga method-ja, ezek logikai működése nem meglepő, így ezen dokumentációból kihagyjuk őket, részletesebben a kódban vannak dokumentálva.
Mikor válik kereshetővé egy dokumentum?
Mivel a BM25 modellnek szüksége van illesztésre, egy dokumentumot kizárólag akkor tud visszaadni, ha ezen dokumentum hozzáadása után lefuttattunk eegy modellillesztést. Hasonlóan, ha egy dokumentumot eltávolítunk, akkor véglegesen akkor tűnik el a rendszerből, amikor illesztünk az eltávolítás után.
Párhuzamosítási problémák
Az implementáció egy érdekesebb része, hogy vigyázni kell a race condition-ök elkerülésére. Mivel ugyanis a kód nem az async szintaxist használja, hanem threading segítségével fut, nem árt észben tartani, hogy mikor és hogyan változhatnak az objektum változói. Célszerű (mint ahogy ez több függvényben látszik) egy threading.Lock objektummal korlátozni az elérést és a módosítást is, hogy elkerüljük, hogy két egymás utáni sor különböző szöveghalmazt hisz aktuálisnak.
Lehetne ezeket async függvényekként is implementálni. A hozzáadás, eltávolítás és keresés műveleteinél valószínűleg működne is nem túl nagy lassulással. Ugyanakkor az illesztés műveletének ideje szemmel látható, és blokkoló folyamat lenne. Persze ez is kikerülhető, ha minden asnyc, kivéve a mátrixműveleteket, és asyncio.to_thread-ek segítségével végezzük ezeket, viszont a race condition-öket ez sem oldja meg, és valami hasonló megoldás akkor is kellene (nem feltétlen lock). Midenesetre jelenlegi implementáció szerint inkább ezekkel nem foglalkozunk, és mindent lock segítségével végzünk, amit szükséges.
A BM25 megvalósítása
A BM25 algoritmus és kapcsolódó műveletek megvalósítására a scikit-learn csomagot használjuk. Maga BM25 implementáció (azaz a BM25Transformer osztály) nem saját, ebben a GitHub repóban megtalálható. Az implementáció nem pontosan ugyanazt az IDF képlet variációt alkalmazza, melyet az algoritmusok működésénél leírtunk, ugyanakkor ez a működést nem befolyásolta, így változatlanul hagytuk.
Mint azt említettük, a jelenlegi megoldásunkban az algoritmust nem szavakra alkalmazzuk, hanem „lemmatizált szókapcsolatokra“. Most részletezzük, mit is jelent ez pontosan. Az alábbi transzformációt a count_analyzer függvény végzi.
-
Adott szöveget először mondatokra bontunk. Innentől kezdve a mondatokra koncentrálunk.
-
Szavakra bontjuk a mondatot, és eltávolítjuk a nagyon gyakori szavakat (ú.n „stop word“)
-
A megtartott szavakat lemmatizáljuk, azaz meghatározzuk a „koncepcionális szótövét“. Ekkor tehát a mondatból gyártottunk egy lemma listát.
-
A lemma listából vesszük a következő „szókapcsolatokat“:
-
egy lemma („hitelkártya“)
-
két egymás utáni lemma („Kamat Plusz“)
-
három egymás utáni lemma („Gránit Platinum Mastercard“)
-
négy egymás utániból egyet kihagyunk („Gránit Platinum
Mastercardbankkártya“)
-
-
Végül tehát egy szöveget átalakítottunk szavak és szókapcsolatok halmazává.
Az implementációban a (3) lépést már hozzáadáskor elvégezzük, és lemmatizált formában tároljuk a dokumentumokat. A (4) lépést az illesztés során hajtjuk végre (pontosabban a CountVectorizer az analyzer paraméterének beadott count_analyzer függvény segítségével).
A szövegek lemmatizálásának feladatát interfész szinten kezeljük, a TextLemmatizer protokoll-osztály definiálja a szükséges műveleteket, és néhány értelmes alap fallback-et. Mivel a lemmagen3 kizárólag szavakat képes lemmatizálni, adott szöveget először mondatokra, majd szavakra kell bontani. Ehhez a SpaCy-t használjuk (minden irreleváns pipeline kikapcsolásával, ugyanis enélkül a sebesség nagyságrendekkel csökkenne).
Mi az a lemma?
A lemmatizáció kérdése alapvető természetes nyelvfeldolgozási (NLP = natural language processing) probléma. Ahogy előbb referáltunk rá, a feladat a „koncepcionális szótő“ meghatározása. Ez néha a valós szótő, néha nem. Néhány példán megmutatjuk a lemmatizáció célját, ami remélhetőleg kitisztítja a fogalom jelentését.
| Szó | Lemma | Szótő | Magyarázat |
|---|---|---|---|
| apádnak | apa | apa | A valós szótő megfelelő. |
| nemzedéketekből | nemzedék | nemz | A valós szótő jelentéstartalomban már messze áll a lemmától, érezni is lehet, hogy a „nemzedéketekből“ az nem redukálható le a „nemz“ szóra, hogy azonos legyen jelentésben. |
| intő | intő int |
int | A lemma kontextus nélkül pontosan nem határozható meg, ugyanis az iskolai intő teljesen más koncepcionálisan, mint „apám intő szava“. |
Minek lemmatizálunk?
Felmerülhet a kérdés, hogy ha szókapcsolatokat határozunk meg, akkor minek lemmatizálunk egyáltalán? Ugyanakkor vegyük észre, hogy adott szó rengeteg alakban szerepelhet a szövegekben (pl. „iskola“, „iskolát“, „iskolába“, „iskolától“, „iskolánk“, …). Azont úl, hogy értelmetlen tárolni ennyi szóalakot, az algoritmus működése is jelentősen romlana, hiszen a „hol találom meg az iskolát?“ kérdésre az „Az iskola címe …“ chunk már kevésbé hasonló a különböző szóalakok miatt.
A lemmatizálás azt a célt szolgálja, hogy az azonos szavakat egy szóalakhoz rendelje.
Hogyan lemmatizálunk?
A lemmatizálás feladata korántsem triviális, igencsak bonyolult NLP probléma, főleg, ha jól akarjuk csinálni. Több módszert próbáltunk a „nagyok“ közül (többek között HuSpaCy, NLTK-Snowball, Stanza). Végül egy kisebb könyvtár, a lemmagen3 lett a befutó: ez biztosítja a legjobb minőséget vállalható sebességgel.
A teljesség igénye nélkül a következő problémák álltak fenn a többi tesztelt csomagnál:
| Csomag | Problémák |
|---|---|
| HuSpaCy | A SpaCy pipeline-ok rendkívül lassúak, nem lehet velük gyorsan sok dokumentumot feldolgozni. |
| Snowball | A Snowball algoritmus nem is lemmatizálásra, hanem szótövesítésre hivatott első sorban. Így túl agresszív, és könnyen azonos alakra tud redukálni teljesen eltérő szavakat. |
| Stanza | Nem igazán jó a magyar tudása a könyvtárnak en bloc. |
| emtsv | Windows-on nem is képes futni, továbbá ez is a lassabb oldalon van. |
Mikor működik jól a BM25?
Mint azt láttuk, így is számos módosítást végzünk a szöveg felbontása során, hogy felülemelkezdjünk az egyszerű, szó alapú keresésen, és minnél pontosabban próbáljuk meg visszaadni a megjelenő szószerkezetek alapján a megfelelő szövegeket (ez heurisztikus tesztek alapján valóban javít).
Ugyanakkor van egy paraméter, amiről eddig nem esett szó, pedig igencsak fontos: a szövegek mennyisége. Mivel a TF-IDF jellegű algoritmusok (köztük a BM25 is) a szövegekben való megjelenést használják a fontosság megtippelésére, a keresés pontos működéséhez alapvetően egy nagy korpusz kell (hiszen vegyük észre, hogy pl. ha egy szövegünk van, az IDF mindenre 1). Kevés szöveg esetén a modell nem (feltétlen) lesz képes kitalálni, hogy mely szavak és szókapcsolatok „kulcsszó-jellegűek“, és melyek lényegtelenek. Ezért nem megfelelő szövegmennyiség esetén jelentősen javítható a teljesítmény, ha egy nagyobb, általános korpuszt rakunk a hozzáadott korpuszunk mellé, amit illesztés után egyszerűen elhagyunk (erre megfelelő lehet párszáz Wikipédia szócikk).
Több korpusz, avagy a környezetek
A kulcsszavas keresés implementációjának része egy környezet-rendszer. Ennek kialakítását már nem maga a keresés funkcionalitás, hanem az admin-rendszer indokolja. Ugyanis új chunk-ok, dokumentumok hozzáadása esetén jogos gondolat, hogy azokat tesztelni szeretnénk, mielőtt a nagyközönség számára közölnénk bármit.
A környezetrendszer filozófiája a következő: környezet lehet „követő“ vagy „önálló“. A követő környezetek mindössze egy referenciát biztosítanak az önálló környezetekre. Ugyanakkor ezeknek is van saját neve, illetve ezáltal mentési útvonala. Az önálló környezetek rendelkeznek egy saját BM25Search objektummal, azaz van saját szöveghalmazuk és mátrixuk.
Egy önálló környezetben minden triviális, egyszerűen lekérjük a megfelelő BM25Search objektumot, majd ezen elvégezzük a kívánt műveletet. A követő környezetekben kicsit változik a helyzet. A keresés művelete hasonló, egyedül a lekérés bonyolultsága változik: addig kell követőkön ugrálni felfelé, amíg önálló környezetet nem találunk (ugyanis követőt is lehet követni). Az összes többi művelet módosító jellegű, így először önállóvá kell alakítanunk a követőt. Ezt egy lecsatolás művelettel végezzük, ami lényegében annyit csinál, hogy a követőhöz átmásolja az általa követett környezet BM25Search objektumát (illetve először is ezt rekurzívan megkeresi, mint a keresésnél). Innentől minden hasonlóan megy.
Az alap környezet neve default (ezt változó szabályozza). Az alap környezetet nem lehet lecsatolni, és minden újonnan inicializált környezet őt követi kezdetben.
A PROD_MATRIX környezet
Jelenleg a python-service és a backend mást tekint „fő“ környezetnek: a python-service a default-ot, míg a backend a PROD_MATRIX-ot használja ilyen célból. Ez a működés ismert, és jelen pillanatban még nehéz eldönteni, hogy bug-nak vagy feature-nek tekintsük. Mindenesetre tudatosan így lett implementálva a backend oldali interfész. (Ebből következik az is, hogy a default környezet mindig üres.)
Jelenlegi alkalmazás
Jelenleg a környezet rendszer nincs kihasználva teljes erejében. A backend oldali alkalmazás többnyire úgy néz ki, hogy ha szükség van arra, hogy specifikus chunk-okból válaszoljunk (pl. tesztelésnél), akkor azokat egyszerűen hozzáadjuk egy új, üres környezethez, majd amikor már nincs szükség erre, azonnal töröljük (pl. tesztelés végén). Ennél komplexebb alkalmazásokra van ezzel felkészítve a rendszer.
Az API
Záró gondolatként álljon pár szó az API felépítéséről. Alapvetően a routing.py fájlban található API feladata pontosan azokat a funkcionalitásokat kitükrözni a backend felé, amelyeket a BM25Search biztosít. Ugyanis ha nem így teszünk, akkor bizonyos esetekben a python-service-ből kéne döntéseket hozni úgy, hogy egyébként az összes információ nem áll rendelkezésre.
Példa
Ennek mintapéldája azon kérdés, hogy mikor hívjuk meg a modellillesztést. Ha ezt a python-service-ben kezelnénk, akkor lehet, hogy feleslegesen gyakran hívnánk illesztést (ami egy blocker folyamatnál nem szerencsés), vagy rossz időben tippelnénk, hogy mikor kell.
Így tehát az API-val nagyon nyers módon lehet irányítani, hogy pontosan mi történjen, és semmilyen automatikus háttérfolyamat nem takarít.
Át lehet-e ezt alakítani?
A rövid válasz, hogy elméleti korlátai nincsenek. Kialakítható egy külön service, ami azzal foglalkozik, hogy időlegesen illeszti a doksihalmazt, amennyiben történt változás. Sőt, a dokumentum hozzáadása és eltávolítása megoldható modellillesztés nélkül a következő módokon:
-
eltávolítás: Egyszerűen kinullázzuk a dokumentum vektorát.
-
hozzáadás: Lekérjük a hozzáadandó dokumentum vektorát (a már illesztett
CountVectorizerésBM25Transformersegítségével), majd ezt a vektort vesszük hozzá a mátrixhoz. Amennyiben sok dokumentumunk van, a képletekben az értékek minimálisan változnak ekkor, ezért ez a művelet egy nagyon közeli mátrixot eredményez (feltéve, hogy a dokumentum nem tartalmaz olyan szót, ami fontos, és eddig sehol máshol nem szerepelt).
Ezzen két módosítás segítségével esély lenne egy olyan BM25 alapú kulcsszavas keresés felállítására, mely service-ként fut, és nem szükséges manuálisan irányítani minden lépését.