
Uno de los retos que nos encontramos a la hora de almacenar la información es que no toda es de la misma naturaleza. Pensemos por ejemplo en documentos de texto, sonido, imágenes, vídeos, etc. En estos casos, los datos no tienen una estructura fija y no se pueden almacenar en una tabla o mejor dicho, si se pueden, pero no de una forma en la que podamos acceder a ellos de forma eficiente
Imaginad por ejemplo, que nos han encargado construir un sistema de seguridad para permitir el acceso a un edificio mediante voz. Para ello, necesito almacenar la información de los empleados, su nombre, apellidos, DNI, etc. y, por supuesto, una grabación de su voz, etc. ¿Cómo lo haríamos?. Almacenar la información es fácil, pero ¿cómo la recuperamos?, ¿cómo conseguimos comparar la voz de una persona con la grabación que tenemos almacenada en un tiempo prudencial?. Es en este tipo de situaciones donde las bases de datos vectoriales nos pueden ayudar.
¿Y cómo lo hacen?. Pues bien, en lugar de almacenar la información de forma tabular, lo que hacen es almacenar la información en forma de vectores multidimensionales. Cada dimensión del vector representa una característica de la información que queremos almacenar. Para explicarlo de manera sencilla, si queremos almacenar la información de una persona, y estamos almacenando su nacionalidad, su edad, su altura, su peso, etc., cada una de estas características se almacenaría en una dimensión del vector. Si dos personas tienen la misma nacionalidad, en esa dimensión tendrían el mismo valor. En el caso de los textos, cada término representaría una dimensión.
A estos vectores se les denomina embeddings, porque no son solo la representación matemática de la información, sino que también representan la semántica de la información. Es decir, si dos vectores son parecidos, es porque la información que representan es parecida. Lo increíble es que esos embeddings que se generan a partir de esa información desestructurada capturan la semántica de la información en el espacio vectorial que representan, vamos, que sirven para comparar la información de una forma más eficiente mediante el uso de operaciones matemáticas entre vectores como: distancia euclídea (cómo de parecidas son las dimensiones de los vectores), la similitud coseno y el producto punto (cuánto apuntan dos vectores en la misma dirección), etc. Cada una de estas operaciones tiene aplicaciones diferentes, por ejemplo, la similitud coseno tiene aplicación para encontrar textos con semántica parecida, mientras que el producto punto se usa para el reconocimiento de imágenes y sonidos.
- Pero, al final ¿esto para qué vale?; pues para muchas cosas, por ejemplo:
- Recomendaciones de productos basándonos en el consumo de productos similares o comportamientos parecidos entre usuarios
- Reconocimiento de imágenes y sonidos para sistemas de seguridad
- Motores de búsqueda semántica para servicios de atención al cliente que contestan de manera automática
- Buscadores de información especializada, como información jurídica o científica, en la que se necesita consultar grandes cantidades de textos de manera continuada
Ya solo nos falta una cosita más ponerle la guinda al pastel de la recuperación de la información. Si además, la información obtenida se la pasamos a un LLM, entonces ya tenemos un sistema de recuperación de información que contesta de una forma natural a las preguntas que le hagamos, como si de una persona que sabe mucho del tema se tratara (Magic!).
Pues ya sin más preámbulos, vamos a hacer un ejemplo sencillo de todo esto de la mano de una de las TOP 5 bases de datos vectoriales del momento: Chroma.
Índice
1. Entorno
- El tutorial está escrito usando el siguiente entorno:
- Hardware: Portátil MacBook Pro 15″ (Intel Core i9, 32GB, 1TB SSD).
- IntelliJ IDEA (2024.2.3) con el plugin de Python
- Sistema Operativo: macOS Sonoma 14.7
2. El ejemplo
Antes de nada, os dejo todo el código fuente de este tutorial en este repositorio de GitHub.
He usado poetry para gestionar las dependencias del proyecto y pyenv para gestionar las versiones de Python.
Usaremos OpenAI, con lo que necesitarás una API Key. El proyecto espera que exista un fichero .env
en la raíz del proyecto con la clave de la API de OpenAI:
OPENAI_API_KEY=[API_KEY]
Para este ejemplo he copiado unas recetas de cocina en un directorio del proyecto. Cada receta está en un fichero de texto y el nombre del fichero es el nombre de la receta que vamos a procesar y cargar en chroma usando el fichero load_recipes.py
del proyecto con:
... documents = load_documents(args.source) chunked_documents = split_documents(documents, size=2000, overlap=20) if args.openai_embedding: chunked_documents = add_open_ai_embeddings(chunked_documents, openai_api_key=os.getenv("OPENAI_API_KEY")) chroma_client = get_persistent_client(database_folder=f"{base_dir}/db/recipes") default_ef = embedding_functions.DefaultEmbeddingFunction() collection_name = "recipes_default" if not args.openai_embedding else "recipes_openai" collection = chroma_client.get_or_create_collection(collection_name, embedding_function=default_ef) for doc in chunked_documents: embeddings = [doc["embedding"]] if args.openai_embedding else None print("*" * 10) print(f"Inserting document {doc["id"]} \ntext: {doc["text"]} \nembeddings: {embeddings}") print("*" * 10) collection.upsert(ids=[doc["id"]], documents=[doc["text"]], embeddings=embeddings)
Os explico un poco el código de la carga de las recetas:
El programa está preparado para usar la función de embedding por defecto de Chroma o de OpenAI, pero no son compatibles entre sí, ya que tienen un número diferente de dimensiones, por lo que si se usa la función de OpenAI, se creará una colección diferente en Chroma. Para usar la función de OpenAI, se debe pasar el parámetro --openai-embedding
al programa.
El programa carga las recetas de cocina del directorio que se le pasa como argumento y las divide en trozos de 2000 caracteres con un overlap de 20 caracteres (en este caso, el overlap es casi innecesario porque al haber separado en ficheros distintos por receta y ser pequeñas, tendremos un «chunk» por receta). Para cada «chunk», llamamos al API de OpenAI para obtener el embedding de cada receta y lo almacenamos en Chroma usando un cliente de persistencia local.
Utilizará la función de distancia por defecto.
Sin más, ejecutamos el programa para cargar los datos:
python ./load_recipes.py /Users/fjmpaez/work/tutoriales/chromadb-start/recipes --openai_embedding
Ya deberíamos tener cargadas las recetas en una colección de Chroma. Si tenéis un cliente de SQLLite (Chroma lo usa como base de datos de backend por defecto), podéis ver la base de datos en el directorio db/recipes
del proyecto:
Ahora, vamos a hacer una búsqueda de recetas en Chroma. En el fichero search_assistant.py
del proyecto, tenemos el siguiente código:
... chroma_client = chromadb.HttpClient(host='localhost', port=8000) collection_name = "recipes_default" if not args.openai_embedding else "recipes_openai" embedding_function = embedding_functions.DefaultEmbeddingFunction() if not args.openai_embedding else embedding_functions.OpenAIEmbeddingFunction( api_key=openai_api_key, model_name="text-embedding-3-small" ) collection = chroma_client.get_collection(collection_name, embedding_function=embedding_function) while True: user_input = input(f"({collection_name})-> Cliente: ") if user_input.lower() == 'exit': break query_result = collection.query(query_texts=[user_input], n_results=3) if len(query_result["documents"][0]) == 0: print("No similar documents found. Try Again!") continue print( f"Found documents:" ) for ids, document in enumerate(query_result["documents"][0]): doc_id = query_result["ids"][0][ids] distance = query_result["distances"][0][ids] print( f"- ID: {doc_id}, Distance: {distance}, Text: {document[:150]}..." )
Os explico el código del asistente de búsqueda:
Tenemos que indicar si usaremos la función de embedding por defecto de Chroma o la de OpenAI. En este caso, usaremos la función de OpenAI, por lo que pasamos el parámetro --openai-embedding
al programa (no debemos mezclar funciones de embedding para buscar en la misma colección y menos si no tienen las mismas dimensiones).
El código ahora se conecta por HTTP a la base de datos.
El programa espera una entrada del usuario y busca en la colección las recetas más parecidas a la entrada del usuario (menor distancia). En este caso, buscaremos las 3 recetas más parecidas.
Ahora, antes de ejecutar el programa, asegúrate de que Chroma está en marcha. Si no lo has hecho ya, puedes arrancar Chroma con el siguiente comando:
chroma run --path ./db/recipes
Y ahora, ejecutamos el programa para hacer búsquedas:
python ./search_assistant.py --openai_embedding
Ahora podemos hacer algunas búsquedas y ver qué obtenemos. Busco «islas afortunadas» y me devuelve la primera Mojo Picón, muy interesante:
Y si pongo «verano» lo primero es el gazpachito, jijiji, ¡Qué bueno!:
Ahora ya solo nos queda aprovecharnos de OpenAI para hablar y recibir respuestas de manera más natural. Para eso usaremos langchain que nos lo hará todo más sencillo:
... system_prompt = """ You are a helpful recipes assistant designed to help clients to find recipes. You must always provide recipes from context. If you can't find a proper recipe, just answer you don't have the recipe. The result must be one only recipe. """ ... chroma_client = chromadb.HttpClient(host='localhost', port=8000) collection_name = "recipes_default" if not args.openai_embedding else "recipes_openai" embedding_function = embedding_functions.DefaultEmbeddingFunction() if not args.openai_embedding else OpenAIEmbeddings( api_key=openai_api_key, model="text-embedding-3-small" ) db = Chroma( client=chroma_client, collection_name=collection_name, embedding_function=embedding_function ) llm = ChatOpenAI(openai_api_key=openai_api_key, model="gpt-4") tools = [ create_retriever_tool( db.as_retriever(), "recipes", "Searches and returns recipes." ) ] agent = create_conversational_retrieval_agent(llm, tools, verbose=True, system_message=SystemMessage(content=system_prompt)) while True: user_input = input(f"({collection_name})-> Cliente: ") if user_input.lower() == 'exit': break response = agent.invoke({"input": user_input}) print(f"AI: {response['output']}") ...
Creamos un prompt para indicarle a OpenAPI que se comporte como un asistente de recetas, pero que solo use las que le pasamos en el contexto, es decir las que pueda recuperar de la colección de Chroma.
Usamos el soporte de langchain para crear un agente conversacional indicando que vamos a usar OpenAI y la base de datos de Chroma como herramienta para enriquecer el contexto (retriever).
Y ya está… vamos a probarlo. Como tenemos las trazas activadas, durante las pruebas, fijáos como el agente de langchain interactúa con Chroma y OpenAI para responder a nuestras preguntas:
python ./recipes_ai_assistant.py --openai_embedding
Empecemos con la clásica poesía:
Ahora, algo fresquito:
Finalmente, pidamos algo que no tenemos en nuestras recetas, que según el prompt debería decir algo como «No tengo esa receta»:
3. Conclusiones
Ya habéis visto que fácil ha sido crear un asistente de búsqueda sobre información desestructurada como son las recetas de cocina almacenadas en texto y dotar al programa de la capacidad de responder de manera natural a preguntas sobre esas recetas. Todo ello gracias a Chroma y OpenAI.
Seguro que se os ocurren muchas más aplicaciones jugando con estas cositas.
Stay tuned!