Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .code-samples.meilisearch.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,20 @@ get_batch_1: |-
client.get_batch(BATCH_UID)
get_similar_post_1: |-
client.index("INDEX_NAME").get_similar_documents({"id": "TARGET_DOCUMENT_ID", "embedder": "default"})
search_parameter_reference_media_1: |-
client.index('movies_fragments').search(
"",
{
"media": {
"image_url": "https://image.tmdb.org/t/p/w500/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg"
},
"hybrid": {
"embedder": "voyage",
"semanticRatio": 1.0
},
"limit": 3
}
)
webhooks_get_1: |-
client.get_webhooks()
webhooks_get_single_1: |-
Expand All @@ -783,3 +797,4 @@ webhooks_patch_1: |-
})
webhooks_delete_1: |-
client.delete_webhook('WEBHOOK_UID')

4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,9 @@
# This value contains a list of modules to be mocked up.
autodoc_mock_imports = ["camel_converter"]

html_title = 'Meilisearch Python | Documentation'
html_title = "Meilisearch Python | Documentation"

# Add Fathom analytics script
html_js_files = [
("https://cdn.usefathom.com/script.js", { "data-site": "QNBPJXIV", "defer": "defer" })
("https://cdn.usefathom.com/script.js", {"data-site": "QNBPJXIV", "defer": "defer"})
]
20 changes: 20 additions & 0 deletions meilisearch/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1110,3 +1110,23 @@ def _valid_uuid(uuid: str) -> bool:
)
match = uuid4hex.match(uuid)
return bool(match)

def get_experimental_features(self) -> dict:
"""
Retrieve the current settings for all experimental features.
Returns:
dict: A mapping of feature names to their enabled/disabled state.
"""
return self.http.get(self.config.paths.experimental_features)

def update_experimental_features(self, features: dict) -> dict:
"""
Update one or more experimental features.

Args:
features (dict): A dictionary mapping feature names to booleans.
For example, {"multimodal": True} to enable multimodal.
Returns:
dict: The updated experimental features settings.
"""
return self.http.patch(self.config.paths.experimental_features, body=features)
1 change: 1 addition & 0 deletions meilisearch/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class Paths:
localized_attributes = "localized-attributes"
edit = "edit"
network = "network"
experimental_features = "experimental-features"
webhooks = "webhooks"

