Implémenter la recherche hybride simple pour la génération augmentée d'extraction à l'aide d'Oracle Database 23ai

Introduction

Ce tutoriel explique comment implémenter une recherche hybride dans le cadre d'un processus de génération augmentée de récupération (RAG) à l'aide de la recherche vectorielle et par mot-clé dans Oracle 23ai.

La RAG est apparue comme une capacité clé pour les entreprises qui utilisent de grands modèles de langage (LLM) pour augmenter leurs réponses avec des informations commerciales ou de domaine spécifiques. La recherche dans une base de connaissances d'entreprise d'informations pertinentes pour la requête d'un utilisateur, puis l'association des informations extraites à la demande au LLM permettent de répondre en s'appuyant sur des données internes, des stratégies, et des informations spécifiques à un scénario, les informations spécifiques réduisant la probabilité d'hallucination, permettant une réponse en langage naturel qui inclut des citations appropriées et des références à des documents dans la base de connaissances.

Oracle Database offre de nombreuses fonctionnalités puissantes pour l'exécution des tâches RAG. La version Oracle Database 23ai a introduit une fonctionnalité de recherche vectorielle AI, qui permet d'effectuer des recherches sémantiques rapides sur des données non structurées. L'utilisation de la recherche sémantique avec des incorporations vectorielles de haute qualité peut sembler presque magique, avec presque toutes les requêtes appelant des documents très pertinents à partir d'une énorme base de connaissances. Cependant, ce n'est pas parce que la recherche vectorielle est disponible et fournit des résultats de haute qualité dans la plupart des scénarios que la recherche traditionnelle basée sur des mots-clés devrait être abandonnée. Tout développeur qui a passé beaucoup de temps à tester la récupération a certainement découvert des bizarreries, dans lesquelles les documents couvrant le sujet spécifique demandé et qui seraient intuitivement inclus dans la réponse ne le sont pas, même s'ils seraient trivialement trouvés par une recherche par mot clé.

Alors, pourquoi ne pas utiliser les deux ?

Oracle Database 23ai s'appuie sur toutes les fonctionnalités puissantes qui ont été ajoutées à la base de données au fil du temps, y compris Oracle Text, qui fournit des fonctionnalités de requête en texte enrichi. Ce tutoriel est conçu pour montrer comment l'existence de ces deux fonctionnalités dans la base de données rend incroyablement simple l'implémentation d'une recherche hybride robuste, offrant le meilleur des deux mondes sans duplication de données.

Remarque : ce tutoriel montre comment implémenter la logique de recherche hybride en Python à l'aide d'un modèle d'intégration local. Oracle Database 23ai prend en charge le calcul des incorporations de texte dans la base de données via l'utilisation de modèles ONNX, et il existe une prise en charge native de la recherche hybride à l'aide d'un modèle ONNX dans la base de données via un package SGBD (système de gestion de base de données). L'implémentation de la logique directement en Python fournit un contrôle beaucoup plus important sur le comportement de la recherche, mais le package SGBD offre un ensemble simple mais puissant de fonctionnalités pour certains cas d'utilisation. Pour plus d'informations, reportez-vous à Import de modèles ONNX dans l'exemple de bout en bout d'Oracle Database et à Présentation de la recherche hybride.

Objectifs

Prérequis

Tâche 1 : configurer la table de base de données

La table utilisée pour la recherche hybride peut être identique à une table utilisée pour la recherche vectorielle, car Oracle Text peut indexer les champs CLOB (Character Large Object), qui sont généralement utilisés pour stocker le contenu du document.

