Open In Colab GitHub Repository

Utilisation de la recherche plein texte avec LangChain et Milvus

Larecherche plein texte est une méthode traditionnelle qui permet de récupérer des documents en faisant correspondre des mots-clés ou des phrases spécifiques dans le texte. Elle classe les résultats sur la base de scores de pertinence calculés à partir de facteurs tels que la fréquence des termes. Alors que la recherche sémantique permet de mieux comprendre le sens et le contexte, la recherche en texte intégral excelle dans la correspondance précise des mots-clés, ce qui en fait un complément utile à la recherche sémantique. L'algorithme BM25 est largement utilisé pour le classement dans la recherche plein texte et joue un rôle clé dans la génération améliorée par la recherche (RAG).

Milvus 2.5 introduit des capacités natives de recherche en texte intégral à l'aide de l'algorithme BM25. Cette approche convertit le texte en vecteurs épars qui représentent les scores BM25. Il suffit de saisir du texte brut pour que Milvus génère et stocke automatiquement les vecteurs épars, sans qu'il soit nécessaire de procéder à une génération manuelle de l'intégration éparse.

L'intégration de LangChain à Milvus a également introduit cette fonctionnalité, simplifiant le processus d'incorporation de la recherche plein texte dans les applications RAG. En combinant la recherche en texte intégral avec la recherche sémantique à l'aide de vecteurs denses, vous pouvez obtenir une approche hybride qui exploite à la fois le contexte sémantique des encastrements denses et la pertinence précise des mots clés grâce à la correspondance des mots. Cette intégration améliore la précision, la pertinence et l'expérience utilisateur des systèmes de recherche.

Ce tutoriel montre comment utiliser LangChain et Milvus pour mettre en œuvre la recherche en texte intégral dans votre application.

  • La recherche plein texte est actuellement disponible dans Milvus Standalone, Milvus Distributed et Zilliz Cloud, bien qu'elle ne soit pas encore prise en charge dans Milvus Lite (qui prévoit cette fonctionnalité pour une mise en œuvre future). Contactez support@zilliz.com pour plus d'informations.
  • Avant de poursuivre ce tutoriel, assurez-vous d'avoir une compréhension de base de la recherche en texte intégral et de l'utilisation de base de l'intégration LangChain Milvus.

Conditions préalables

Avant d'exécuter ce bloc-notes, assurez-vous que les dépendances suivantes sont installées :

! pip install --upgrade --quiet  langchain langchain-core langchain-community langchain-text-splitters langchain-milvus langchain-openai bs4 #langchain-voyageai

Si vous utilisez Google Colab, pour activer les dépendances qui viennent d'être installées, vous devrez peut-être redémarrer le runtime (cliquez sur le menu "Runtime" en haut de l'écran, et sélectionnez "Restart session" dans le menu déroulant).

Nous utiliserons les modèles d'OpenAI. Vous devez préparer les variables d'environnement OPENAI_API_KEY à partir d'OpenAI.

import os

os.environ["OPENAI_API_KEY"] = "sk-***********"

Spécifiez votre serveur Milvus URI (et éventuellement TOKEN). Pour savoir comment installer et démarrer le serveur Milvus, suivez ce guide.

URI = "http://localhost:19530"
# TOKEN = ...

Préparez quelques documents d'exemples :

from langchain_core.documents import Document

docs = [
    Document(page_content="I like this apple", metadata={"category": "fruit"}),
    Document(page_content="I like swimming", metadata={"category": "sport"}),
    Document(page_content="I like dogs", metadata={"category": "pets"}),
]

Initialisation avec la fonction BM25

Pour la recherche en texte intégral, Milvus VectorStore accepte un paramètre builtin_function. Ce paramètre permet de transmettre une instance de BM25BuiltInFunction, ce qui est différent de la recherche sémantique qui transmet généralement des enchâssements denses à VectorStore,

Voici un exemple simple de recherche hybride dans Milvus avec OpenAI dense embedding pour la recherche sémantique et BM25 pour la recherche en texte intégral :

