TSM - Artemis - o platformă extensibilă de căutare și manipulare de date în limbaj natural

Tămaș Ionuț - Software Developer @ TORA Trading Services


Un domeniu de model bine structurat conține multe informații ce sunt expresive și ușor de înțeles pentru utilizatorul final. De exemplu, o clasă Comandă are o proprietate Client cu semantica: "O comandă este făcută de un client", iar clasa Client conține proprietăți simple precum Nume, Vârstă, Email cu o semantică ușor de înțeles: Un client numit John în vârstă de 30 de ani cu adresa de email john\@email.com. Astfel, observăm că un domeniu de model bine abstractizat aduce după sine în mod gratuit informații de care ne putem folosi atunci când construim experiența finală pentru utilizator (UX).

Majoritatea operațiunilor noastre pe aplicații web pot fi clasificate în mod generic în două categorii: căutare și procesare de anumite entități. În special în sisteme de administrare web, căutarea este implementată în mod tradițional, navigând la o pagină de căutare pentru anumite modele și aplicând filtre (via căsuțe text, combo lists, radio-buttons, etc.) pentru a filtra pe baza unui subset din proprietăți un grid de entități.

Ar fi ideal dacă tot mecanismul de căutare ar consta într-o singură căsuță text ce poate să răspundă, în limbaj natural unor interogări pentru comenzi precum "Comenzi făcute de John cu prețul > 200RON și orașul de livrare Seattle" și să genereze dinamic un grid pe baza acestor căutări cu rezultatele obținute; de asemenea, aceste interogări să poată să fie aplicate pe orice tip de entitate din domeniul nostru.

În acest fel, am putea expune o experiență de căutare mai rapidă pentru utilizatorii noștri și scapa programatorii sistemului de povara de a construi mecanisme de căutare adaptate pentru domeniul sistemului.

Prolog: Definirea problemei și pipeline-ul de procesare

Construirea unei platforme NLP (Natural Language Processing) comparabilă cu înțelegerea unui om este deocamdată o problemă AI-completă (deocamdată imposibilă), însă scopul nostru este de a:

Bazându-ne pe acestea, schițăm următoarea diagramă a pipeline-ului de procesare în Figura 1:

Figura 1 - Pipeline-ul de procesare NLP

Capitolul 1: Etapa de configurare

Etapa de configurare este pasul în care transformăm domeniul de model al sistemului într-un graf de căutare și vom folosi acest graf atunci când procesăm inputul utilizatorului. Am optat pentru o configurare bazată pe adnotări:

[Searchable]
public partial class Order
{
    [NonSearchable]
    public int Id { get; set; }
    public string ShipCity { get; set; }
    public string ShipCountry { get; set; }
    public string ShipAddress { get; set; }
    public string Code { get; set; }
    public decimal TotalPrice { get; set; }

    [NonSearchable]
    public Nullable<int> ShipperId { get; set; }
    [NonSearchable]
    public Nullable<int> EmployeeId { get; set; }
    [NonSearchable]
    public Nullable<int> CustomerId { get; set; }

    [Searchable("managed by", "handled by", 
    "assigned to")]
    public virtual Employee Employee { get; set; }
    [Searchable("shipped by")]
    public virtual Shipper Shipper { get; set; }
    [Searchable("made by")]
    public virtual Customer Customer { get; set; }
}

Am definit trei tipuri de atribute pentru configurarea domeniului:

Prin alias definim o expresie ce reprezintă semantica tip-proprietate. Implicit, toate proprietățile fac parte din mecanismul de căutare cu semantica implicită "Entity with property name" pentru toate proprietățile non-booleene. Dupa adnotarea domeniului, construim un graf ce conține nodurile ca tipuri "interogabile" legate între ele prin muchii ce conțin semantica relațională definită în aliasurile adnotărilor.
Proprietățile booleene merită atenție specială deoarece ele vin în diverse nuanțe naturale. În faza de construcție, extragem un alias implicit pentru fiecare proprietate booleană în funcție de numele ei. Câteva exemple sunt definite în Figura 3 și este prezentată o scurtă clasificare a acestora:

Figura 3 - Proprietățile de tip boolean și regulile de transformare

Capitolul 2: Etapa de parsare

Faza de parsare este componenta centrală a frameworkului nostru: aici luăm inputul utilizatorului şi îl procesăm pe baza grafului de căutare pentru a construi o structură de date pe care o vom folosi în următorii paşi ai procesării.