Remarque : le code SQL pour la configuration initiale de la table est affiché ici directement, au lieu d'être appelé à partir de Python. Le compte utilisateur de base de données utilisé par votre processus RAG doit uniquement être autorisé à interroger la table et non à créer des tables et des index, car ces tâches seront effectuées par un administrateur de base de données à l'aide de leurs outils préférés.

  1. Une fois connecté à la base de données, créez une table à utiliser pour stocker les documents utilisés par le processus RAG à l'aide du code SQL suivant.

    CREATE TABLE hybridsearch 
    (id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY,
    text CLOB, 
    embeddings VECTOR(768, FLOAT32),
    metadata JSON);
    

    La taille de la colonne vectorielle dépend du modèle d'intégration qui va être utilisé pour générer les vecteurs pour la recherche sémantique. Ici, nous utilisons 768, qui correspond au modèle vectoriel utilisé plus loin dans cet exemple, bien que si un modèle alternatif est utilisé, cette valeur peut avoir besoin d'être mise à jour pour refléter cette modification. Une colonne JSON est indiquée pour le stockage des métadonnées de document, car elle peut fournir des structures flexibles tout en autorisant le filtrage sur les attributs du document. Bien qu'elle ne soit pas utilisée dans ce tutoriel, elle est incluse car tout scénario réel nécessitera des métadonnées de document.

  2. Pour activer la recherche par mot-clé du texte, un index de texte doit être créé sur la colonne de texte.

    CREATE SEARCH INDEX rag_text_index ON hybridsearch (text);
    

Tâche 2 : installer des bibliothèques

Ce tutoriel illustre l'utilisation d'une exécution Python pour implémenter l'assimilation de documents et la recherche hybride. Il est recommandé de configurer l'exécution Python à l'aide d'un environnement venv ou conda.

Remarque : ce tutoriel tente d'introduire des sections de code comme requis pour présenter chaque concept et nécessitera une refactorisation s'il devait être intégré à une solution plus large.

  1. Installez les dépendances requises pour ce tutoriel à l'aide de pip.

    $ pip install -U oracledb sentence-transformers git+https://github.com/LIAAD/yake
    

Tâche 3 : ingérer des documents dans la base de données

