Open In Colab GitHub Repository

Utilização da pesquisa de texto integral com LangChain e Milvus

A pesquisa de texto integral é um método tradicional de recuperação de documentos através da correspondência de palavras-chave ou frases específicas no texto. Classifica os resultados com base em pontuações de relevância calculadas a partir de factores como a frequência de termos. Enquanto a pesquisa semântica é melhor na compreensão do significado e do contexto, a pesquisa de texto integral é excelente na correspondência exacta de palavras-chave, o que a torna um complemento útil da pesquisa semântica. O algoritmo BM25 é amplamente utilizado para a classificação na pesquisa de texto integral e desempenha um papel fundamental na Retrieval-Augmented Generation (RAG).

O Milvus 2.5 introduz capacidades nativas de pesquisa de texto integral utilizando o BM25. Esta abordagem converte o texto em vectores esparsos que representam as pontuações BM25. Basta introduzir o texto em bruto e o Milvus gera e armazena automaticamente os vectores esparsos, sem necessidade de geração manual de incorporação esparsa.

A integração da LangChain com o Milvus também introduziu esta funcionalidade, simplificando o processo de incorporação da pesquisa de texto integral nas aplicações RAG. Combinando a pesquisa de texto integral com a pesquisa semântica com vectores densos, é possível obter uma abordagem híbrida que aproveita o contexto semântico das incorporações densas e a relevância precisa das palavras-chave da correspondência de palavras. Esta integração melhora a precisão, a relevância e a experiência do utilizador dos sistemas de pesquisa.

Este tutorial mostrará como utilizar o LangChain e o Milvus para implementar a pesquisa de texto integral na sua aplicação.

  • A pesquisa de texto completo está atualmente disponível no Milvus Standalone, Milvus Distributed e Zilliz Cloud, embora ainda não seja suportada no Milvus Lite (que tem esta funcionalidade planeada para implementação futura). Entre em contacto com support@zilliz.com para obter mais informações.
  • Antes de prosseguir com este tutorial, certifique-se de que tem uma compreensão básica da pesquisa de texto completo e da utilização básica da integração LangChain Milvus.

Pré-requisitos

Antes de executar este notebook, certifique-se de ter as seguintes dependências instaladas:

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

Se estiver a utilizar o Google Colab, para ativar as dependências acabadas de instalar, poderá ser necessário reiniciar o tempo de execução (clique no menu "Tempo de execução" na parte superior do ecrã e selecione "Reiniciar sessão" no menu pendente).

Vamos utilizar os modelos do OpenAI. Deve preparar as variáveis de ambiente OPENAI_API_KEY do OpenAI.

import os

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

Especifique o seu servidor Milvus URI (e, opcionalmente, o TOKEN). Para saber como instalar e iniciar o servidor Milvus, siga este guia.

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

Preparar alguns documentos de exemplo:

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"}),
]

Inicialização com a função BM25

Para a pesquisa de texto completo, o Milvus VectorStore aceita um parâmetro builtin_function. Através deste parâmetro, pode passar uma instância do BM25BuiltInFunction. Isto é diferente da pesquisa semântica, que normalmente passa embeddings densos para o VectorStore,

Eis um exemplo simples de pesquisa híbrida no Milvus com a incorporação densa do OpenAI para a pesquisa semântica e o BM25 para a pesquisa de texto integral:

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,
)

No código acima, definimos uma instância de BM25BuiltInFunction e passamos essa instância para o objeto Milvus. BM25BuiltInFunction é uma classe de invólucro leve para Function em Milvus.

Pode especificar os campos de entrada e de saída para esta função nos parâmetros do BM25BuiltInFunction:

  • input_field_names (str): O nome do campo de entrada, por defeito é text. Indica qual o campo que esta função lê como entrada.
  • output_field_names (str): O nome do campo de saída, por defeito sparse. Indica o campo para o qual esta função envia o resultado calculado.

Note-se que nos parâmetros de inicialização do Milvus acima mencionados, também especificamos vector_field=["dense", "sparse"]. Uma vez que o campo sparse é tomado como o campo de saída definido por BM25BuiltInFunction, o outro campo dense será automaticamente atribuído ao campo de saída de OpenAIEmbeddings.

Na prática, especialmente quando se combinam vários embeddings ou funções, recomendamos que se especifiquem explicitamente os campos de entrada e saída de cada função para evitar ambiguidades.

