LTAL, Projet : Moteur de recherche

Idée : Ce projet est un exercice en construction et l'application d'un moteur de recherche à partir d'outils de haut niveau, concrètement : Whoosh de Matt Chaput. Voir aussi la doc. Ceci est une librairie en Python pur, destiné à faciliter la construction des codes de recherche, d'indexation, etc. On travaillera sur l'indexation, la pertinence, etc. On peut en profiter pour la construction des correcteurs orthographiques (il gère les codes basés sur la distance de Levenshtein). La librairie dispose de plusieurs algorithmes du domaine, par ex. de la ranking function BM25F (une variante du tf-idf, voir Wikipédia), des procédures d'analyse des textes, des parseurs de requêtes, etc. Plusieurs choses ici peuvent vous être utiles, en toute indépendance de ce projet et de l'option TAL. Installez Whoosh chez vous et lisez la documentation. (Elle n'est pas idéale, et manque de vrais exemples.) Whoosh a été partiellement inspiré par Lucene, une populaire et efficace machinerie de recherche, écrite en Java, la base d'un populaire serveur de recherche textuelle : Solr ; les deux appartenant au monde Apache, donc libres et bien maintenus.


Énoncé

En suivant les consignes ci-dessous, et éventuellement mon aide (si vous la souhaitez), écrire un indexeur et un moteur de recherche des sites Web (ou répertoires locaux) avec l'interface, qui accepte des requêtes raisonnablement compliquées, avec les compositions booléennes, etc., comme Whoosh le permet.

Vous devez ensuite tester le système sur une configuration grandeur nature. Par exemple, vous essayerez d'indexer deux de mes dossiers d'enseignement qui vous doivent être connus : https://karczmarczuk.users.greyc.fr/TEACH/TAL/, et ... /TEACH/PyObj/ (pas la peine de faire plus !). Il vous faudra suivre récursivement les liens, et traiter seulement les fichiers .html. Après l'indexation on peut passer à la recherche.


Introduction

Puisque Whoosh est une librairie de haut niveau, il est indispensable de maîtriser le rapport entre les éléments de la Recherche d'Information (typiquement : textuelle) introduits en cours (une révision s'impose !), et l'usage des outils disponibles sur le Web. Cette section sera donc assez longue. Revenez à l'exercice sur l'indexation fait en TP. Si vous ne l'avez pas complété, regardez au moins mon programme - test, qui trouve les lignes contenant un mot spécifié. L'index construit par le programme est un dictionnaire, qui associe les mots et les adresses des lignes correspondantes (multiples) dans le texte de Jules Verne. Un minimum de nettoyage a été proposé : j'ai éliminé les mots très courts (moins de 4 lettres), et quelques stop-words. On peut faire mieux.

Cependant, le vrai problème de recherche inclut :


Comment collecter les documents d'un répertoire et d'un site Web.

Files ; outils standard