Une fois la table créée, les documents peuvent être insérés sous forme de lignes dans la table. En général, le processus d'ingestion doit être distinct du processus de requête et utiliser différents comptes de base de données avec des droits d'accès différents (car le processus de requête ne doit pas pouvoir modifier la table). Toutefois, dans le cadre de ce tutoriel, ils ne sont pas différenciés ici.

  1. Dans votre environnement Python, établissez votre connexion à la base de données. Par exemple, pour Autonomous Transaction Processing.

    import os
    import oracledb
    import traceback
    import json 
    import re
       
    try:
        print(f'Attempting to connect to the database with user: [{os.environ["DB_USER"]}] and dsn: [{os.environ["DB_DSN"]}]')
        connection = oracledb.connect(user=os.environ["DB_USER"], password=os.environ["DB_PASSWORD"], dsn=os.environ["DB_DSN"],
                                    config_dir="/path/to/dbwallet",
                                    wallet_location="/path/to/dbwallet",
                                    wallet_password=os.environ["DB_WALLET_PASSWORD"])
        print("Connection successful!")
    except Exception as e:
        print(traceback.format_exc())
        print("Connection failed!")
    

    La documentation python-oracledb fournit des détails sur la connexion à des instances non ADB qui ne peuvent pas utiliser de portefeuilles de connexion.

  2. Initialisez le modèle d'intégration qui sera utilisé pour calculer les vecteurs d'intégration. Ici, le modèle all-mpnet-base-v2 est utilisé, qui est disponible sous la licence Apache. Bien que ce modèle d'intégration spécifique ne soit utilisé qu'à titre d'illustration, d'autres modèles peuvent fonctionner mieux ou pire en fonction de vos données. Cet exemple utilise l'interface SentenceTransformers pour plus de simplicité. Pour plus d'informations, reportez-vous à SentenceTransformers Documentation.

    from sentence_transformers import SentenceTransformer
       
    model = SentenceTransformer('sentence-transformers/all-mpnet-base-v2')
    
  3. Implémentez une fonction d'ingestion de documents simple. Le processus d'obtention, d'analyse et de découpage des documents est hors de portée pour ce tutoriel, et dans le but de ce tutoriel, il est supposé qu'ils seront simplement fournis sous forme de chaînes. Cette fonction calcule les incorporations à l'aide du modèle fourni, puis insère le document et les incorporations dans la table créée.

    def add_document_to_table(connection, table_name, model, document, **kwargs):
        """
        Adds a document to the database for use in RAG.
        @param connection An established database connection.
        @param table_name The name of the table to add the document to
        @param model A sentence transformers model, with an 'encode' function that returns embeddings
        @param document The document to add, as a string
        Keyword Arguments:
        metadata: A dict with metadata about the document which is stored as a JSON object
        """
        #Calculate the embeddings for the document
        embeddings = model.encode(document)
        insert_sql = f"""INSERT INTO {table_name} (text, embeddings, metadata) VALUES (:text, :embeddings, :metadata)"""
        metadata = kwargs.get('metadata', {})
        cursor = connection.cursor()
        try:
            cursor.execute(insert_sql, text=document, embeddings=json.dumps(embeddings.tolist()), metadata=json.dumps(metadata))
        except Exception as e:
            print(traceback.format_exc())
            print("Insert failed!")
    
  4. Ajoutez des exemples de documents à la base de données à des fins de test.

    Remarque : un élément commit() explicite est appelé, ce qui déclenche la mise à jour de l'index de texte.

    table_name = "testhybrid"
       
    # These samples are just included to provide some data to search, not to 
    # demonstrate the efficacy of key phrase versus semantic search. The benefits
    # of hybrid search typically start to emerge when using a much larger volume
    # of content.
    document_samples = [
        "Oracle Database 23ai is the next long-term support release of Oracle Database. It includes over 300 new features with a focus on artificial intelligence (AI) and developer productivity.",
        "Features such as AI Vector Search enable you to leverage a new generation of AI models to generate and store vectors of documents, images, sound, and so on;  index them and quickly look for similarity while leveraging the existing analytical capabilities of Oracle Database.",
        "New developer-focused features now make it simpler to build next-generation applications that use JSON or relational development approaches or both interchangeably.",
        "With a built-in VECTOR data type, you can run AI-powered vector similarity searches within the database instead of having to move business data to a separate vector database.",
        "Property graphs provide an intuitive way to find direct or indirect dependencies in data elements and extract insights from these relationships. The enterprise-grade manageability, security features, and performance features of Oracle Database are extended to property graphs.",
        "The ISO SQL standard has been extended to include comprehensive support for property graph queries and creating property graphs in SQL.",
        "Transactional Event Queues (TxEventQ) are queues built into the Oracle Database. TxEventQ are a high performance partitioned implementation with multiple event streams per queue.",
        "Transactional Event Queues (TxEventQ) now support the KafkaProducer and KafkaConsumer classes from Apache Kafka. Oracle Database can now be used as a source or target for applications using the Kafka APIs.",
        "Database metrics are stored in Prometheus, a time-series database and metrics tailored for developers are displayed using Grafana dashboards. A database metrics exporter aids the metrics exports from database views into Prometheus time series database."
        "The Java Database Connectivity (JDBC) API is the industry standard for database-independent connectivity between the Java programming language and a wide range of databases—SQL databases and other tabular data sources, such as spreadsheets or flat files.",
        "Java Database Connectivity (JDBC) is a Java standard that provides the interface for connecting from Java to relational databases. The JDBC standard is defined and implemented through the standard java.sql interfaces. This enables individual providers to implement and extend the standard with their own JDBC drivers.",
        "The JDBC Thin driver enables a direct connection to the database by providing an implementation of Oracle Net Services on top of Java sockets. The driver supports the TCP/IP protocol and requires a TNS listener on the TCP/IP sockets on the database server.",
        "The JDBC Thin driver is a pure Java, Type IV driver that can be used in applications. It is platform-independent and does not require any additional Oracle software on the client-side. The JDBC Thin driver communicates with the server using Oracle Net Services to access Oracle Database.",
        "The JDBC OCI driver, written in a combination of Java and C, converts JDBC invocations to calls to OCI, using native methods to call C-entry points. These calls communicate with the database using Oracle Net Services.",
        "The python-oracledb driver is a Python extension module that enables access to Oracle Database. By default, python-oracledb allows connecting directly to Oracle Database 12.1 or later. This Thin mode does not need Oracle Client libraries.",
        "Users interact with a Python application, for example by making web requests. The application program makes calls to python-oracledb functions. The connection from python-oracledb Thin mode to the Oracle Database is established directly.",
        "Python-oracledb is said to be in ‘Thick’ mode when it links with Oracle Client libraries. Depending on the version of the Oracle Client libraries, this mode of python-oracledb can connect to Oracle Database 9.2 or later.",
        "To use python-oracledb Thick mode, the Oracle Client libraries must be installed separately. The libraries can be from an installation of Oracle Instant Client, from a full Oracle Client installation (such as installed by Oracle’s GUI installer), or even from an Oracle Database installation (if Python is running on the same machine as the database).",
        "Oracle’s standard client-server version interoperability allows connection to both older and newer databases from different Oracle Client library versions."
    ]
       
    for document in document_samples:
        add_document_to_table(connection, table_name, model, document)
       
    #Call an explicit commit after adding the documents, which will trigger an async update of the text index
    connection.commit()
    

