From b0ba15f3d5f29242cab65849052cbc54e5d2f33c Mon Sep 17 00:00:00 2001 From: Octopus Date: Wed, 1 Apr 2026 16:40:32 +0800 Subject: [PATCH] Add MiniMax RAG cookbook notebook Add a new cookbook notebook demonstrating how to use MiniMax M2.7 and M2.7-highspeed models with Haystack via the OpenAI-compatible API. The notebook covers: - Basic chat with MiniMax using OpenAIChatGenerator - Full RAG pipeline with InMemoryDocumentStore and embedding retrieval - Model comparison between M2.7 and M2.7-highspeed variants Also includes 34 tests (31 unit + 3 integration) validating notebook structure, MiniMax configuration, and API connectivity. --- index.toml | 5 + notebooks/rag_with_minimax.ipynb | 354 +++++++++++++++++++++++++++++++ tests/test_rag_with_minimax.py | 337 +++++++++++++++++++++++++++++ 3 files changed, 696 insertions(+) create mode 100644 notebooks/rag_with_minimax.ipynb create mode 100644 tests/test_rag_with_minimax.py diff --git a/index.toml b/index.toml index 6809dc6..3f427c3 100644 --- a/index.toml +++ b/index.toml @@ -368,3 +368,8 @@ title = "LinkedIn, Company Intelligence & Lead Enrichment with Haystack, MongoDB notebook = "ai_sales_research_assistant.ipynb" new = true topics = ["RAG", "Web-QA"] + +[[cookbook]] +title = "RAG Pipeline with MiniMax via OpenAI-Compatible API" +notebook = "rag_with_minimax.ipynb" +topics = ["RAG"] diff --git a/notebooks/rag_with_minimax.ipynb b/notebooks/rag_with_minimax.ipynb new file mode 100644 index 0000000..c1b44c6 --- /dev/null +++ b/notebooks/rag_with_minimax.ipynb @@ -0,0 +1,354 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# RAG Pipeline with MiniMax via OpenAI-Compatible API\n", + "\n", + "In this notebook, you'll learn how to use **[MiniMax](https://www.minimax.io/)** large language models with **Haystack** through the OpenAI-compatible API.\n", + "\n", + "MiniMax provides models like **MiniMax-M2.7** (204K context window) and **MiniMax-M2.7-highspeed**, which are accessible via an OpenAI-compatible endpoint. This means you can use Haystack's built-in `OpenAIChatGenerator` to work with MiniMax models — no extra integration packages required.\n", + "\n", + "We'll build:\n", + "1. A **basic chat** example to verify the connection\n", + "2. A full **RAG pipeline** using MiniMax as the generator" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Install the required packages:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!pip install -q -U haystack-ai datasets sentence-transformers" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Set your MiniMax API key. You can get one from the [MiniMax Platform](https://platform.minimax.io/)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from getpass import getpass\n", + "\n", + "if \"MINIMAX_API_KEY\" not in os.environ:\n", + " os.environ[\"MINIMAX_API_KEY\"] = getpass(\"Enter your MiniMax API key: \")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Basic Chat with MiniMax\n", + "\n", + "Since MiniMax provides an OpenAI-compatible API at `https://api.minimax.io/v1`, you can use\n", + "Haystack's `OpenAIChatGenerator` directly by setting `api_base_url` and passing the API key." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from haystack.components.generators.chat import OpenAIChatGenerator\n", + "from haystack.dataclasses import ChatMessage\n", + "from haystack.utils import Secret\n", + "\n", + "minimax_chat = OpenAIChatGenerator(\n", + " api_key=Secret.from_env_var(\"MINIMAX_API_KEY\"),\n", + " model=\"MiniMax-M2.7\",\n", + " api_base_url=\"https://api.minimax.io/v1\",\n", + " generation_kwargs={\"temperature\": 0.7, \"max_tokens\": 512},\n", + ")\n", + "\n", + "messages = [ChatMessage.from_user(\"What are the Seven Wonders of the Ancient World? List them briefly.\")]\n", + "result = minimax_chat.run(messages=messages)\n", + "\n", + "print(result[\"replies\"][0].text)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Build a RAG Pipeline\n", + "\n", + "Now let's build a full Retrieval-Augmented Generation (RAG) pipeline using MiniMax as the LLM.\n", + "We'll use:\n", + "- **InMemoryDocumentStore** for storing documents\n", + "- **SentenceTransformersDocumentEmbedder** for creating document embeddings\n", + "- **InMemoryEmbeddingRetriever** for retrieval\n", + "- **OpenAIChatGenerator** (pointing to MiniMax) for generation" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Load and Index Documents\n", + "\n", + "We'll use the [Seven Wonders](https://huggingface.co/datasets/bilgeyucel/seven-wonders) dataset." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from datasets import load_dataset\n", + "from haystack import Document\n", + "\n", + "dataset = load_dataset(\"bilgeyucel/seven-wonders\", split=\"train\")\n", + "docs = [Document(content=doc[\"content\"], meta=doc[\"meta\"]) for doc in dataset]\n", + "\n", + "print(f\"Loaded {len(docs)} documents\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from haystack.document_stores.in_memory import InMemoryDocumentStore\n", + "from haystack.components.embedders import SentenceTransformersDocumentEmbedder\n", + "\n", + "document_store = InMemoryDocumentStore()\n", + "\n", + "doc_embedder = SentenceTransformersDocumentEmbedder(\n", + " model=\"sentence-transformers/all-MiniLM-L6-v2\"\n", + ")\n", + "\n", + "docs_with_embeddings = doc_embedder.run(docs)\n", + "document_store.write_documents(docs_with_embeddings[\"documents\"])\n", + "\n", + "print(f\"Indexed {document_store.count_documents()} documents\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Define the Prompt Template\n", + "\n", + "Create a prompt that instructs the model to answer based on the retrieved documents." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from haystack.components.builders import ChatPromptBuilder\n", + "from haystack.dataclasses import ChatMessage\n", + "\n", + "system_message = ChatMessage.from_system(\n", + " \"You are a helpful assistant that answers questions based on the provided documents. \"\n", + " \"If the documents don't contain the answer, say so.\"\n", + ")\n", + "\n", + "user_message_template = \"\"\"\\\n", + "Answer the question based on these documents:\n", + "\n", + "{% for document in documents %}\n", + "Document {{ loop.index }}:\n", + "{{ document.content }}\n", + "---\n", + "{% endfor %}\n", + "\n", + "Question: {{ question }}\n", + "Answer:\"\"\"\n", + "\n", + "prompt_builder = ChatPromptBuilder(\n", + " template=[system_message, ChatMessage.from_user(user_message_template)]\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Assemble the RAG Pipeline" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from haystack import Pipeline\n", + "from haystack.components.embedders import SentenceTransformersTextEmbedder\n", + "from haystack.components.retrievers.in_memory import InMemoryEmbeddingRetriever\n", + "\n", + "text_embedder = SentenceTransformersTextEmbedder(\n", + " model=\"sentence-transformers/all-MiniLM-L6-v2\"\n", + ")\n", + "retriever = InMemoryEmbeddingRetriever(document_store)\n", + "\n", + "llm = OpenAIChatGenerator(\n", + " api_key=Secret.from_env_var(\"MINIMAX_API_KEY\"),\n", + " model=\"MiniMax-M2.7\",\n", + " api_base_url=\"https://api.minimax.io/v1\",\n", + " generation_kwargs={\"temperature\": 0.7, \"max_tokens\": 1024},\n", + ")\n", + "\n", + "rag_pipeline = Pipeline()\n", + "rag_pipeline.add_component(\"text_embedder\", text_embedder)\n", + "rag_pipeline.add_component(\"retriever\", retriever)\n", + "rag_pipeline.add_component(\"prompt_builder\", prompt_builder)\n", + "rag_pipeline.add_component(\"llm\", llm)\n", + "\n", + "rag_pipeline.connect(\"text_embedder.embedding\", \"retriever.query_embedding\")\n", + "rag_pipeline.connect(\"retriever\", \"prompt_builder.documents\")\n", + "rag_pipeline.connect(\"prompt_builder\", \"llm\")\n", + "\n", + "rag_pipeline.draw(\"rag_pipeline_minimax.png\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Ask Questions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "question = \"Why were people visiting the Temple of Artemis?\"\n", + "\n", + "result = rag_pipeline.run(\n", + " data={\n", + " \"text_embedder\": {\"text\": question},\n", + " \"retriever\": {\"top_k\": 3},\n", + " \"prompt_builder\": {\"question\": question},\n", + " }\n", + ")\n", + "\n", + "print(result[\"llm\"][\"replies\"][0].text)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "question = \"How did the Colossus of Rhodes collapse?\"\n", + "\n", + "result = rag_pipeline.run(\n", + " data={\n", + " \"text_embedder\": {\"text\": question},\n", + " \"retriever\": {\"top_k\": 3},\n", + " \"prompt_builder\": {\"question\": question},\n", + " }\n", + ")\n", + "\n", + "print(result[\"llm\"][\"replies\"][0].text)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Using MiniMax-M2.7-highspeed\n", + "\n", + "For faster inference, you can switch to the `MiniMax-M2.7-highspeed` model.\n", + "It has the same 204K context window but optimized for speed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fast_llm = OpenAIChatGenerator(\n", + " api_key=Secret.from_env_var(\"MINIMAX_API_KEY\"),\n", + " model=\"MiniMax-M2.7-highspeed\",\n", + " api_base_url=\"https://api.minimax.io/v1\",\n", + " generation_kwargs={\"temperature\": 0.7, \"max_tokens\": 512},\n", + ")\n", + "\n", + "messages = [ChatMessage.from_user(\"Explain what RAG (Retrieval-Augmented Generation) is in 3 sentences.\")]\n", + "result = fast_llm.run(messages=messages)\n", + "\n", + "print(result[\"replies\"][0].text)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "In this notebook, you learned how to:\n", + "- Use **MiniMax** models with Haystack's `OpenAIChatGenerator` via the OpenAI-compatible API\n", + "- Build a complete **RAG pipeline** with MiniMax as the generator\n", + "- Switch between **MiniMax-M2.7** (high quality) and **MiniMax-M2.7-highspeed** (optimized for speed)\n", + "\n", + "### Available MiniMax Models\n", + "\n", + "| Model | Context Window | Description |\n", + "|-------|---------------|-------------|\n", + "| `MiniMax-M2.7` | 204K tokens | Latest flagship model |\n", + "| `MiniMax-M2.7-highspeed` | 204K tokens | Speed-optimized variant |\n", + "\n", + "### Key Configuration\n", + "\n", + "To use MiniMax with any Haystack component that supports `OpenAIChatGenerator`:\n", + "\n", + "```python\n", + "from haystack.components.generators.chat import OpenAIChatGenerator\n", + "from haystack.utils import Secret\n", + "\n", + "generator = OpenAIChatGenerator(\n", + " api_key=Secret.from_env_var(\"MINIMAX_API_KEY\"),\n", + " model=\"MiniMax-M2.7\",\n", + " api_base_url=\"https://api.minimax.io/v1\",\n", + ")\n", + "```\n", + "\n", + "For more details, visit the [MiniMax API documentation](https://platform.minimax.io/)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/tests/test_rag_with_minimax.py b/tests/test_rag_with_minimax.py new file mode 100644 index 0000000..cc76050 --- /dev/null +++ b/tests/test_rag_with_minimax.py @@ -0,0 +1,337 @@ +""" +Tests for the MiniMax RAG cookbook notebook. + +Unit tests validate notebook structure, configuration patterns, and MiniMax API setup. +Integration tests (marked with @pytest.mark.integration) validate actual API calls. +""" + +import json +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +NOTEBOOK_PATH = Path(__file__).parent.parent / "notebooks" / "rag_with_minimax.ipynb" + +MINIMAX_API_BASE_URL = "https://api.minimax.io/v1" +MINIMAX_MODELS = ["MiniMax-M2.7", "MiniMax-M2.7-highspeed"] + + +# ─── helpers ─── + + +def load_notebook(): + """Load the notebook and return parsed JSON.""" + with open(NOTEBOOK_PATH, "r") as f: + return json.load(f) + + +def get_all_code(nb): + """Concatenate all code cell sources.""" + return "\n".join( + "".join(cell["source"]) + for cell in nb["cells"] + if cell["cell_type"] == "code" + ) + + +def get_all_markdown(nb): + """Concatenate all markdown cell sources.""" + return "\n".join( + "".join(cell["source"]) + for cell in nb["cells"] + if cell["cell_type"] == "markdown" + ) + + +# ─── unit tests: notebook structure ─── + + +class TestNotebookStructure: + """Validate notebook file structure and metadata.""" + + def test_notebook_exists(self): + assert NOTEBOOK_PATH.exists(), f"Notebook not found at {NOTEBOOK_PATH}" + + def test_valid_json(self): + nb = load_notebook() + assert "cells" in nb + assert "metadata" in nb + assert nb["nbformat"] == 4 + + def test_has_markdown_and_code_cells(self): + nb = load_notebook() + cell_types = [cell["cell_type"] for cell in nb["cells"]] + assert "markdown" in cell_types + assert "code" in cell_types + + def test_starts_with_title(self): + nb = load_notebook() + first_cell = nb["cells"][0] + assert first_cell["cell_type"] == "markdown" + title = "".join(first_cell["source"]) + assert "MiniMax" in title + + def test_has_pip_install(self): + code = get_all_code(load_notebook()) + assert "pip install" in code + assert "haystack-ai" in code + + def test_ends_with_summary(self): + nb = load_notebook() + last_cell = nb["cells"][-1] + assert last_cell["cell_type"] == "markdown" + text = "".join(last_cell["source"]) + assert "Summary" in text or "summary" in text + + +class TestMiniMaxConfiguration: + """Validate MiniMax-specific configuration in notebook cells.""" + + def test_api_base_url_correct(self): + code = get_all_code(load_notebook()) + assert MINIMAX_API_BASE_URL in code + + def test_uses_minimax_api_key_env(self): + code = get_all_code(load_notebook()) + assert "MINIMAX_API_KEY" in code + + def test_uses_m27_model(self): + code = get_all_code(load_notebook()) + assert "MiniMax-M2.7" in code + + def test_uses_highspeed_model(self): + code = get_all_code(load_notebook()) + assert "MiniMax-M2.7-highspeed" in code + + def test_temperature_in_valid_range(self): + """MiniMax requires temperature in (0.0, 1.0].""" + code = get_all_code(load_notebook()) + import re + + temps = re.findall(r'"temperature":\s*([\d.]+)', code) + for t in temps: + temp_val = float(t) + assert 0.0 < temp_val <= 1.0, f"Temperature {temp_val} outside (0, 1]" + + def test_uses_openai_chat_generator(self): + code = get_all_code(load_notebook()) + assert "OpenAIChatGenerator" in code + + def test_api_key_via_secret(self): + code = get_all_code(load_notebook()) + assert "Secret.from_env_var" in code + + def test_no_hardcoded_api_key(self): + code = get_all_code(load_notebook()) + # Should not contain actual API keys + import re + + assert not re.search(r'api_key\s*=\s*["\'][a-zA-Z0-9]{20,}["\']', code) + + +class TestRAGPipelineStructure: + """Validate RAG pipeline components in notebook.""" + + def test_has_document_store(self): + code = get_all_code(load_notebook()) + assert "InMemoryDocumentStore" in code + + def test_has_embedder(self): + code = get_all_code(load_notebook()) + assert "SentenceTransformersDocumentEmbedder" in code + + def test_has_retriever(self): + code = get_all_code(load_notebook()) + assert "InMemoryEmbeddingRetriever" in code + + def test_has_prompt_builder(self): + code = get_all_code(load_notebook()) + assert "ChatPromptBuilder" in code + + def test_pipeline_connections(self): + code = get_all_code(load_notebook()) + assert "rag_pipeline.connect" in code + + def test_has_question_examples(self): + code = get_all_code(load_notebook()) + assert "question" in code + + +class TestDocumentation: + """Validate documentation quality in markdown cells.""" + + def test_mentions_minimax_in_title(self): + md = get_all_markdown(load_notebook()) + assert "MiniMax" in md + + def test_mentions_openai_compatible(self): + md = get_all_markdown(load_notebook()) + assert "OpenAI-compatible" in md or "OpenAI compatible" in md + + def test_mentions_context_window(self): + md = get_all_markdown(load_notebook()) + assert "204K" in md + + def test_mentions_both_models_in_docs(self): + md = get_all_markdown(load_notebook()) + assert "MiniMax-M2.7" in md + assert "MiniMax-M2.7-highspeed" in md + + def test_has_api_key_instructions(self): + md = get_all_markdown(load_notebook()) + assert "API key" in md or "api key" in md or "API Key" in md + + def test_has_model_table(self): + md = get_all_markdown(load_notebook()) + assert "| Model" in md + + +class TestIndexToml: + """Validate index.toml entry for the MiniMax notebook.""" + + def test_notebook_in_index(self): + try: + import tomllib + except ImportError: + import tomli as tomllib + + index_path = Path(__file__).parent.parent / "index.toml" + data = tomllib.loads(index_path.read_text()) + + notebooks = [nb["notebook"] for nb in data["cookbook"] if "notebook" in nb] + assert "rag_with_minimax.ipynb" in notebooks + + def test_index_entry_has_required_fields(self): + try: + import tomllib + except ImportError: + import tomli as tomllib + + index_path = Path(__file__).parent.parent / "index.toml" + data = tomllib.loads(index_path.read_text()) + + minimax_entry = None + for nb in data["cookbook"]: + if nb.get("notebook") == "rag_with_minimax.ipynb": + minimax_entry = nb + break + + assert minimax_entry is not None, "MiniMax entry not found in index.toml" + assert "title" in minimax_entry + assert "topics" in minimax_entry + assert len(minimax_entry["topics"]) > 0 + assert "MiniMax" in minimax_entry["title"] + + +# ─── unit tests: mock-based API validation ─── + + +class TestMiniMaxAPISetup: + """Test MiniMax API configuration with mocked OpenAI client.""" + + @patch("openai.OpenAI") + def test_client_creation_with_minimax_base_url(self, mock_openai_cls): + """Verify OpenAI client can be configured with MiniMax base URL.""" + from openai import OpenAI + + client = OpenAI( + api_key="test-key", + base_url=MINIMAX_API_BASE_URL, + ) + mock_openai_cls.assert_called_once_with( + api_key="test-key", + base_url=MINIMAX_API_BASE_URL, + ) + + def test_generation_kwargs_valid(self): + """Ensure generation kwargs match MiniMax constraints.""" + kwargs = {"temperature": 0.7, "max_tokens": 512} + assert 0.0 < kwargs["temperature"] <= 1.0 + assert kwargs["max_tokens"] > 0 + + def test_model_names_valid(self): + """Verify model names match MiniMax naming convention.""" + for model in MINIMAX_MODELS: + assert model.startswith("MiniMax-") + assert "M2.7" in model + + +# ─── integration tests ─── + + +@pytest.mark.integration +class TestMiniMaxIntegration: + """Integration tests that make actual API calls to MiniMax. + + Run with: pytest -m integration + Requires MINIMAX_API_KEY environment variable. + """ + + @pytest.fixture(autouse=True) + def skip_without_api_key(self): + if not os.environ.get("MINIMAX_API_KEY"): + pytest.skip("MINIMAX_API_KEY not set") + + @staticmethod + def _strip_think_tags(text): + """Remove ... tags from MiniMax responses.""" + import re + return re.sub(r"[\s\S]*?\s*", "", text).strip() + + def test_basic_chat_completion(self): + """Test basic chat completion with MiniMax M2.7.""" + from openai import OpenAI + + client = OpenAI( + api_key=os.environ["MINIMAX_API_KEY"], + base_url=MINIMAX_API_BASE_URL, + ) + response = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[{"role": "user", "content": "Say 'hello' in one word."}], + temperature=0.1, + max_tokens=256, + ) + assert response.choices[0].message.content is not None + content = self._strip_think_tags(response.choices[0].message.content) + assert len(content) > 0 + + def test_highspeed_model(self): + """Test chat completion with MiniMax-M2.7-highspeed.""" + from openai import OpenAI + + client = OpenAI( + api_key=os.environ["MINIMAX_API_KEY"], + base_url=MINIMAX_API_BASE_URL, + ) + response = client.chat.completions.create( + model="MiniMax-M2.7-highspeed", + messages=[{"role": "user", "content": "What is 2+2? Answer with just the number."}], + temperature=0.1, + max_tokens=256, + ) + assert response.choices[0].message.content is not None + content = self._strip_think_tags(response.choices[0].message.content) + assert "4" in content + + def test_haystack_openai_chat_generator(self): + """Test MiniMax via Haystack's OpenAIChatGenerator.""" + from haystack.components.generators.chat import OpenAIChatGenerator + from haystack.dataclasses import ChatMessage + from haystack.utils import Secret + + generator = OpenAIChatGenerator( + api_key=Secret.from_env_var("MINIMAX_API_KEY"), + model="MiniMax-M2.7", + api_base_url=MINIMAX_API_BASE_URL, + generation_kwargs={"temperature": 0.1, "max_tokens": 50}, + ) + messages = [ChatMessage.from_user("What is the capital of France? One word answer.")] + result = generator.run(messages=messages) + + assert "replies" in result + assert len(result["replies"]) > 0 + assert "Paris" in result["replies"][0].text