La manière la plus facile est :
import os
f=os.walk(chemin, params ...)
ce qui construit un itérateur dont les éléments sont des triplets (chemin, repNoms, fichiers). Par ex. si on lance walk("..") depuis le répertoire Progs dans mon dossier TAL (sous Windows), on obtiendra
[   (   '..',
        ['Doc', 'GD', 'Gra', ...],
        ['36180.pdf', 'apptre.html', 'Automates-nombre.gv', ...]),
    (   '..\\Doc',
        ['nxdoc'],
        [   '- Building Probabilistic Models For Natural Language.pdf',
            '0703109v2.pdf',
            '1_1_Brunet.pdf',
            ...]),
    (   '..\\Doc\\nxdoc',
        ['examples', 'reference', 'tutorial', ...],
        ['.buildinfo', 'bibliography.html', 'contents.html', ...]),
    (   '..\\Doc\\nxdoc\\examples', ...
   ...
    (   '..\\TB',
        ['dec'],
        [   'test1.txt',
            'test1.xml',
            'treebolic-installer-2.0.6-20130131.exe',
            ...]),
    ('..\\TB\\dec', [], []),
   ...
  ]
Alors, si vous voulez indexer récursivement tout un répertoire, ce qui reste est le filtrage des noms éligibles (par ex. seulement les .html). Il faudra reconstruire les chemins complets depuis les noms des fichiers et les noms des répertoires, afin de pouvoir ouvrir les fichiers et lire leur contenu. Regardez aussi les dispositifs os.listdir et fnmatch.

Web crawling

Vous connaissez déjà urllib.request qui permet la lecture d'une page Web comme un fichier. En principe il n'est pas difficile de récupérer les structures <a href="http ... > ... </a> à l'aide des expressions régulières, mais c'est fatigant. Le paquetage BeautifulSoup facilite ces opérations. Voici comment récupérer tous les liens dans une page Web :
import urllib.request as ur
dc=ur.urlopen("https://karczmarczuk.users.greyc.fr/TEACH/TAL/Master.html")
doc=dc.read()

from bs4 import BeautifulSoup as BS
soup = BS(doc)

for lnk in soup.find_all('a'):
    x=lnk.get('href'); if x: print(x)
donne :
https://dias.users.greyc.fr/?op=paginas/tal.html
#attent
http://nltk.org/
http://nltk.org/book/
tp0609.html
tp1309.html
...
proHyph.html
proTree.html
proEng.html
Comme vous voyez, on trouve ici des liens relatifs et absolus, et aussi internes.

Afin de suivre les liens, il faut savoir reconstruire les URLs à partir des fragments, et aussi éliminer les éléments inutiles. Le module urllib.parse facilite l'analyse des chaînes qui décrivent l'accès Web. Exemple :

from urllib.parse import urlparse
o = urlparse("https://dias.users.greyc.fr/?op=paginas/tal.html")
donne la valeur de o comme un 6-tuple :
ParseResult(scheme='https', 
            netloc='dias.users.greyc.fr', 
            path='/',  params='', 
            query='op=paginas/tal.html', 
            fragment='')
Pour la reconstruction des chemins, la partie la plus importante est le path, par ex. '/TEACH/TAL/Master.html'.

BeautifulSoup peut faire beaucoup plus, récupérer les balises concrètes (ou leurs hiérarchies), ou des éléments de la page avec un id concret, récupérer le texte purifié, sans le balisage (avec .get_text()), et en général, profiter de plusieurs fonctionnalités présents dans html5lib, lxml, etc.


Indexation avec Whoosh.

Vous n'échapperez pas à la nécessité de lire la doc, (ou ici) mais voici quelques informations pour commencer, car la documentation du Whoosh n'est pas très élémentaire, parfois assez succinte. Vous devez créer un répertoire, où vous allez stocker votre index (ou plusieurs, mais évitez-le pour l'instant).
nd="../indexdir"
os.mkdir(nd)     # Il faut importer os.path .
Il faut établir le Schéma de votre base, quel genre d'informations y sera stocké. Par exemple :
schema = Schema(title=TEXT(stored=True), 
                path=ID(stored=True), 
                content=TEXT(vector=True))
ix = create_in(nd, schema)
Ce schéma a trois champs : deux textes, et un ID qui peut être textuel, mais qui est considéré comme une unité indivisible et unique. Quand vous chercherez un terme, la réponse vous donnera cet ID, ainsi que le titre du document (qui peut être récupéré de la page Web, ou ajouté à la main, etc.) Vous pourrez ajouter à ce schéma d'autres champs, comme la date de la création.

Le paramètre stored=True signifie que le paramètre – qui ne fait forcément pas partie de l'index, on ne peut pas le chercher – est stocké dans la base, et quand on trouve le document, on peut récupérer sa valeur.

Le champ vector (et aussi : format) spécifient quel type d'information est stocké sur le disque et comment. vector concerne le "forward index", utile pour savoir - pour un document - les positions (numéros des mots) et les fréquences des termes indexés. Les formats prédéfinis sont : Existence - oui ou nom pour un terme, sans autre précision ; Stored - le terme n'est pas indexé, seulement stocké et peut faire partie des résultats ; Frequency - l'index mémorise le nombre d'occurrences ; Positions - l'index mémorise les positions séquentielles des mots. Ceci ne facilite pas directement la recherche de la position du terme dans le document, comme dans notre petit exemple d'indexation du livre de Verne, (sauf que nous avons mémorisé les lignes), mais on y reviendra.

La classe TEXT du champ utilise par defaut Frequency, si on n'a pas besoin des contextes des termes, et la paramétrisation inclut phrase=False, ou Positions, si phrase=True (ceci tient par défaut).

Concernant les "vecteurs" et l'index direct, un exemple semble approprié. Si l'index inversé contient l'info suivante, avec les documents numérotés:

apple 	[(doc=1, freq=2), (doc=2, freq=5), (doc=3, freq=1)]
bear 	[(doc=2, freq=7)]
alors le vecteur des termes, ou l'index direct contiendra
DOC      POSTINGS
1 	  [(text=apple, freq=2)]
2 	  [(text=apple, freq=5), (text='bear', freq=7)]
3 	  [(text=apple, freq=1)]
D'autres choses, comme les champs dynamiques, et la modification des schémas dans une base existante, ne seront pas discutés.

Quand le schéma est défini, on peut commencer le travail d'indexation. Voici comment ajouter deux documents à l'index.

writer = ix.writer()
def op(nf):r=open(nf,'r',encoding='utf-8'); return r
f1="../Master.html"
f2="../tp0410.html"
fo1=op(f1); fo2=op(f2)

writer.add_document(title="Master", path=f1,
                    content=fo1.read())
writer.add_document(title="Second document", path=f2,
                    content=fo2.read())
writer.commit()  #(ou .cancel() si on abandonne)
Et c'est tout pour commencer. L'index est là, avec le contenu indexé, et un peu d'info supplémentaire. S'il faut répéter cette expérience, il faut nettoyer l'index, ou - ce qui est plus simple - détruire tout d'abord. Plus d'information : dans la doc, par ex. sur le sujet de la mise à jour de l'index si les documents changent.


Recherche avec Whoosh

Vous ouvrez un index existant, et vous spécifiez son "robot de recherche" :
from whoosh.index import *  # (surtout open_dir)
nd="../indexdir"
ix = open_dir(nd)

sch=ix.searcher()
La phase essentielle est la création de la requête. Ceci peut être un terme (mot) ou une composition booléenne, par exemple
from whoosh.query import *
qry = And([Term("content", "python"), Term("content", "href")])
Ce qui précise les termes cherchés, dans quels champs, et comment combinés. Les opérations Or et Not sont également possibles. Mais ceci n'est pas commode (surtout si la recherche se fait à travers une interface dehors du programme Python). Alors il y a des utilitaires, comme le parseur de requêtes en forme textuelle :
from whoosh.query import *
from whoosh.qparser import QueryParser

qstring="Python OR href"
parser = QueryParser("content", ix.schema)
qry = parser.parse(qstring)
Ensuite il suffit de lancer la recherche :
res = sch.search(qry,terms=True)
# et, par exemple, pour voir si on a trouvé quelque chose :
print(len(res))
Les objets sch et res contiennent plusieurs éléments utiles dans l'analyse. Par ex. list(sch.documents) donne [{'path': '../Master.html', 'title': 'Master'}, {'path': '../tp0410.html', 'title': 'Second document'}].

Voulez-vous savoir ce que vous avez indexé?

r=[str(x,'utf-8') for x in sch.lexicon("content")]
La valeur de r vaut :
['000', '003020', ... 'abcef', 'abréviation', 'abréviations', 'absolument', 'accord', 'accès', 'adaptée', ... 'défaillances', 'définie', 'définissable', 'définition', 'définitions', 'déjà', 'dépassent', 'détails', 'dûment', 'edward', 'efface', 'effacer', 'efficace', ... 'xmlns', 'xy', 'yel', 'yes', 'yx', 'z.br', 'zemljimlnbvazer', 'zip', 'ça', 'échelle', ... '\u0438\u043e\u0441\u0438\u0444\u043e\u0432\u0438\u0447', '\u043b\u0435\u0432\u0435\u043d\u0448\u0442\u0435\u0439\u043d']

(Exercice supplémentaire. Où ai-je, diable, utilisé le mot 'zemljimlnbvazer'?? Je vous assure que ceci n'est ni polonais, ni portugais...)

Exercice supplémentaire sérieux. . Qu'est-ce que cette séquence indexée : '\u043b\u0435\u0432\u0435\u043d\u0448\u0442\u0435\u0439\u043d'? C'est une question sérieuse, et je demande une réponse concrète.

Concernant les résultats :

>>> res
<Top 2 Results for Or([Term('content', 'python'), Term('content', 'href')]) 
   runtime=0.0014853843156043575>
>>> res[0]
<Hit {'path': '../Master.html', 'title': 'Master'}>
>>> res[1]
<Hit {'path': '../tp0410.html', 'title': 'Second document'}>
Afin d'éviter la présence des flots de données ouverts, il est recommandé de fermer le searcher après son travail :
try:
  sch=ix.searcher()
  ...
finally: sch.close()
ou :
with ix.searcher() as sch:
   ...
Le reste du processus passe par l'analyse des propriétés de l'objet résultat. Nous n'allons pas discuter la paramétrisation de la recherche plus élaborée, ou personnalisée, mais ce n'est pas difficile.

Analyse des résultats

Un objet de type Results se comporte comme une liste de résultats (et on peut limiter sa longueur avec limits=N). Puisque title et path ont été stockés, le "Hit" les répertorie. Cet objet se comporte comme un dictionnaire, avec les méthodes keys et values. Et res[0].items() donne [('title', 'Master'), ('path', '../Master.html')].

Puisque dans une recherche simple et restreinte on sait ce que l'on cherche, le résultat ne contient pas forcément le terme trouvé, sauf si .search(...) contient terms=True, comme ici. La réponse aura alors .matched_terms(), qui donne [('content', b'python'), ('content', b'href')].

Nous allons à présent trouver la fréquence des termes. Cette information est stockée lors de l'indexation, donc on la trouve sans analyser les résultats de la recherche. L'expression sch.frequency("content","href") donne 24, c'est le nombre d'occurrences dans tous les documents. Afin de trouver la fréquence dans un document, il faut parcourir l'"index direct". C'est un peu lent, car la structure est séquentielle, mais la programmation est commode, car nous pouvons facilement transformer in itérateur en dictionnaire.

v=sch.vector_as("frequency",0,"content") # Master.
d=dict(v)
Alors d["href"] retourne 15. Si au lieu de "frequency" nous utilisons "positions", ce terme fournira la liste [48, 81, 95, 406, 852, 858, 1763, 1826, 3651] (ce qui n'est pas très utile).


Comment trouver les lignes contenant les termes

Ceci peut être assez compliqué, et les techniques variées sont utilisées. Les objets de classe "Matcher" dans Whoosh stockent l'information qui peut être utile, mais l'apprentissage de ces modules très mal documentés, vous prendra une semaine !

La proposition est la suivante. Revenez à l'indexation. Pour chaque fichier qui devra être indexé, au lieu d'indexer par "content" le contenu du fichier, obtenu par .read(), vous allez lire le fichier ligne par ligne par readline() ou readlines(), et traiter chaque ligne comme un document séparé ! Il faudra stocker l'information qui identifie ce "document" : son chemin (le nom du document entier), le nom (classe ID construite à partir du numéro de la ligne), et aussi la position du début de la ligne dans le document, obtenue par la fonction système tell(). (Lisez le programme ndex0.py).

Vous aurez ainsi probablement une "base" avec des dizaines de milliers de "documents" très courts indexés. Quand un searcher trouve ces documents, vous récupérez le champ contenant le chemin d'accès, et vous le placez dans un dictionnaire, apparié avec les "noms" des documents indexés, c'est à dire, les numéros des lignes. Après la fin de la recherche vous procédez comme dans le programme ndex0 : vous ovrez le grand document, et avec seek() vous positionnez le ficher sur la ligne trouvée, ce qui permettra d'afficher les termes trouvés.

Ceci conclut le projet.


Quelques utilitaires [Lecture optionnelle, hors projet]

(Vous pouvez constater que je "squatte" cette option (et mes TP), afin de compléter vos connaissances dans le domaine des applications pratiques de Python. Non pas du langage ; l'usage des outils de haut niveau s'impose. Si on veut traiter les textes, structures phrasales, relations sémantiques, on doit se libérer de la programmation trop pervasive de bas niveau, qui doit fournir surtout une "colle" pour intégrer les fonctionnalités évoluées. Donc, parlons un peu de Python dans le contexte Web.).


Retour à l'index