Une fois les documents chargés, les fonctionnalités de recherche de vecteur AI d'Oracle Database 23ai peuvent être utilisées pour effectuer une recherche sémantique basée sur un vecteur que nous dérivons d'une requête.

  1. Implémentez une fonction d'aide pour utiliser les objets CLOB renvoyés par la base de données.

    def get_clob(result):
        """
        Utility function for getting the value of a LOB result from the DB.
        @param result Raw value from the database
        @returns string
        """
        clob_value = ""
        if result:
            if isinstance(result, oracledb.LOB):
                raw_data = result.read()
                if isinstance(raw_data, bytes):
                    clob_value = raw_data.decode("utf-8")
                else:
                    clob_value = raw_data
            elif isinstance(result, str):
                clob_value = result
            else:
                raise Exception("Unexpected type:", type(result))
        return clob_value
    
  2. Implémentez une fonction pour effectuer la recherche sémantique à l'aide de la fonction SQL vector_distance(). Le modèle all-mpnet-base-v2 utilisé dans ce tutoriel utilise la similarité COSINE, qui a été définie par défaut ici. Si vous utilisez un autre modèle, vous devrez peut-être spécifier une autre stratégie de distance.

    def retrieve_documents_by_vector_similarity(connection, table_name, model, query, num_results, **kwargs):
        """
        Retrieves the most similar documents from the database based upon semantic similarity.
        @param connection An established database connection.
        @param table_name The name of the table to query
        @param model A sentence transformers model, with an 'encode' function that returns embeddings
        @param query The string to search for semantic similarity with
        @param num_results The number of results to return
        Keyword Arguments:
        distance_strategy: The distance strategy to use for comparison One of: 'EUCLIDEAN', 'DOT', 'COSINE' - Default: COSINE
        @returns: Array<(string, string, dict)> Array of documents as a tuple of 'id', 'text', 'metadata'
        """
        # In many cases, building up the search SQL may involve adding a WHERE 
        # clause in order to search only a subset of documents, though this is
        # omitted for this simple example.
        search_sql = f"""SELECT id, text, metadata,
                        vector_distance(embeddings, :embedding, {kwargs.get('distance_strategy', 'COSINE')}) as distance
                        FROM {table_name}
                        ORDER BY distance
                        FETCH APPROX FIRST {num_results} ROWS ONLY
                        """
        query_embedding = model.encode(query)
        cursor = connection.cursor()
        try:
            cursor.execute(search_sql, embedding=json.dumps(query_embedding.tolist()))
        except Exception as e:
            print(traceback.format_exc())
            print("Retrieval failed!")
        rows = cursor.fetchall()
        documents = []
        for row in rows:
            documents.append((row[0].hex(), get_clob(row[1]), row[2]))
       
        return documents
    
  3. Validez la fonctionnalité de recherche sémantique à l'aide de l'exemple suivant.

    query = "I am writing a python application and want to use Apache Kafka for interacting with queues, is this supported by the Oracle database?"
       
    documents_from_vector_search = retrieve_documents_by_vector_similarity(connection, table_name, model, query, 4)
    print(documents_from_vector_search)
    

