Skip to content

Commit 9bcc58b

Browse files
authored
feat!: Support custom embedding models in the DocumentIndexClient (#1151)
* feat!: Support custom embedding models in the DocumentIndexClient - Allow custom embedding models when creating new indexes - Support new InstructableEmbed embedding strategy - Update test fixture to cover new configuration - Update CHANGELOG.md with user-facing changes * test: Explicitly test new index configuration structure The changes introduced in the previous commit were covered by a test that randomly selects either semantic or instructable. Running the test suite multiple times in a row is the only way to confirm with high probability that the new structure (which now includes a variant) is correct. This commit splits this test out into two sub-tests which check the semantic and instructable variants individually, with the usual field-level randomisation.
1 parent 2a8d0cb commit 9bcc58b

5 files changed

Lines changed: 176 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
## Unreleased
33

44
### Features
5-
...
5+
- You can now customise the embedding model when creating an index using the `DocumentIndexClient`.
6+
- You can now use the `InstructableEmbed` embedding strategy when creating an index using the `DocumentIndexClient`. See the `document_index.ipynb` notebook for more information and an example.
67

78
### Fixes
89
...
@@ -11,7 +12,10 @@
1112
...
1213

1314
### Breaking Changes
14-
...
15+
- The way you configure indexes in the `DocumentIndexClient` has changed. See the `document_index.ipynb` notebook for more information.
16+
- The `EmbeddingType` alias has been renamed to `Representation` to better align with the underlying API.
17+
- The `embedding_type` field has been removed from the `IndexConfiguration` class. You now configure embedding-related parameters via the `embedding` field.
18+
- You now always need to specify an embedding model when creating an index. Previously, this was always `luminous-base`.
1519

1620
## 7.3.1
1721
### Features

src/documentation/document_index.ipynb

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121
" DocumentPath,\n",
2222
" IndexConfiguration,\n",
2323
" IndexPath,\n",
24+
" InstructableEmbed,\n",
2425
" LimitedConcurrencyClient,\n",
26+
" SemanticEmbed,\n",
2527
")\n",
2628
"from intelligence_layer.core import InMemoryTracer\n",
2729
"from intelligence_layer.examples import MultipleChunkRetrieverQa, RetrieverBasedQaInput\n",
@@ -133,7 +135,9 @@
133135
"\n",
134136
"# customise the parameters of the index here\n",
135137
"index_configuration = IndexConfiguration(\n",
136-
" chunk_size=64, chunk_overlap=0, embedding_type=\"asymmetric\"\n",
138+
" chunk_size=64,\n",
139+
" chunk_overlap=0,\n",
140+
" embedding=SemanticEmbed(model_name=\"luminous-base\", representation=\"asymmetric\"),\n",
137141
")\n",
138142
"\n",
139143
"# create the namespace-wide index resource\n",
@@ -314,7 +318,10 @@
314318
"\n",
315319
"# customise the parameters of the index here\n",
316320
"index_configuration = IndexConfiguration(\n",
317-
" chunk_size=64, chunk_overlap=0, embedding_type=\"asymmetric\", hybrid_index=\"bm25\"\n",
321+
" chunk_size=64,\n",
322+
" chunk_overlap=0,\n",
323+
" hybrid_index=\"bm25\",\n",
324+
" embedding=SemanticEmbed(model_name=\"luminous-base\", representation=\"asymmetric\"),\n",
318325
")\n",
319326
"\n",
320327
"# create the namespace-wide index resource\n",
@@ -349,6 +356,46 @@
349356
"document_index_retriever.get_relevant_documents_with_scores(query=\"25 April\")"
350357
]
351358
},
359+
{
360+
"cell_type": "markdown",
361+
"metadata": {},
362+
"source": [
363+
"# Instructable Embeddings\n",
364+
"\n",
365+
"As well as supporting custom embedding models, the Document Index also supports instructable embeddings. This lets you prompt embedding models like `pharia-1-embedding-4608-control` with custom instructions for queries and documents. Steering the model like this can help the model understand nuances of your specific data and ultimately lead to embeddings that are more useful for your use-case. To use default instructions, leave the instruction fields unspecified.\n",
366+
"\n",
367+
"To use an instructable embedding model, create an index as follows."
368+
]
369+
},
370+
{
371+
"cell_type": "code",
372+
"execution_count": null,
373+
"metadata": {},
374+
"outputs": [],
375+
"source": [
376+
"# change this value if you want to use an index of a different name\n",
377+
"INSTRUCTABLE_EMBEDDING_INDEX = \"intelligence-layer-sdk-demo-instructable-embedding\"\n",
378+
"\n",
379+
"index_path = IndexPath(namespace=NAMESPACE, index=INSTRUCTABLE_EMBEDDING_INDEX)\n",
380+
"\n",
381+
"# customise the parameters of the index here\n",
382+
"index_configuration = IndexConfiguration(\n",
383+
" chunk_size=64,\n",
384+
" chunk_overlap=0,\n",
385+
" embedding=InstructableEmbed(\n",
386+
" model_name=\"pharia-1-embedding-4608-control\",\n",
387+
" query_instruction=\"Represent the user's question about rivers to find a relevant wikipedia paragraph\",\n",
388+
" document_instruction=\"Represent the document so that it can be matched to a user's question about rivers\",\n",
389+
" ),\n",
390+
")\n",
391+
"\n",
392+
"# create the namespace-wide index resource\n",
393+
"document_index.create_index(index_path, index_configuration)\n",
394+
"\n",
395+
"# assign the index to the collection\n",
396+
"document_index.assign_index_to_collection(collection_path, INSTRUCTABLE_EMBEDDING_INDEX)"
397+
]
398+
},
352399
{
353400
"cell_type": "markdown",
354401
"metadata": {},

src/intelligence_layer/connectors/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,12 @@
6363
from .document_index.document_index import Filters as Filters
6464
from .document_index.document_index import IndexConfiguration as IndexConfiguration
6565
from .document_index.document_index import IndexPath as IndexPath
66+
from .document_index.document_index import InstructableEmbed as InstructableEmbed
6667
from .document_index.document_index import InternalError as InternalError
6768
from .document_index.document_index import InvalidInput as InvalidInput
6869
from .document_index.document_index import ResourceNotFound as ResourceNotFound
6970
from .document_index.document_index import SearchQuery as SearchQuery
71+
from .document_index.document_index import SemanticEmbed as SemanticEmbed
7072
from .limited_concurrency_client import (
7173
AlephAlphaClientProtocol as AlephAlphaClientProtocol,
7274
)

src/intelligence_layer/connectors/document_index/document_index.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@
88
from urllib.parse import quote, urljoin
99

1010
import requests
11-
from pydantic import BaseModel, Field, field_validator, model_validator
11+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
1212
from pydantic.types import StringConstraints
1313
from requests import HTTPError
1414
from typing_extensions import Self
1515

1616
from intelligence_layer.connectors.base.json_serializable import JsonSerializable
1717

18-
EmbeddingType: TypeAlias = Literal["symmetric", "asymmetric"]
18+
Representation: TypeAlias = Literal["symmetric", "asymmetric"]
1919
HybridIndex: TypeAlias = Literal["bm25"] | None
20+
EmbeddingConfig: TypeAlias = Union["SemanticEmbed", "InstructableEmbed"]
2021

2122

2223
class IndexPath(BaseModel, frozen=True):
@@ -31,21 +32,60 @@ class IndexPath(BaseModel, frozen=True):
3132
index: str
3233

3334

35+
class SemanticEmbed(BaseModel):
36+
"""Semantic embedding configuration.
37+
38+
Args:
39+
model_name: Name of the model to use.
40+
representation: The embedding representation to use: "symmetric" or "asymmetric".
41+
Use "symmetric" when the queries and documents are the same, e.g., for classification tasks.
42+
Use "asymmetric" when the queries and documents are different, e.g., for search tasks.
43+
"""
44+
45+
# `model_name` conflicts with the default protected `model_*` namespace
46+
model_config = ConfigDict(protected_namespaces=())
47+
48+
strategy: Literal["semantic_embed"] = "semantic_embed"
49+
model_name: str
50+
representation: Representation
51+
52+
53+
class InstructableEmbed(BaseModel):
54+
"""Instructable embedding configuration.
55+
56+
Args:
57+
model_name: Name of the model to use.
58+
query_instruction: Instruction to apply when embedding queries.
59+
document_instruction: Instruction to apply when embedding documents.
60+
"""
61+
62+
# `model_name` conflicts with the default protected `model_*` namespace
63+
model_config = ConfigDict(protected_namespaces=())
64+
65+
strategy: Literal["instructable_embed"] = "instructable_embed"
66+
model_name: str
67+
query_instruction: str = ""
68+
document_instruction: str = ""
69+
70+
3471
class IndexConfiguration(BaseModel):
3572
"""Configuration of an index.
3673
3774
Args:
38-
embedding_type: "symmetric" or "asymmetric" embedding type.
3975
chunk_overlap: The maximum number of tokens of overlap between consecutive chunks. Must be
4076
less than `chunk_size`.
4177
chunk_size: The maximum size of the chunks in tokens to be used for the index.
4278
hybrid_index: If set to "bm25", combine vector search and keyword search (bm25) results.
79+
embedding: Configuration for the embedding of chunks.
4380
"""
4481

45-
embedding_type: EmbeddingType
82+
# `model_name` in `embedding` conflicts with the default protected `model_*` namespace
83+
model_config = ConfigDict(protected_namespaces=())
84+
4685
chunk_overlap: int = Field(default=0, ge=0)
4786
chunk_size: int = Field(..., gt=0, le=2046)
4887
hybrid_index: HybridIndex = None
88+
embedding: EmbeddingConfig
4989

5090
@model_validator(mode="after")
5191
def validate_chunk_overlap(self) -> Self:
@@ -535,8 +575,8 @@ def create_index(
535575
data = {
536576
"chunk_size": index_configuration.chunk_size,
537577
"chunk_overlap": index_configuration.chunk_overlap,
538-
"embedding_type": index_configuration.embedding_type,
539578
"hybrid_index": index_configuration.hybrid_index,
579+
"embedding": index_configuration.embedding.model_dump(),
540580
}
541581
response = requests.put(url, data=dumps(data), headers=self.headers)
542582
self._raise_for_status(response)
@@ -596,10 +636,10 @@ def index_configuration(self, index_path: IndexPath) -> IndexConfiguration:
596636
self._raise_for_status(response)
597637
response_json: Mapping[str, Any] = response.json()
598638
return IndexConfiguration(
599-
embedding_type=response_json["embedding_type"],
600639
chunk_overlap=response_json["chunk_overlap"],
601640
chunk_size=response_json["chunk_size"],
602641
hybrid_index=response_json.get("hybrid_index"),
642+
embedding=response_json["embedding"],
603643
)
604644

605645
def assign_index_to_collection(

tests/connectors/document_index/test_document_index.py

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import re
33
import string
44
from collections.abc import Callable, Iterator
5+
from contextlib import contextmanager
56
from datetime import datetime, timedelta, timezone
67
from functools import wraps
78
from http import HTTPStatus
@@ -19,16 +20,19 @@
1920
DocumentFilterQueryParams,
2021
DocumentIndexClient,
2122
DocumentPath,
22-
EmbeddingType,
23+
EmbeddingConfig,
2324
FilterField,
2425
FilterOps,
2526
Filters,
2627
HybridIndex,
2728
IndexConfiguration,
2829
IndexPath,
30+
InstructableEmbed,
2931
InvalidInput,
32+
Representation,
3033
ResourceNotFound,
3134
SearchQuery,
35+
SemanticEmbed,
3236
)
3337

3438
P = ParamSpec("P")
@@ -72,8 +76,12 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
7276
return decorator(func)
7377

7478

79+
def random_alphanumeric_string(length: int = 20) -> str:
80+
return "".join(random.choices(string.ascii_letters + string.digits, k=length))
81+
82+
7583
def random_identifier() -> str:
76-
name = "".join(random.choices(string.ascii_letters + string.digits, k=20))
84+
name = random_alphanumeric_string(20)
7785
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
7886
return f"ci-il-{name}-{timestamp}"
7987

@@ -92,6 +100,25 @@ def is_outdated_identifier(identifier: str, timestamp_threshold: datetime) -> bo
92100
return not timestamp > timestamp_threshold
93101

94102

103+
def random_semantic_embed() -> EmbeddingConfig:
104+
return SemanticEmbed(
105+
representation=random.choice(get_args(Representation)),
106+
model_name="luminous-base",
107+
)
108+
109+
110+
def random_instructable_embed() -> EmbeddingConfig:
111+
return InstructableEmbed(
112+
model_name="pharia-1-embedding-4608-control",
113+
query_instruction=random_alphanumeric_string(),
114+
document_instruction=random_alphanumeric_string(),
115+
)
116+
117+
118+
def random_embedding_config() -> EmbeddingConfig:
119+
return random.choice([random_semantic_embed(), random_instructable_embed()])
120+
121+
95122
@fixture(scope="session")
96123
def document_index_namespace() -> str:
97124
return "team-document-index"
@@ -203,24 +230,27 @@ def read_only_collection_path(
203230
document_index.delete_collection(collection_path)
204231

205232

206-
@fixture
207-
def random_index(
208-
document_index: DocumentIndexClient, document_index_namespace: str
233+
@contextmanager
234+
def random_index_with_embedding_config(
235+
document_index: DocumentIndexClient,
236+
document_index_namespace: str,
237+
embedding_config: EmbeddingConfig,
209238
) -> Iterator[tuple[IndexPath, IndexConfiguration]]:
210239
name = random_identifier()
240+
211241
chunk_size, chunk_overlap = sorted(
212242
random.sample([0, 32, 64, 128, 256, 512, 1024], 2), reverse=True
213243
)
214-
embedding_type = random.choice(get_args(EmbeddingType))
244+
215245
hybrid_index_choices: list[HybridIndex] = ["bm25", None]
216246
hybrid_index = random.choice(hybrid_index_choices)
217247

218248
index = IndexPath(namespace=document_index_namespace, index=name)
219249
index_configuration = IndexConfiguration(
220250
chunk_size=chunk_size,
221251
chunk_overlap=chunk_overlap,
222-
embedding_type=embedding_type,
223252
hybrid_index=hybrid_index,
253+
embedding=embedding_config,
224254
)
225255
try:
226256
document_index.create_index(index, index_configuration)
@@ -229,6 +259,26 @@ def random_index(
229259
document_index.delete_index(index)
230260

231261

262+
@fixture
263+
def random_instructable_index(
264+
document_index: DocumentIndexClient, document_index_namespace: str
265+
) -> Iterator[tuple[IndexPath, IndexConfiguration]]:
266+
with random_index_with_embedding_config(
267+
document_index, document_index_namespace, random_instructable_embed()
268+
) as index:
269+
yield index
270+
271+
272+
@fixture
273+
def random_semantic_index(
274+
document_index: DocumentIndexClient, document_index_namespace: str
275+
) -> Iterator[tuple[IndexPath, IndexConfiguration]]:
276+
with random_index_with_embedding_config(
277+
document_index, document_index_namespace, random_semantic_embed()
278+
) as index:
279+
yield index
280+
281+
232282
@fixture
233283
def document_contents() -> DocumentContents:
234284
text = """John Stith Pemberton, the inventor of the world-renowned beverage Coca-Cola, was a figure whose life was marked by creativity, entrepreneurial spirit, and the turbulent backdrop of 19th-century America. Born on January 8, 1831, in Knoxville, Georgia, Pemberton grew up in an era of profound transformation and change.
@@ -547,19 +597,31 @@ def test_document_path_is_immutable() -> None:
547597
def test_index_configuration_rejects_invalid_chunk_overlap() -> None:
548598
try:
549599
IndexConfiguration(
550-
chunk_size=128, chunk_overlap=128, embedding_type="asymmetric"
600+
chunk_size=128,
601+
chunk_overlap=128,
602+
embedding=random_embedding_config(),
551603
)
552604
except ValidationError as e:
553605
assert "chunk_overlap must be less than chunk_size" in str(e)
554606
else:
555607
raise AssertionError("ValidationError was not raised")
556608

557609

558-
def test_indexes_in_namespace_are_returned(
610+
def test_semantic_indexes_in_namespace_are_returned(
611+
document_index: DocumentIndexClient,
612+
random_semantic_index: tuple[IndexPath, IndexConfiguration],
613+
) -> None:
614+
index_path, index_configuration = random_semantic_index
615+
retrieved_index_configuration = document_index.index_configuration(index_path)
616+
617+
assert retrieved_index_configuration == index_configuration
618+
619+
620+
def test_instructable_indexes_in_namespace_are_returned(
559621
document_index: DocumentIndexClient,
560-
random_index: tuple[IndexPath, IndexConfiguration],
622+
random_instructable_index: tuple[IndexPath, IndexConfiguration],
561623
) -> None:
562-
index_path, index_configuration = random_index
624+
index_path, index_configuration = random_instructable_index
563625
retrieved_index_configuration = document_index.index_configuration(index_path)
564626

565627
assert retrieved_index_configuration == index_configuration

0 commit comments

Comments
 (0)