domenica 3 dicembre 2023

FAQ Search

 

 

In un momento storico in cui molte delle risposte che cerchiamo vengono generate dall'intelligenza artificiale (i noti modelli GPT) ci sono dei campi in cui fare affidamento solo a queste tecnologie non è del tutto consigliato, il primo che viene in mente è il campo medico dove il parere dell'esperto sia per ricadute sulla salute che per tematiche legali è ancora fondamentale.


Negli anni si è provato ad automatizzare questo processo, uno dei risultati è il proliferare di milioni di siti internet a sfondo medico che hanno contribuito a creare una nuova malattia, la cybercondria.


Le FAQ hanno il vantaggio di non inventare nulla (se sono "certificate") ma allo stesso tempo in molti casi sono estremamente verticali su un argomento utilizzando un linguaggio molto specifico. Questo può essere un problema per i LLM che generano gli embeddings in quanto sono addestrati su testi generalisti e perdono i tecnicismi.

Ho parlato ci cybercondria al PyCon22

 

Come procedere?

Si possono perseguire due strade:

  • addestrare un modello sul mercato verticale, richiede tempo, risorse e soprattutto una fonte dati corposa da cui attingere.
  • utilizzare la [hybrid search](https://weaviate.io/blog/hybrid-search-explained), ovvero combinare un algoritmo di sparse search come BM25 ed un pretrained sentence transformer. Anche qui ci sono dei contro uno verrà citato in seguito.


Non esiste una strategia giusta da utilizzare, in questo notebook mostrerà la seconda, o almeno un modo di approcciare all'hybrid search, andando avanti cercherò di elencare le possibili variazioni sul tema.


Haystack

Per creare il document store e il nostro motore di ricerca utilizzerò Haystack una liberia specializzata in ricerca semantica e Q&A che permette di costruire in maniera modulare search engine utilizzando vettori sia sparsi che densi.

Dataset

Utilizzerò un piccolo dataset, circa 200 FAQ copiato dal sito dell'AIRC.

Pipeline

La FAQ pipeline sarà composta da:

N.B. il notebook è stato creato su Colab, se volete usare altri environment per l'installazione vi consiglio la documentazione ufficiale di haystack.

Hybrid Search Pipeline

DocumentStore

Un Haystack DocumentStore è un database che può memorizzare sia vettori che metadati, questi vengono passati al retriever quando arriva una query.

Haystack fornisce connettori per diversi VectorDB e SearchEngine come (ElasticSearch, Pinecone, Qdrant, ...) ma nel nostro caso avendo pochissimi dati usememo InMemoryDocumentStore.

 

from haystack.document_stores import InMemoryDocumentStore

document_store = InMemoryDocumentStore(use_bm25=True)

Retriever

Come elencato in precedenza ci servono due retriever:

  • Sparse con BM25
  • Dense con Sentence Transformer


Li inizializziamo semplicemente importando le classi dai nodi di haystack.

from haystack.nodes import EmbeddingRetriever, BM25Retriever

sparse_retriever = BM25Retriever(document_store=document_store)

dense_retriever = EmbeddingRetriever(
    document_store=document_store,
    embedding_model="nickprock/sentence-bert-base-italian-uncased",
    batch_size=16,
    use_gpu=True,
    scale_score=False,
)

Caricare i dati sul DocumentStore

Come già accennato abbiamo un .csv di FAQ copiate dal sito dell'AIRC. I dati sono molto pochi ma rendono bene su questo task.

Non ci aspettiamo grandissimi risultati in termini di vastità di query che si possono sottoporre.

import pandas as pd

df = pd.read_csv('/content/airc.csv', sep=";")
# Minimal cleaning
df.fillna(value="", inplace=True)
df["Domanda"] = df["Domanda"].apply(lambda x: x.strip())
df.head()

 

 A questo punto bisogna creare gli embeddings delle domande, in questo caso particolare non ci serve l'embedding di entrambe le parti perchè la ricerca viene fatta solo sulla domanda e le risposte vengono solo proposte senza nessuna elaborazione.

Al termine carichiamo tutto sul DocumentStore

df = df.rename(columns={"Domanda": "content", "Risposta":"answer"})

I documenti vengono caricati nel DocumentStore, il content è la parte che viene "tradotta" in vettore, il resto và nei metadati. Viene anche creato l'indice per il  BM25.

docs_to_index = df.to_dict(orient="records")
document_store.write_documents(docs_to_index)
document_store.update_embeddings(dense_retriever)

Costruire la Pipeline

Haystack ha nella sua cassetta degli attrezzi moltissime pipipeline tra cui quella per le FAQ, però noi vogliamo un hybrid retriever e questo dobbiamo costruircelo.
 

  • Per prima cosa verranno aggiunti alla pipeline due rami, i due retriever che prendono in input la query.
  • I risultati dei due verranno riversati in un nodo di unione `JoinDocuments`, questo nodo calcola uno score, può anche essere usato come punto rerank (non è il nostro caso). Noi non vogliamo un suo score ma solo che concateni i documenti eliminando i duplicati.
  • I documenti estratti verranno passati al reranker, ovvero il nostro cross encoder che ci dirà quali sono i documenti più simili a cio che abbiamo richiesto.
  • Infine il documento viene passato al `Doc2Answers` che ne estrarrà la risposta

from haystack.nodes import JoinDocuments, SentenceTransformersRanker
from haystack.nodes.other.docs2answers import Docs2Answers

join_documents = JoinDocuments()
rerank = SentenceTransformersRanker("nickprock/cross-encoder-italian-bert-stsb")
doc_to_answers = Docs2Answers()

Ora che abbiamo tutte le componenti è il momento di andare a costruire l'hybrid search engine un livello alla volta.

Farlo è molto semplice, inizializziamo una piepline di base e aggiungiamo le componenti, per ogni componente aggiunta và specificato:

  • nome: stringa
  • componente di base: l'oggetto che aggiungiamo
  • gli input: lista di stringhe

from haystack.pipelines import Pipeline

hybrid_retriever = Pipeline()
hybrid_retriever.add_node(component = sparse_retriever,name = "sparseRetriever", inputs=["Query"])
hybrid_retriever.add_node(component = dense_retriever, name = "denseRetriever", inputs=["Query"])
hybrid_retriever.add_node(component = join_documents, name = "joinDocuments", inputs=["sparseRetriever", "denseRetriever"])
hybrid_retriever.add_node(component = rerank, name = "reranker", inputs=["joinDocuments"])
hybrid_retriever.add_node(component=doc_to_answers, name="Docs2Answers", inputs=["reranker"])


Ora possiamo far eseguire la nostra query, inseriamo alcuni parametri presenti ai vari livelli dell'hybrid search:

  • Dai due retriever ci facciamo restituire 10 documenti a testa, quindi nella peggiore delle ipotesi avremo 20 documenti da concatenare
  • Il nodo di join ci restituisce i primi dodici
  • questi dodici vengono passati al reranker che li confronta con la query e ci darà il più simile
prediction = hybrid_retriever.run(query="Cosa sono i marker tumorali?", params={"sparseRetriever":{"top_k": 10},"denseRetriever":{"top_k": 10},"joinDocuments":{"top_k_join": 12},"reranker": {"top_k": 1}})

Debug Pipeline

Per indagare al meglio sui layer della pipeline possiamo andare in debug. Una cosa che non ho approfondito sono gli score dei documenti, diversi retriever danno score diversi e non sempre equiparabili, questo è un problema aperto della hybrid search, se volete approfondire rimando ad un bel post dal blog di Qdrant e ad uno da quello di Pinecone.

Per andare in debug basta aggiungere "debug" = True ai parametri del livello su cui volete indagare.

Conclusioni

Il post è introduttivo e credo sia una base esaustiva e con qualche link di appronfondimento sul tema per chi cerca qualcosa di più avanzato.
Il notebook con il codice lo trovate su github.

Il consiglio che dò a chi si approccia a questa tematica per la prima volta è attivare e disattivare dei livelli nella pipeline e confrontare i risultati restituiti, forse il dataset è troppo piccolo ma la ricerca dovrebbe comunque trarre giovamento dal doppio retriever.
Come sempre i vostri feedback sono ben accetti!
Buona Ricerca 😃