from langchain_milvus import Milvus, BM25BuiltInFunction
from langchain_openai import OpenAIEmbeddings


vectorstore = Milvus.from_documents(
    documents=docs,
    embedding=OpenAIEmbeddings(),
    builtin_function=BM25BuiltInFunction(),
    # `dense` is for OpenAI embeddings, `sparse` is the output field of BM25 function
    vector_field=["dense", "sparse"],
    connection_args={
        "uri": URI,
    },
    # Strong consistency waits for all loads to complete, adding latency with large datasets
    # consistency_level="Strong",
    # drop_old=True,
)

Dans le code ci-dessus, nous définissons une instance de BM25BuiltInFunction et la transmettons à l'objet Milvus. BM25BuiltInFunction est une classe enveloppante légère pour la recherche sémantique dans Milvus. Function dans Milvus.

Vous pouvez spécifier les champs d'entrée et de sortie de cette fonction dans les paramètres de l'objet BM25BuiltInFunction:

  • input_field_names (str) : Le nom du champ d'entrée, par défaut text. Il indique le champ que cette fonction lit en entrée.
  • output_field_names (str) : Le nom du champ de sortie, par défaut sparse. Il indique le champ dans lequel cette fonction émet le résultat calculé.

Notez que dans les paramètres d'initialisation de Milvus mentionnés ci-dessus, nous spécifions également vector_field=["dense", "sparse"]. Étant donné que le champ sparse est considéré comme le champ de sortie défini par BM25BuiltInFunction, l'autre champ dense sera automatiquement affecté au champ de sortie d'OpenAIEmbeddings.

Dans la pratique, en particulier lors de la combinaison de plusieurs embeddings ou fonctions, nous recommandons de spécifier explicitement les champs d'entrée et de sortie pour chaque fonction afin d'éviter toute ambiguïté.

Dans l'exemple suivant, nous spécifions explicitement les champs d'entrée et de sortie de BM25BuiltInFunction, ce qui permet de savoir clairement à quel champ la fonction intégrée est destinée.

# from langchain_voyageai import VoyageAIEmbeddings

embedding1 = OpenAIEmbeddings(model="text-embedding-ada-002")
embedding2 = OpenAIEmbeddings(model="text-embedding-3-large")
# embedding2 = VoyageAIEmbeddings(model="voyage-3")  # You can also use embedding from other embedding model providers, e.g VoyageAIEmbeddings


vectorstore = Milvus.from_documents(
    documents=docs,
    embedding=[embedding1, embedding2],
    builtin_function=BM25BuiltInFunction(
        input_field_names="text", output_field_names="sparse"
    ),
    text_field="text",  # `text` is the input field name of BM25BuiltInFunction
    # `sparse` is the output field name of BM25BuiltInFunction, and `dense1` and `dense2` are the output field names of embedding1 and embedding2
    vector_field=["dense1", "dense2", "sparse"],
    connection_args={
        "uri": URI,
    },
    # Strong consistency waits for all loads to complete, adding latency with large datasets
    # consistency_level="Strong",
    # drop_old=True,
)

vectorstore.vector_fields
['dense1', 'dense2', 'sparse']