No exemplo seguinte, especificamos explicitamente os campos de entrada e saída de BM25BuiltInFunction, tornando claro para que campo se destina a função incorporada.

# 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']

Neste exemplo, temos três campos vectoriais. Entre eles, sparse é utilizado como campo de saída para BM25BuiltInFunction, enquanto os outros dois, dense1 e dense2, são automaticamente atribuídos como campos de saída para os dois modelos OpenAIEmbeddings (com base na ordem).

Desta forma, é possível definir vários campos vectoriais e atribuir-lhes diferentes combinações de embeddings ou funções, para implementar a pesquisa híbrida.

Ao efetuar a pesquisa híbrida, basta passar o texto da consulta e, opcionalmente, definir os parâmetros topK e reranker. A instância vectorstore tratará automaticamente os embeddings vectoriais e as funções incorporadas e, finalmente, utilizará um reranker para refinar os resultados. Os detalhes de implementação subjacentes ao processo de pesquisa estão ocultos ao utilizador.

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')]

Para mais informações sobre a pesquisa híbrida, pode consultar a introdução à pesquisa híbrida e este tutorial de pesquisa híbrida LangChain Milvus.

Pesquisa BM25 sem incorporação

Se pretender efetuar apenas a pesquisa de texto integral com a função BM25 sem utilizar qualquer pesquisa semântica baseada na incorporação, pode definir o parâmetro de incorporação para None e manter apenas o builtin_function especificado como a instância da função BM25. O campo vetorial tem apenas um campo "esparso". Por exemplo:

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']

Personalizar analisador

Os analisadores são essenciais na pesquisa de texto completo, dividindo a frase em tokens e efectuando a análise lexical, como a remoção de stemming e stop word. Os analisadores são normalmente específicos do idioma. Pode consultar este guia para saber mais sobre os analisadores no Milvus.

O Milvus suporta dois tipos de analisadores: Analisadores incorporados e Analisadores personalizados. Por predefinição, o BM25BuiltInFunction utilizará o analisador incorporado padrão, que é o analisador mais básico que simboliza o texto com pontuação.

Se você quiser usar um analisador diferente ou personalizar o analisador, pode passar o parâmetro analyzer_params na inicialização 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,
)

Podemos dar uma vista de olhos ao esquema da coleção Milvus e certificarmo-nos de que o analisador personalizado está configurado corretamente.

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': {}}]}

Para mais pormenores sobre o conceito, por exemplo, analyzer, tokenizer, filter, enable_match, analyzer_params, consulte a documentação do analisador.

Analisador multilingue

O Milvus suporta analisadores multilingues para documentos em várias línguas. Utilize multi_analyzer_params em 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},
)

Para mais pormenores, consulte a documentação do analisador multilingue.

Utilizar a pesquisa híbrida e a reclassificação no RAG

Aprendemos a utilizar a função básica incorporada BM25 em LangChain e Milvus. Vamos apresentar uma implementação optimizada do RAG com pesquisa híbrida e reanálise.

Este diagrama mostra o processo Hybrid Retrieve & Reranking, combinando BM25 para correspondência de palavras-chave e pesquisa vetorial para recuperação semântica. Os resultados de ambos os métodos são fundidos, reavaliados e passados para um LLM para gerar a resposta final.

A pesquisa híbrida equilibra a precisão e a compreensão semântica, melhorando a exatidão e a robustez para diversas consultas. Recupera candidatos com a pesquisa de texto completo BM25 e a pesquisa vetorial, assegurando uma recuperação semântica, com conhecimento do contexto e exacta.

Vamos começar com um exemplo.

Preparar os dados

Utilizamos o Langchain WebBaseLoader para carregar documentos a partir de fontes Web e dividi-los em partes utilizando o 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#')

Carregar o documento no armazenamento vetorial Milvus

Tal como na introdução acima, inicializamos e carregamos os documentos preparados para o armazenamento vetorial Milvus, que contém dois campos vectoriais: dense é para a incorporação OpenAI e sparse é para a função 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,
)

Construir a cadeia RAG

Preparamos a instância LLM e o prompt e, em seguida, combinamo-los num pipeline RAG utilizando a linguagem de expressão LangChain.

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)

Utilize a LCEL (Linguagem de Expressão LangChain) para construir uma cadeia 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()

Invocar a cadeia RAG com uma pergunta específica e obter a resposta

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.'

Parabéns! Construiu uma cadeia RAG de pesquisa híbrida (vetor denso + função bm25 esparsa) alimentada por Milvus e LangChain.