Ce tutoriel utilise Oracle Text, qui fournit de puissants outils de requête de texte dans Oracle Database. Bien qu'Oracle Text offre un large éventail de fonctionnalités à des fins de recherche hybride, il suffit d'une recherche simple par mots-clés ou expressions-clés. Il existe un certain nombre de techniques pour l'extraction et la recherche de mots-clés, mais cette implémentation est conçue pour être aussi simple que possible, à l'aide de YAKE (Yet Another Keyword Extractor), qui s'appuie sur les fonctionnalités de langue pour effectuer une extraction non supervisée des mots-clés et des expressions clés.

Il existe un large éventail d'autres approches de recherche par mot clé, l'algorithme Okapi BM25 étant populaire. Cependant, l'utilisation d'une extraction par mot-clé non supervisée avec un index de recherche de texte puissant tel que celui fourni par Oracle Text présente l'avantage d'être particulièrement simple, et la robustesse est fournie via la combinaison avec la recherche sémantique.

  1. Implémenter une fonction pour l'extraction d'expressions clés.

    import yake
       
    def extract_keywords(query, num_results):
        """
        Utility function for extracting keywords from a string.
        @param query The string from which keywords should be extracted
        @param num_results The number of keywords/phrases to return
        @returns Array<(string, number)> Array of keywords/phrases as a tuple of 'keyword', 'score' (lower scores are more significant)
        """
        language = "en"
        #Max number of words to include in a key phrase
        max_ngram_size = 2
        windowSize = 1
       
        kw_extractor = yake.KeywordExtractor(lan=language, n=max_ngram_size, windowsSize=windowSize, top=num_results, features=None)
        keywords = kw_extractor.extract_keywords(query.strip().lower())
        return sorted(keywords, key=lambda kw: kw[1])
    

    Comme cette méthode d'extraction de mots-clés repose sur des fonctionnalités linguistiques, il est important de définir la langue appropriée et les performances peuvent varier en fonction de la langue elle-même.

  2. Validez l'extraction par mot-clé à l'aide de l'exemple suivant.

    query = "I am writing a python application and want to use Apache Kafka for interacting with queues, is this supported by the Oracle database?"
       
    keywords = extract_keywords(query, 4)
    print(keywords)
    
  3. Implémenter une fonction pour effectuer une recherche par mot clé.

    def retrieve_documents_by_keywords(connection, table_name, query, num_results):
        """
        Retrieves the documents from the database which have the highest density of matching keywords as the query
        @param connection An established database connection.
        @param table_name The name of the table to query
        @param query The string from which to extract keywords/phrases for searching
        @param num_results The number of results to return
        @returns: Array<(string, string, dict)> Array of documents as a tuple of 'id', 'text', 'metadata'
        """
        num_keywords = 4
        keywords = extract_keywords(query, num_keywords)
        search_sql = f"""SELECT id, text, metadata, SCORE(1)
                        FROM {table_name}
                        WHERE CONTAINS (text, :query_keywords, 1) > 0
                        ORDER BY SCORE(1) DESC
                        FETCH APPROX FIRST {num_results} ROWS ONLY
                        """
        #Assemble the keyword search query, adding the stemming operator to each word
        stemmed_keywords = []
        splitter = re.compile('[^a-zA-Z0-9_\\+\\-/]')
        for keyword in keywords:
            stemmed_keyword = ""
            for single_word in splitter.split(keyword[0]):
                stemmed_keyword += "$" + single_word +" "
            stemmed_keywords.append(stemmed_keyword.strip())
        cursor = connection.cursor()
        try:
            cursor.execute(search_sql, query_keywords=",".join(stemmed_keywords))
        except Exception as e:
            print(traceback.format_exc())
            print("Retrieval failed!")
        rows = cursor.fetchall()
        documents = []
        for row in rows:
            documents.append((row[0].hex(), get_clob(row[1]), row[2]))
        return documents
    

    L'un des comportements les plus simples d'Oracle Text est d'effectuer des recherches par mot-clé via la fonction CONTAINS, qui prend en charge un large éventail d'opérateurs supplémentaires pour affiner ou élargir la recherche. Dans ce tutoriel, l'opérateur stemming est utilisé. Développe une requête pour inclure tous les termes ayant le même mot racine ou racine que le terme spécifié. Ceci est utilisé pour normaliser les mots indépendamment de la pluralité et du temps, afin de permettre à cat de correspondre à cats par exemple. Pour plus d'informations, voir Oracle Text CONTAINS - Opérateurs de requête.

    Remarque : si vous l'appliquez à un grand corpus de documents, il est conseillé de configurer l'index de texte pour inclure les tiges de mots afin d'améliorer les performances. Pour plus d'informations sur Basic Lexer, reportez-vous à BASIC_LEXER.

  4. Validez la recherche par mot-clé à l'aide de l'exemple suivant.

    query = "I am writing a python application and want to use Apache Kafka for interacting with queues, is this supported by the Oracle database?"
       
    documents_from_keyphrase_search = retrieve_documents_by_keywords(connection, table_name, query, 4)
    print(documents_from_keyphrase_search)
    