def __init__(
Expand Down
6 changes: 6 additions & 0 deletions meilisearch/models/embedders.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ class RestEmbedder(CamelBase):
Template defining the data Meilisearch sends to the embedder
document_template_max_bytes: Optional[int]
Maximum allowed size of rendered document template (defaults to 400)
indexing_fragments: Optional[Dict[str, Dict[str, str]]]
Defines how to fragment documents for indexing (multi-modal search)
search_fragments: Optional[Dict[str, Dict[str, str]]]
Defines how to fragment search queries (multi-modal search)
request: Dict[str, Any]
A JSON value representing the request Meilisearch makes to the remote embedder
response: Dict[str, Any]
Expand All @@ -185,6 +189,8 @@ class RestEmbedder(CamelBase):
dimensions: Optional[int] = None
document_template: Optional[str] = None
document_template_max_bytes: Optional[int] = None
indexing_fragments: Optional[Dict[str, Dict[str, str]]] = None
search_fragments: Optional[Dict[str, Dict[str, str]]] = None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type definition Dict[str, Dict[str, str]] for indexing_fragments and search_fragments seems too restrictive.

These fragments often contain complex nested structures (like lists of objects) wrapped in a value key.

For example, a valid fragment configuration looks like this:

"textAndPoster": {
    "value": {
        "content": [
            {"type": "text", "text": "..."},
            {"type": "image_url", "image_url": "..."}
        ]
    }
}

Would the Optional[Dict[str, Dict[str, Any]]] type be more fitting?

request: Dict[str, Any]
response: Dict[str, Any]
headers: Optional[Dict[str, str]] = None
Expand Down
193 changes: 193 additions & 0 deletions tests/client/test_experimental_features.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# pylint: disable=redefined-outer-name
"""Tests for indexingFragments and searchFragments in embedders (multimodal feature).
These tests validate CONFIGURATION ONLY, not AI functionality.
They only ensure fragments can be configured and stored in Meilisearch.
No AI calls or document indexing/searching occurs.
"""

import pytest

DUMMY_URL = "http://localhost:8000/embed"
TEST_MODEL = "test-model"
MULTIMODAL_MODEL = "multimodal"


def apply_embedders(index, config):
"""Helper to update embedders and wait for task completion."""
response = index.update_embedders(config)
update = index.wait_for_task(response.task_uid)
assert update.status == "succeeded"
return index.get_embedders()


def test_rest_embedder_with_fragments(empty_index, multimodal_enabled):
"""Tests that REST embedder can be configured with indexingFragments and searchFragments."""
index = empty_index()

config = {
"rest_fragments": {
"source": "rest",
"url": DUMMY_URL,
"apiKey": "test-key",
"dimensions": 512,
"indexingFragments": {"text": {"value": "{{doc.title}} - {{doc.description}}"}},
"searchFragments": {"text": {"value": "{{fragment}}"}},
"request": {"input": ["{{fragment}}"], "model": TEST_MODEL},
"response": {"data": [{"embedding": "{{embedding}}"}]},
"headers": {"Authorization": "Bearer test-key"},
}
}

embedders = apply_embedders(index, config)

e = embedders.embedders["rest_fragments"]
assert e.source == "rest"
assert e.url == DUMMY_URL
assert e.dimensions == 512
assert e.indexing_fragments is not None
assert e.search_fragments is not None


def test_rest_embedder_with_multiple_fragments(empty_index, multimodal_enabled):
"""Tests REST embedder with multiple fragment types."""
index = empty_index()

config = {
"multi_fragments": {
"source": "rest",
"url": DUMMY_URL,
"dimensions": 1024,
"indexingFragments": {
"text": {"value": "{{doc.title}}"},
"description": {"value": "{{doc.overview}}"}
},
"searchFragments": {
"text": {"value": "{{fragment}}"},
"description": {"value": "{{fragment}}"}
},
"request": {"input": ["{{fragment}}"], "model": TEST_MODEL},
"response": {"data": [{"embedding": "{{embedding}}"}]},
}
}

embedders = apply_embedders(index, config)

e = embedders.embedders["multi_fragments"]
assert e.source == "rest"
assert len(e.indexing_fragments) >= 1
assert len(e.search_fragments) >= 1


def test_fragments_without_document_template(empty_index, multimodal_enabled):
"""Tests fragments can be used without documentTemplate."""
index = empty_index()

config = {
"fragments_only": {
"source": "rest",
"url": DUMMY_URL,
"dimensions": 512,
"indexingFragments": {"text": {"value": "{{doc.content}}"}},
"searchFragments": {"text": {"value": "{{fragment}}"}},
"request": {"input": ["{{fragment}}"], "model": TEST_MODEL},
"response": {"data": [{"embedding": "{{embedding}}"}]},
}
}

embedders = apply_embedders(index, config)
e = embedders.embedders["fragments_only"]
assert e.document_template is None
assert e.indexing_fragments is not None
assert e.search_fragments is not None


def test_fragments_require_multimodal_feature(empty_index):
"""Tests fragments require multimodal feature enabled."""
index = empty_index()

config = {
"test": {
"source": "rest",
"url": DUMMY_URL,
"dimensions": 512,
"indexingFragments": {"text": {"value": "{{doc.title}}"}},
"searchFragments": {"text": {"value": "{{fragment}}"}},
"request": {"input": ["{{fragment}}"], "model": TEST_MODEL},
"response": {"data": [{"embedding": "{{embedding}}"}]},
}
}

# May succeed or fail depending on server config; both are acceptable
try:
embedders = apply_embedders(index, config)
assert embedders.embedders["test"].indexing_fragments is not None
except Exception:
pass


def test_update_fragments_separately(empty_index, multimodal_enabled):
"""Tests updating indexingFragments and searchFragments separately."""
index = empty_index()

initial_config = {
"updatable": {
"source": "rest",
"url": DUMMY_URL,
"dimensions": 512,
"indexingFragments": {"text": {"value": "{{doc.title}}"}},
"searchFragments": {"text": {"value": "{{fragment}}"}},
"request": {"input": ["{{fragment}}"], "model": TEST_MODEL},
"response": {"data": [{"embedding": "{{embedding}}"}]},
}
}

apply_embedders(index, initial_config)

updated_config = {
"updatable": {
"source": "rest",
"url": DUMMY_URL,
"dimensions": 512,
"indexingFragments": {"text": {"value": "{{doc.title}} - {{doc.description}}"}},
"searchFragments": {"text": {"value": "{{fragment}}"}},
"request": {"input": ["{{fragment}}"], "model": TEST_MODEL},
"response": {"data": [{"embedding": "{{embedding}}"}]},
}
}

embedders = apply_embedders(index, updated_config)
assert embedders.embedders["updatable"].indexing_fragments is not None


def test_profile_picture_and_title_fragments(empty_index, multimodal_enabled):
"""Tests real-world use case: user profiles with picture and title."""
index = empty_index()

config = {
"user_profile": {
"source": "rest",
"url": DUMMY_URL,
"dimensions": 768,
"indexingFragments": {
"user_name": {"value": "{{doc.name}}"},
"avatar": {"value": "{{doc.profile_picture_url}}"},
"biography": {"value": "{{doc.bio}}"},
},
"searchFragments": {
"user_name": {"value": "{{fragment}}"},
"avatar": {"value": "{{fragment}}"},
"biography": {"value": "{{fragment}}"},
},
"request": {"input": ["{{fragment}}"], "model": MULTIMODAL_MODEL},
"response": {"data": [{"embedding": "{{embedding}}"}]},
}
}

embedders = apply_embedders(index, config)
e = embedders.embedders["user_profile"]

assert e.source == "rest"
expected_keys = {"user_name", "avatar", "biography"}
assert set(e.indexing_fragments.keys()) == expected_keys
assert set(e.search_fragments.keys()) == expected_keys
Loading