Să vedem nişte exemple de interogări pe care dorim să le parsăm pentru a descoperi regulile nostru:

Pe baza acestor exemple, definim regulile de parsare din Figura 4:

Figura 4 - Exemple și regulile de parsare

Figura 5 - State machine-ul parser-ului

Parserul este implementat pe baza diagramei Finite-State Machine, împărţind inputul utilizatorului după spaţii. Structura de date a parserului menţine o stare internă, o stivă a tipurilor identificate şi un arbore al tipurilor de date împreună cu interogări asupra proprietăţilor lor, precum exemplu reprezentat în Figura 6, unde putem vedea cum structura de date îşi schimbă starea în funcţie de cuvintele procesate.

Expresiile booleene sunt mai dificil de procesat deoarece operatorul şi valoarea de comparat sunt absente şi trebuie inferate din semantică relaţiei. Această procesare o facem generând două tipuri de aliasuri pentru fiecare proprietate booleană: versiunea afirmativă şi cea negată, iar în etapa de parsare identificăm versiunea semantică şi tranziționăm direct în starea comparand.

Capitolul 3: Etapa de filtrare de date

După etapa de parsare, construim o expresie generică pentru entitatea de bază urmărind arborele de parsare generat anterior. Pentru construcţia acestei expresii am optat pentru o abordare fluentă, precum în exemplul de mai jos pentru o interogare a unei proprietăţi numerice "mai mare ca", filtre similar fiind implementate asemănător:

public ExpressionBuilder AndGreaterThan(
  string property, int value)
{
  Expression source = GetExpressionBody(property);
  ConstantExpression targetValue = 
    Expression.Constant(value, source.Type); 

  BinaryExpression comparisonExpression = 
    Expression.GreaterThan(source, targetValue);

  _accumulator = Expression.AndAlso(
    _accumulator, comparisonExpression);

    return this;
} 

Pentru interogarea Orders with price > 100 managed by "John", vom folosi construcția:

Expression expression = ExpressionBuilder
               .Empty
               .WithType(typeof(Order))
               .AndGreaterThan("Prie", 100)
               .AndContains("Employee.Name", "John")
               .GetExpression();

unde metoda GetExpression returnează o expresie lambda non-generică pentru tipul specificat. După construcţia expresiei de filtrare, am construit câteva metode ajutătoare (extension methods) pentru a aplica filtrul pe instanţe IEnumerable sau IQueryable:

static IEnumerable<T> WhereBy<T>
 (this IEnumerable<T> collection, Expression filter)

static IEnumerable<object> Where
 (this IQueryable queryable, Expression filter)

Putem folosi aceste metode ajutătoare precum urmează:

IEnumerable<Order> orders = GetOrders();                    
// IEnumerable collection instance

IEnumerable<object> memoryResult = 
  orders.WhereBy(filter);     
// "WhereBy" extension

DbContext context = new ArtemisContext();                      
// EF data context

DbSet queryable = context.Set(typeof(Order));                  
// IQueryable instance

IEnumerable<object> queryableResult = 
  queryable.Where(filter); 
// "Where" extension

Capitolul 4: Etapa de predicție

Pe baza arborelui de parsare, putem arată sugestii utilizatorului pentru a produce o interogare validă. Ne folosim de starea curentă a parsorului şi de cuvintele neidentificate pentru a genera sugestii. De interes aici sunt sugestiile de proprietăţi, unde folosim stiva tipurilor parsate pentru a da o prioritate mai mare a proprietăţilor tipurilor de pe vârful stivei decât celor de jos, deoarece utilizatorii vor aplică filtre pe ultimul tip parsat. Dăm precedenţă proprietăţilor ce nu au fost folosite deja, căci cel mai probabil utilizatorul va aplica un filtru pe proprietate. Figura 6 arată evoluţia entităţilor parsate pe stivă şi arborele de interogare construit pentru interogarea: Orders with price > 100 managed by "John", iar in Figura 1 putem vedea câteva exemple de sugestii.

Epilog: Artemis NLP

Domeniul "Natural language processing" este o zonă foarte complexă în știința calculatoarelor. În acest articol, ne-am concentrat asupra paşilor construirii unui aparat NLP robust, restricţionat oricărui domeniu de model împreună cu o suită de facilităţi de augmentare a semanticii extensibile.