Creación de un chatbot de código abierto usando LangChain y Milvus en menos de 5 minutos
En mi blog anterior, recorrimos cómo comenzar con una conexión a Milvus en unos minutos. Esta publicación usará un stack RAG (Retrieval Augmented Generation) completamente de código abierto con LangChain para responder preguntas sobre Milvus utilizando las páginas web de documentación de nuestro producto.
Usar Q&A de código abierto con recuperación ahorra dinero, ya que hacemos llamadas gratuitas a nuestros datos casi todo el tiempo: recuperación, evaluación e iteraciones de desarrollo. Solo hacemos una llamada de pago a OpenAI una vez para el paso final de generación de chat.
Para quienes estén interesados en profundizar en los aspectos técnicos, el código fuente de un ChatBot en vivo está disponible en nuestro GitHub. El código completo de este notebook está en nuestro bootcamp Git Hub.
RAG (Retrieval Augmented Generation) se usa para fundamentar texto de IA generativa (basando el texto generado en datos fácticos y personalizados para reducir las alucinaciones). El texto de tus datos personalizados, que crees que es verdadero, como la documentación del producto, se recupera de una base de datos vectorial para responder una pregunta. Luego, insertas las respuestas de texto precisas como “contexto” junto con la “pregunta” en el “prompt,” que alimentas a un LLM como ChatGPT de OpenAI. El LLM genera una respuesta de chat similar a la humana que está fundamentada.
Procesos RAG:
Comienza con tus datos personalizados, que crees que son verdaderos, y un modelo de embeddings para el codificador.
Divide en fragmentos y genera embeddings de tus datos usando el codificador. Guarda los datos y metadatos en una base de datos vectorial.
El usuario hace una pregunta. Genera embeddings de la pregunta usando el mismo codificador del paso 1.
Recupera respuestas a tu pregunta realizando una búsqueda semántica usando la base de datos vectorial.
Introduce los fragmentos de texto de respuesta de tus documentos personalizados en un “Contexto.” Introduce la pregunta y el contexto en un prompt. Envía el prompt a un LLM generador.
Recibe una respuesta fiable del LLM generador.
Paso 1: Ingerir los datos
Milvus es una base de datos vectorial de alto rendimiento que simplifica la ingesta de datos no estructurados personalizados y la creación de embeddings. Milvus está optimizado para el almacenamiento, indexación y búsqueda rápidos de embeddings (o vectores).
OpenAI es una organización que desarrolla y proporciona modelos y herramientas de IA. Es conocida por sus modelos de lenguaje de vanguardia, como la serie GPT (Generative Pre-trained Transformer).
LangChain es una biblioteca de herramientas y wrappers que ayuda a los desarrolladores a cerrar la brecha entre el software tradicional y los LLMs.
Los datos que usaremos son las páginas web de documentación de nuestro producto. ReadTheDocs es una plataforma de alojamiento de documentación de software de código abierto y gratuita donde la documentación se escribe con el generador de documentos Sphinx.
Empecemos.
# Download readthedocs pages locally.
DOCS_PAGE="https://pymilvus.readthedocs.io/en/latest/"
wget -r -A.html -P rtdocs --header="Accept-Charset: UTF-8" $DOCS_PAGE
El código anterior descarga las páginas web en un directorio local llamado rtdocs. A continuación, leeremos la documentación en LangChain.
#!pip install langchain
from langchain.document_loaders import ReadTheDocsLoader
loader = ReadTheDocsLoader(
"rtdocs/pymilvus.readthedocs.io/en/latest/",
features="html.parser")
docs = loader.load()
Paso 2: Dividir los datos en fragmentos usando jerarquías HTML
Antes de incrustar, es necesario decidir tu estrategia de fragmentación, el tamaño de los fragmentos y el solapamiento entre fragmentos. En esta demostración, usaré:
Estrategia = Usar jerarquías de encabezados markdown. Mantener juntas las secciones markdown a menos que sean demasiado largas.
Tamaño del fragmento = Usar el parámetro del modelo de embeddings
MAX_SEQ_LENGTHSolapamiento = Regla general del 10-15%
Funciones =
HTMLHeaderTextSplitter de Langchain para dividir secciones markdown.
RecursiveCharacterTextSplitter de Langchain para dividir reseñas largas de forma recursiva.
from langchain.text_splitter import HTMLHeaderTextSplitter, RecursiveCharacterTextSplitter
# Define the headers to split on for the HTMLHeaderTextSplitter
headers_to_split_on = [
("h1", "Header 1"),
("h2", "Header 2"),]
# Create an instance of the HTMLHeaderTextSplitter
html_splitter = HTMLHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
# Use the embedding model parameters.
chunk_size = MAX_SEQ_LENGTH - HF_EOS_TOKEN_LENGTH
chunk_overlap = np.round(chunk_size * 0.10, 0)
# Create an instance of the RecursiveCharacterTextSplitter
child_splitter = RecursiveCharacterTextSplitter(
chunk_size = chunk_size,
chunk_overlap = chunk_overlap,
length_function = len,)
# Split the HTML text using the HTMLHeaderTextSplitter.
html_header_splits = []
for doc in docs:
splits = html_splitter.split_text(doc.page_content)
for split in splits:
# Add the source URL and header values to the metadata
metadata = {}
new_text = split.page_content
for header_name, metadata_header_name in headers_to_split_on:
header_value = new_text.split("¶ ")[0].strip()
metadata[header_name] = header_value
try:
new_text = new_text.split("¶ ")[1].strip()
except:
break
split.metadata = {
**metadata,
"source": doc.metadata["source"]}
# Add the header to the text
split.page_content = split.page_content
html_header_splits.extend(splits)
# Split the documents further into smaller, recursive chunks.
chunks = child_splitter.split_documents(html_header_splits)
end_time = time.time()
print(f"chunking time: {end_time - start_time}")
print(f"docs: {len(docs)}, split into: {len(html_header_splits)}")
print(f"split into chunks: {len(chunks)}, type: list of {type(chunks[0])}")
# Inspect a chunk.
print()
print("Looking at a sample chunk...")
print(chunks[1].page_content[:100])
print(chunks[1].metadata)
Observa arriba que cada fragmento está fundamentado con la fuente del documento. Además, los títulos de los encabezados se mantienen junto con el fragmento de texto markdown. Estos encabezados pueden utilizarse más adelante para recuperar una sección completa de encabezado.
Paso 3: Generar embeddings
La mayoría de las demostraciones ahora usan las API de OpenAI Embeddings. Dado que estos son tus propios datos personalizados, ¿por qué no usar modelos de embeddings de código abierto y el nivel gratuito de Zilliz Cloud para buscar en tus propios datos tanto como quieras gratis?
Los modelos de embeddings/recuperación de código abierto son tan buenos como OpenAI Embeddings (ada-002), según los últimos resultados del benchmark MTEB. A continuación, podemos ver que el modelo más pequeño con mejor clasificación es bge-large-en-v1.5. Usaremos ese modelo en este blog.
Fuente de la imagen: https://huggingface.co/spaces/mteb/leaderboard, ordenado por columna, Retrieval Average (15 datasets), impreso el 24 de nov. de 2023.
La imagen anterior muestra la clasificación de modelos de embeddings, con el primer puesto voyage-lite-01-instruct (tamaño 4,2 GB, y el tercer puesto bge-base-en-v1.5 (tamaño 1,5 GB). OpenAIEmbedding text-embeddings-ada-002 ocupa el puesto 22 (no se muestra porque está mucho más abajo en la lista).
A continuación, inicializamos un codificador usando el checkpoint del modelo de embeddings elegido.
#pip install torch, sentence-transformers
import torch
from sentence_transformers import SentenceTransformer
# Inicializar la configuración de torch
DEVICE = torch.device('cuda:3'
if torch.cuda.is_available()
else 'cpu')
# Cargar el modelo codificador desde el hub de modelos de huggingface.
model_name = "BAAI/bge-base-en-v1.5"
encoder = SentenceTransformer(model_name, device=DEVICE)
# Obtener los parámetros del modelo y guardarlos para más tarde.
MAX_SEQ_LENGTH = encoder.get_max_seq_length()
EMBEDDING_LENGTH = encoder.get_sentence_embedding_dimension()
Ahora, genera embeddings usando el codificador que inicializamos desde un checkpoint de HuggingFace. Reúne todos los datos en una lista de diccionarios.
chunk_list = []
for chunk in chunks:
# Generar embeddings usando el codificador de HuggingFace.
embeddings = torch.tensor(encoder.encode([chunk.page_content]))
embeddings = F.normalize(embeddings, p=2, dim=1)
converted_values = list(map(np.float32, embeddings))[0]
# Reunir el vector de embedding, el fragmento de texto original y los metadatos.
chunk_dict = {
'vector': converted_values,
'text': chunk.page_content,
'source': chunk.metadata['source'],
'h1': chunk.metadata['h1'][:50],
'h2': chunk.metadata['h1'][:50],}
chunk_list.append(chunk_dict)
Paso 4: Crear el índice de Milvus e insertar datos
En este paso, escribiremos el cuádruple (vector, text, source, h1, h2) en la base de datos para cada fragmento de texto original.
Iniciemos nuestro servidor Milvus y conectémonos a él. Para usar Milvus serverless alojado en la nube, necesitarás una ZILLIZ_API_KEY. En mi blog anterior, Connecting to Milvus, mostré instrucciones para conectarse a Zilliz.
#pip install pymilvus
from pymilvus import connections
ENDPOINT=”https://xxxx.api.region.zillizcloud.com:443”
connections.connect(
uri=ENDPOINT,
token=TOKEN)
Crea una colección de Milvus (piensa en ella como una tabla de base de datos) llamada MilvusDocs. La colección toma un esquema y un índice. El esquema usa la longitud de embedding del modelo codificador.
from pymilvus import (
FieldSchema, DataType,
CollectionSchema, Collection)
# 1. Definir un esquema mínimo expandible.
fields = [
FieldSchema(“pk”, DataType.INT64, is_primary=True, auto_id=True),
FieldSchema(“vector”, DataType.FLOAT_VECTOR, dim=768),]
schema = CollectionSchema(
fields,
enable_dynamic_field=True,)
# 2. Crear la colección.
mc = Collection(“MilvusDocs”, schema)
# 3. Indexar la colección.
mc.create_index(
field_name=”vector”,
index_params={
“index_type”: “AUTOINDEX”,
“metric_type”: “COSINE”,}
A diferencia de Pinecone, ¡insertar todos los datos y poblar un índice de embeddings en Milvus/Zilliz es rápido!
# Insertar datos en la colección de Milvus.
insert_result = mc.insert(chunk_list)
# Después de insertar la entidad final, llama a flush
# para detener los segmentos en crecimiento que quedan en memoria.
mc.flush()
print(mc.partitions)
Paso 5: Haz preguntas sobre tus documentos
Ahora estamos listos para hacer preguntas sobre nuestros documentos personalizados usando el poder de la búsqueda semántica. La búsqueda semántica utiliza una técnica de vecinos más cercanos en el espacio vectorial para encontrar los documentos coincidentes más cercanos que respondan la pregunta del usuario. La búsqueda semántica busca comprender el significado detrás de las preguntas y los documentos, en lugar de solo coincidir palabras clave. Durante la recuperación, Milvus también puede utilizar metadatos para mejorar la experiencia de búsqueda (usando expresiones booleanas en la opción de la API de Milvus expr=).
# Definir una pregunta de muestra sobre tus datos.
QUESTION = "what is the default distance metric used in AUTOINDEX?"
QUERY = [question]
# Antes de realizar una búsqueda, carga los datos en memoria.
mc.load()
# Incrustar la pregunta usando el mismo codificador.
embedded_question = torch.tensor(encoder.encode([QUESTION]))
# Normalizar las incrustaciones a longitud unitaria.
embedded_question = F.normalize(embedded_question, p=2, dim=1)
# Convertir las incrustaciones a lista de listas de np.float32.
embedded_question = list(map(np.float32, embedded_question))
# Devolver los k resultados principales con AUTOINDEX.
TOP_K = 5
# Ejecutar búsqueda vectorial semántica usando tu consulta y la base de datos vectorial.
start_time = time.time()
results = mc.search(
data=embedded_question,
anns_field="vector",
# Sin parámetros para AUTOINDEX
param={},
# Expresión booleana si la hay
expr="",
output_fields=["h1", "h2", "text", "source"],
limit=TOP_K,
consistency_level="Eventually")
elapsed_time = time.time() - start_time
print(f"Tiempo de búsqueda de Milvus: {elapsed_time} sec")
A continuación, echamos un vistazo rápido a lo que se recuperó. Luego metemos todos los textos en un campo context.
for n, hits in enumerate(results):
print(f"{n}th query result")
for hit in hits:
print(hit)
# Assemble the context as a stuffed string.
context = ""
for r in results[0]:
text = r.entity.text
context += f"{text} "
# Also save the context metadata to retrieve along with the answer.
context_metadata = {
"h1": results[0][0].entity.h1,
"h2": results[0][0].entity.h2,
"source": results[0][0].entity.source,}
Arriba, podemos ver que, efectivamente, se recuperaron 5 fragmentos de texto. En particular, el primer fragmento contiene la respuesta a la pregunta sobre la métrica predeterminada. Como recuperamos usando la opción de la API de Milvus output_fields=, los metadatos de fuentes y citas se recuperan junto con el fragmento.
id: 445766022949255988, distance: 0.708217978477478, entity: {
'chunk': "...# Optional, default MetricType.L2 } timeout (float) –
An optional duration of time in seconds to allow for the
RPC. …",
'source': 'https://pymilvus.readthedocs.io/en/latest/api.html',
'h1': 'API reference',
'h2': 'Client'}
Paso 6: Usar un LLM para generar una respuesta de chat a la pregunta del usuario usando el contexto recuperado
Usaremos un modelo de IA generativa abierto y muy pequeño, o LLM, disponible en HuggingFace.
#pip install transformers
from transformers import AutoTokenizer, pipeline
tiny_llm = "deepset/tinyroberta-squad2"
tokenizer = AutoTokenizer.from_pretrained(tiny_llm)
# context cannot be empty so just put random text in it.
QA_input = {
'question': question,
'context': 'The quick brown fox jumped over the lazy dog'}
nlp = pipeline('question-answering',
model=tiny_llm,
tokenizer=tokenizer)
result = nlp(QA_input)
print(f"Question: {question}")
print(f"Answer: {result['answer']}")
¡La respuesta no fue muy útil! Ahora, haz la misma pregunta usando el contexto recuperado.
QA_input = {
'question': question,
'context': context,}
nlp = pipeline('question-answering',
model=tiny_llm,
tokenizer=tokenizer)
result = nlp(QA_input)
# Print the question, answer, grounding sources and citations.
Answer = assemble_grounding_sources(result[‘answer’], context_metadata)
print(f"Question: {question}")
print(answer)
¡Esa respuesta se ve un poco mejor! Hemos practicado la recuperación gratis en nuestros propios datos usando LLMs de código abierto. Ahora hagamos una llamada de pago a OpenAI GPT. Esperamos la misma respuesta que el LLM simple de código abierto, pero más humana.
def prepare_response(response):
return response["choices"][-1]["message"]["content"]
def generate_response(
llm,
temperature=0.0, #0 for reproducible experiments
grounding_sources=None,
system_content="", assistant_content="", user_content=""):
response = openai.ChatCompletion.create(
model=llm,
temperature=temperature,
api_key=openai.api_key,
messages=[
{"role": "system", "content": system_content},
{"role": "assistant", "content": assistant_content},
{"role": "user", "content": user_content}, ])
answer = prepare_response(response=response)
# Add the grounding sources and citations.
answer = assemble_grounding_sources(answer, grounding_sources)
return answer
# Generate response
response = generate_response(
llm="gpt-3.5-turbo-1106",
temperature=0.0,
grounding_sources=context_metadata,
system_content="Answer the question using the context provided. Be succinct.",
user_content=f"question: {QUESTION}, context: {context}")
# Print the question, answer, grounding sources and citations.
print(f"Question: {QUESTION}")
print(response)
Resumen
Demostramos un chatbot RAG de recuperación y respuesta a preguntas de principio a fin en documentos personalizados. Vimos lo fácil que es iterar recuperando y respondiendo preguntas usando tus datos de forma gratuita. Esto fue posible con LangChain, Milvus y LLMs de código abierto para el codificador y la generación de chat. Durante la recuperación, Milvus proporcionó fuentes y citas (simplemente agrega esos campos en los metadatos durante la carga de datos y usa ‘output_fields=’ en la llamada de recuperación de la API). Finalmente, vimos que este enfoque ahorra dinero, ya que hacemos llamadas gratuitas a nuestros datos casi todo el tiempo: recuperación, evaluación e iteraciones de desarrollo. Solo hacemos una llamada de pago a OpenAI una vez para el paso final de generación de chat.
Más recursos para empezar con Milvus y Zilliz
Sigue leyendo

Zilliz Named "Highest Performer" and "Easiest to Use" in G2's Summer 2025 Grid® Report for Vector Databases
Zilliz shines in G2's Summer 2025 Grid® Report as both "Highest Performer" and "Easiest to Use," solving the performance-usability dilemma.

What Exactly Are AI Agents? Why OpenAI and LangChain Are Fighting Over Their Definition?
AI agents are software programs powered by AI that can perceive their environment, make decisions, and take actions to achieve a goal—often autonomously.

Vector Databases vs. Graph Databases
Use a vector database for AI-powered similarity search; use a graph database for complex relationship-based queries and network analysis.