Fusionner et utiliser les résultats

Une fois les documents obtenus, ils peuvent être fournis à un LLM en tant que contexte supplémentaire qu'il peut utiliser pour répondre à des requêtes ou des instructions. Dans certains scénarios, il peut être approprié d'inclure simplement tous les documents récupérés dans l'invite du LLM. Dans d'autres cas, l'absence de documents pertinents est un élément important du contexte en soi, et il peut donc être important de déterminer leur pertinence. Cela peut être basé sur une pondération particulière qui est placée sur chaque type de recherche, ou la pertinence de chaque document peut être évaluée indépendamment de la façon dont il a été renvoyé à l'aide d'un modèle de reclassement.

Dans chacun de ces cas d'utilisation, la déduplication des résultats sera une étape importante. Chaque fonction a conservé l'ID de document qui fournit un identifiant unique qui peut être utilisé à cette fin. Exemple :

def deduplicate_documents(*args):
    """
    Combines lists of documents returning a union of the lists, with duplicates removed (based upon an 'id' match)
    Arguments:
        Any number of arrays of documents as a tuple of 'id', 'text', 'metadata'
    @returns: Array<(string, string, dict)> Single array of documents containing no duplicates
    """
    #Definitely not the most efficient de-duplication, but in this case, lists are typically <10 items
    documents = []
    for document_list in args:
        for document in document_list:
            if document[0] not in map(lambda doc: doc[0], documents):
                documents.append(document)
    return documents

Ces deux méthodes permettent d'extraire les documents pertinents de la même table de base de données, en utilisant deux mécanismes différents pour déterminer la similarité. L'exécution des deux méthodes est très rapide. Par conséquent, l'application des deux techniques pour extraire les documents pertinents entraîne une surcharge minimale au-delà de l'index de texte. La recherche sémantique permet de récupérer des documents indépendamment de l'utilisation de synonymes ou d'une faute de frappe occasionnelle, tandis que la recherche d'expression clé peut capturer des scénarios dans lesquels l'utilisateur pose des questions très spécifiques sur un sujet particulier, tel qu'un nom de produit ou de fonction. Les résultats combinés peuvent se compléter pour ajouter de la robustesse au processus RAG global.

Remerciements

Ressources de formation supplémentaires

Explorez d'autres ateliers sur docs.oracle.com/learn ou accédez à d'autres contenus de formation gratuits sur le canal Oracle Learning YouTube. De plus, visitez le site education.oracle.com/learning-explorer pour devenir un explorateur Oracle Learning.

Pour obtenir la documentation produit, consultez le site Oracle Help Center.