Dans cet exemple, nous avons trois champs vectoriels. Parmi eux, sparse est utilisé comme champ de sortie pour BM25BuiltInFunction, tandis que les deux autres, dense1 et dense2, sont automatiquement affectés comme champs de sortie pour les deux modèles OpenAIEmbeddings (en fonction de l'ordre).

De cette manière, vous pouvez définir plusieurs champs de vecteurs et leur attribuer différentes combinaisons d'embeddings ou de fonctions, afin de mettre en œuvre la recherche hybride.

Lors de la recherche hybride, il suffit de transmettre le texte de la requête et de définir éventuellement les paramètres topK et reranker. L'instance vectorstore traitera automatiquement les intégrations vectorielles et les fonctions intégrées et utilisera finalement un reranker pour affiner les résultats. Les détails de l'implémentation sous-jacente du processus de recherche sont cachés à l'utilisateur.

vectorstore.similarity_search(
    "Do I like apples?", k=1
)  # , ranker_type="weighted", ranker_params={"weights":[0.3, 0.3, 0.4]})
[Document(metadata={'category': 'fruit', 'pk': 454646931479251897}, page_content='I like this apple')]

Pour plus d'informations sur la recherche hybride, vous pouvez consulter l'introduction à la recherche hybride et ce tutoriel sur la recherche hybride LangChain Milvus.

Recherche BM25 sans intégration

Si vous souhaitez effectuer uniquement une recherche en texte intégral avec la fonction BM25 sans utiliser de recherche sémantique basée sur l'intégration, vous pouvez régler le paramètre d'intégration sur None et ne conserver que le site builtin_function spécifié comme instance de la fonction BM25. Le champ vectoriel n'a qu'un champ "clairsemé". Par exemple, le champ vectoriel n'a qu'un champ "clairsemé" :

vectorstore = Milvus.from_documents(
    documents=docs,
    embedding=None,
    builtin_function=BM25BuiltInFunction(
        output_field_names="sparse",
    ),
    vector_field="sparse",
    connection_args={
        "uri": URI,
    },
    # Strong consistency waits for all loads to complete, adding latency with large datasets
    # consistency_level="Strong",
    # drop_old=True,
)

vectorstore.vector_fields
['sparse']

Personnaliser l'analyseur

Les analyseurs sont essentiels dans la recherche en texte intégral car ils décomposent la phrase en tokens et effectuent des analyses lexicales telles que le stemming et l'élimination des mots vides. Les analyseurs sont généralement spécifiques à une langue. Vous pouvez consulter ce guide pour en savoir plus sur les analyseurs dans Milvus.

Milvus prend en charge deux types d'analyseurs : Les analyseurs intégrés et les analyseurs personnalisés. Par défaut, le site BM25BuiltInFunction utilise l'analyseur intégré standard, qui est l'analyseur le plus basique et qui symbolise le texte avec la ponctuation.

Si vous souhaitez utiliser un analyseur différent ou personnaliser l'analyseur, vous pouvez passer le paramètre analyzer_params dans l'initialisation de BM25BuiltInFunction.

analyzer_params_custom = {
    "tokenizer": "standard",
    "filter": [
        "lowercase",  # Built-in filter
        {"type": "length", "max": 40},  # Custom filter
        {"type": "stop", "stop_words": ["of", "to"]},  # Custom filter
    ],
}


vectorstore = Milvus.from_documents(
    documents=docs,
    embedding=OpenAIEmbeddings(),
    builtin_function=BM25BuiltInFunction(
        output_field_names="sparse",
        enable_match=True,
        analyzer_params=analyzer_params_custom,
    ),
    vector_field=["dense", "sparse"],
    connection_args={
        "uri": URI,
    },
    # Strong consistency waits for all loads to complete, adding latency with large datasets
    # consistency_level="Strong",
    # drop_old=True,
)

Nous pouvons examiner le schéma de la collection Milvus et nous assurer que l'analyseur personnalisé est correctement configuré.

vectorstore.col.schema
{'auto_id': True, 'description': '', 'fields': [{'name': 'text', 'description': '', 'type': <DataType.VARCHAR: 21>, 'params': {'max_length': 65535, 'enable_match': True, 'enable_analyzer': True, 'analyzer_params': {'tokenizer': 'standard', 'filter': ['lowercase', {'type': 'length', 'max': 40}, {'type': 'stop', 'stop_words': ['of', 'to']}]}}}, {'name': 'pk', 'description': '', 'type': <DataType.INT64: 5>, 'is_primary': True, 'auto_id': True}, {'name': 'dense', 'description': '', 'type': <DataType.FLOAT_VECTOR: 101>, 'params': {'dim': 1536}}, {'name': 'sparse', 'description': '', 'type': <DataType.SPARSE_FLOAT_VECTOR: 104>, 'is_function_output': True}, {'name': 'category', 'description': '', 'type': <DataType.VARCHAR: 21>, 'params': {'max_length': 65535}}], 'enable_dynamic_field': False, 'functions': [{'name': 'bm25_function_de368e79', 'description': '', 'type': <FunctionType.BM25: 1>, 'input_field_names': ['text'], 'output_field_names': ['sparse'], 'params': {}}]}

Pour plus de détails sur les concepts, par exemple analyzer, tokenizer, filter, enable_match, analyzer_params, veuillez vous référer à la documentation de l'analyseur.

Analyseur multilingue

Milvus prend en charge les analyseurs multilingues pour les documents rédigés dans plusieurs langues. Utilisez multi_analyzer_params dans BM25BuiltInFunction:

from pymilvus import DataType

multi_analyzer_params = {
    "analyzers": {
        "english": {"type": "english"},
        "chinese": {"type": "chinese"},
        "default": {"tokenizer": "icu"},
    },
    "by_field": "language",
}

vectorstore = Milvus.from_documents(
    documents=docs,
    embedding=OpenAIEmbeddings(),
    builtin_function=BM25BuiltInFunction(
        output_field_names="sparse",
        multi_analyzer_params=multi_analyzer_params,
    ),
    vector_field=["dense", "sparse"],
    metadata_schema={"language": {"dtype": DataType.VARCHAR, "kwargs": {"max_length": 100}}},
    connection_args={"uri": URI},
)

Pour plus de détails, voir la documentation de l'analyseur multilingue.

Utilisation de la recherche hybride et du reclassement dans RAG

Nous avons appris à utiliser la fonction de base BM25 dans LangChain et Milvus. Nous allons maintenant présenter une implémentation optimisée de RAG avec la recherche et le reclassement hybrides.

Ce diagramme illustre le processus de recherche et de reclassement hybride, qui combine la fonction BM25 pour la recherche par mots-clés et la recherche vectorielle pour la recherche sémantique. Les résultats des deux méthodes sont fusionnés, reclassés et transmis à un LLM pour générer la réponse finale.

La recherche hybride équilibre la précision et la compréhension sémantique, améliorant la précision et la robustesse pour diverses requêtes. Elle récupère les candidats à l'aide de la recherche plein texte BM25 et de la recherche vectorielle, garantissant ainsi une récupération sémantique, contextuelle et précise.

Commençons par un exemple.

Préparer les données

Nous utilisons le Langchain WebBaseLoader pour charger des documents à partir de sources web et les découper en morceaux à l'aide du RecursiveCharacterTextSplitter.

import bs4
from langchain_community.document_loaders import WebBaseLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

# Create a WebBaseLoader instance to load documents from web sources
loader = WebBaseLoader(
    web_paths=(
        "https://lilianweng.github.io/posts/2023-06-23-agent/",
        "https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/",
    ),
    bs_kwargs=dict(
        parse_only=bs4.SoupStrainer(
            class_=("post-content", "post-title", "post-header")
        )
    ),
)
# Load documents from web sources using the loader
documents = loader.load()
# Initialize a RecursiveCharacterTextSplitter for splitting text into chunks
text_splitter = RecursiveCharacterTextSplitter(chunk_size=2000, chunk_overlap=200)

# Split the documents into chunks using the text_splitter
docs = text_splitter.split_documents(documents)

# Let's take a look at the first document
docs[1]
Document(metadata={'source': 'https://lilianweng.github.io/posts/2023-06-23-agent/'}, page_content='Fig. 1. Overview of a LLM-powered autonomous agent system.\nComponent One: Planning#\nA complicated task usually involves many steps. An agent needs to know what they are and plan ahead.\nTask Decomposition#\nChain of thought (CoT; Wei et al. 2022) has become a standard prompting technique for enhancing model performance on complex tasks. The model is instructed to “think step by step” to utilize more test-time computation to decompose hard tasks into smaller and simpler steps. CoT transforms big tasks into multiple manageable tasks and shed lights into an interpretation of the model’s thinking process.\nTree of Thoughts (Yao et al. 2023) extends CoT by exploring multiple reasoning possibilities at each step. It first decomposes the problem into multiple thought steps and generates multiple thoughts per step, creating a tree structure. The search process can be BFS (breadth-first search) or DFS (depth-first search) with each state evaluated by a classifier (via a prompt) or majority vote.\nTask decomposition can be done (1) by LLM with simple prompting like "Steps for XYZ.\\n1.", "What are the subgoals for achieving XYZ?", (2) by using task-specific instructions; e.g. "Write a story outline." for writing a novel, or (3) with human inputs.\nAnother quite distinct approach, LLM+P (Liu et al. 2023), involves relying on an external classical planner to do long-horizon planning. This approach utilizes the Planning Domain Definition Language (PDDL) as an intermediate interface to describe the planning problem. In this process, LLM (1) translates the problem into “Problem PDDL”, then (2) requests a classical planner to generate a PDDL plan based on an existing “Domain PDDL”, and finally (3) translates the PDDL plan back into natural language. Essentially, the planning step is outsourced to an external tool, assuming the availability of domain-specific PDDL and a suitable planner which is common in certain robotic setups but not in many other domains.\nSelf-Reflection#')

Charger le document dans le magasin vectoriel Milvus

Comme dans l'introduction ci-dessus, nous initialisons et chargeons les documents préparés dans le magasin de vecteurs Milvus, qui contient deux champs de vecteurs : dense est pour l'intégration OpenAI et sparse est pour la fonction BM25.

vectorstore = Milvus.from_documents(
    documents=docs,
    embedding=OpenAIEmbeddings(),
    builtin_function=BM25BuiltInFunction(),
    vector_field=["dense", "sparse"],
    connection_args={
        "uri": URI,
    },
    # Strong consistency waits for all loads to complete, adding latency with large datasets
    # consistency_level="Strong",
    # drop_old=True,
)

Construction de la chaîne RAG

Nous préparons l'instance LLM et l'invite, puis nous les combinons dans un pipeline RAG à l'aide du LangChain Expression Language.

from langchain_core.runnables import RunnablePassthrough
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI

# Initialize the OpenAI language model for response generation
llm = ChatOpenAI(model_name="gpt-4o", temperature=0)

# Define the prompt template for generating AI responses
PROMPT_TEMPLATE = """
Human: You are an AI assistant, and provides answers to questions by using fact based and statistical information when possible.
Use the following pieces of information to provide a concise answer to the question enclosed in <question> tags.
If you don't know the answer, just say that you don't know, don't try to make up an answer.
<context>
{context}
</context>

<question>
{question}
</question>

The response should be specific and use statistics or numbers when possible.

Assistant:"""

# Create a PromptTemplate instance with the defined template and input variables
prompt = PromptTemplate(
    template=PROMPT_TEMPLATE, input_variables=["context", "question"]
)
# Convert the vector store to a retriever
retriever = vectorstore.as_retriever()


# Define a function to format the retrieved documents
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

Utilisez le LCEL (LangChain Expression Language) pour construire une chaîne RAG.

# Define the RAG (Retrieval-Augmented Generation) chain for AI response generation
rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

# rag_chain.get_graph().print_ascii()

Invoquer la chaîne RAG avec une question spécifique et récupérer la réponse.

query = "What is PAL and PoT?"
res = rag_chain.invoke(query)
res
'PAL (Program-aided Language models) and PoT (Program of Thoughts prompting) are approaches that involve using language models to generate programming language statements to solve natural language reasoning problems. This method offloads the solution step to a runtime, such as a Python interpreter, allowing for complex computation and reasoning to be handled externally. PAL and PoT rely on language models with strong coding skills to effectively generate and execute these programming statements.'

Nous vous félicitons ! Vous avez construit une chaîne RAG hybride (vecteur dense + fonction bm25 clairsemée) alimentée par Milvus et LangChain.