Skip to content

Commit fc18112

Browse files
authored
Fix Azure AI Search Embedding (#83)
1 parent 64f1b8e commit fc18112

13 files changed

Lines changed: 347 additions & 83 deletions

File tree

src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,5 @@ description: Initial standalone release notes for the CrestApps.Core repository.
7272
- distinguishes uploaded vision images from searchable documents in the shared document-availability prompt so multimodal chat sessions analyze supported attached images directly instead of defaulting to document-tool or metadata-only responses
7373
- caps the total uploaded vision-image bytes loaded into a single multimodal request through `ChatDocumentsOptions.MaxVisionInputBytesPerRequest`, removes the extra `MemoryStream` copy when attaching those images, and documents how to resolve a vision-capable chat client for direct image-description requests
7474
- adds `CrestApps.Core.PostgreSQL` and `CrestApps.Core.AI.PostgreSQL` packages providing a lightweight PostgreSQL + pgvector vector search backend as an alternative to Elasticsearch and Azure AI Search, registers the same keyed services (`ISearchIndexManager`, `ISearchDocumentManager`, `IDataSourceContentManager`, `IDataSourceDocumentReader`, `IODataFilterTranslator`) under the `"PostgreSQL"` provider name, supports `AddAIDocuments()`, `AddAIDataSources()`, and `AddAIMemory()` builder extensions, and integrates into both MVC and Blazor sample hosts
75+
- fixes hosted document and data-source indexing flows so background workers create a scoped service provider before resolving scoped indexing services, preventing upload-triggered failures and similar nightly alignment lifetime issues
76+
- standardizes Azure AI Search configuration on top-level `AuthenticationType`, `ApiKey`, `IdentityClientId`, and `IndexPrefix` settings under `CrestApps:AzureAISearch`, and refreshes the sample host / docs examples to list the full supported option set in one place

src/CrestApps.Core.Docs/docs/data-sources/azure-ai.md

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ builder.Services.AddCoreAzureAISearchServices();
2929
```json
3030
{
3131
"CrestApps": {
32-
"Search": {
33-
"AzureAISearch": {
34-
"Endpoint": "https://my-search.search.windows.net",
35-
"ApiKey": "your-admin-api-key"
36-
}
32+
"AzureAISearch": {
33+
"Endpoint": "https://my-search.search.windows.net",
34+
"AuthenticationType": "Default",
35+
"ApiKey": "",
36+
"IdentityClientId": "",
37+
"IndexPrefix": ""
3738
}
3839
}
3940
}
@@ -44,7 +45,10 @@ builder.Services.AddCoreAzureAISearchServices();
4445
| Property | Type | Description |
4546
|----------|------|-------------|
4647
| `Endpoint` | `string` | Azure AI Search endpoint URL |
47-
| `ApiKey` | `string` | Admin API key. If empty, uses `DefaultAzureCredential` |
48+
| `AuthenticationType` | `string` | Authentication mode. Supported values: `Default`, `ApiKey`, `ManagedIdentity` |
49+
| `ApiKey` | `string` | Admin API key. Required when `AuthenticationType` is `ApiKey` |
50+
| `IdentityClientId` | `string` | Optional managed identity client ID used by `DefaultAzureCredential` or `ManagedIdentityCredential` |
51+
| `IndexPrefix` | `string` | Optional prefix applied to framework-managed Azure AI Search index names |
4852

4953
## Services Registered (Keyed by `"AzureAISearch"`)
5054

@@ -74,8 +78,11 @@ Override `IAIDataSourceIndexingQueue` when you need a durable or distributed que
7478

7579
## Authentication
7680

77-
- **API Key** — Provide the `ApiKey` property
78-
- **Azure AD** — Leave `ApiKey` empty and the service uses `DefaultAzureCredential` (Managed Identity, VS credentials, etc.)
81+
Set `AuthenticationType` to one of these values:
82+
83+
- **`Default`** — Uses `DefaultAzureCredential`. This is the default when `AuthenticationType` is omitted or invalid.
84+
- **`ApiKey`** — Uses the admin API key from `ApiKey`.
85+
- **`ManagedIdentity`** — Uses `ManagedIdentityCredential`. Set `IdentityClientId` when you need a user-assigned managed identity.
7986

8087
## Azure Setup
8188

@@ -104,11 +111,12 @@ The simplest approach — provide the admin API key directly:
104111
```json title="appsettings.json"
105112
{
106113
"CrestApps": {
107-
"Search": {
108-
"AzureAISearch": {
109-
"Endpoint": "https://myapp-search.search.windows.net",
110-
"ApiKey": "your-admin-api-key"
111-
}
114+
"AzureAISearch": {
115+
"Endpoint": "https://myapp-search.search.windows.net",
116+
"AuthenticationType": "ApiKey",
117+
"ApiKey": "your-admin-api-key",
118+
"IdentityClientId": "",
119+
"IndexPrefix": ""
112120
}
113121
}
114122
}
@@ -125,10 +133,12 @@ Leave `ApiKey` empty and the service uses `DefaultAzureCredential`, which automa
125133
```json title="appsettings.json"
126134
{
127135
"CrestApps": {
128-
"Search": {
129-
"AzureAISearch": {
130-
"Endpoint": "https://myapp-search.search.windows.net"
131-
}
136+
"AzureAISearch": {
137+
"Endpoint": "https://myapp-search.search.windows.net",
138+
"AuthenticationType": "Default",
139+
"ApiKey": "",
140+
"IdentityClientId": "",
141+
"IndexPrefix": ""
132142
}
133143
}
134144
}
@@ -141,6 +151,28 @@ Leave `ApiKey` empty and the service uses `DefaultAzureCredential`, which automa
141151
3. **Visual Studio / VS Code credentials** (for local development)
142152
4. **Azure CLI** (`az login`)
143153

154+
If you want to prefer a specific user-assigned managed identity while still using `DefaultAzureCredential`, set `IdentityClientId`.
155+
156+
### Option 3: Managed Identity Only
157+
158+
Set `AuthenticationType` to `ManagedIdentity` when you want Azure AI Search to authenticate only with managed identity credentials:
159+
160+
```json title="appsettings.json"
161+
{
162+
"CrestApps": {
163+
"AzureAISearch": {
164+
"Endpoint": "https://myapp-search.search.windows.net",
165+
"AuthenticationType": "ManagedIdentity",
166+
"ApiKey": "",
167+
"IdentityClientId": "",
168+
"IndexPrefix": ""
169+
}
170+
}
171+
}
172+
```
173+
174+
Leave `IdentityClientId` empty for a system-assigned managed identity, or set it to the client ID of a user-assigned managed identity.
175+
144176
To use Managed Identity:
145177
1. Enable system-assigned managed identity on your Azure App Service.
146178
2. In the Azure AI Search resource, go to **Access Control (IAM)****Add role assignment**.
@@ -153,11 +185,12 @@ To use Managed Identity:
153185
```json
154186
{
155187
"CrestApps": {
156-
"Search": {
157-
"AzureAISearch": {
158-
"Endpoint": "https://myapp-search.search.windows.net",
159-
"ApiKey": "your-admin-api-key"
160-
}
188+
"AzureAISearch": {
189+
"Endpoint": "https://myapp-search.search.windows.net",
190+
"AuthenticationType": "Default",
191+
"ApiKey": "",
192+
"IdentityClientId": "",
193+
"IndexPrefix": ""
161194
}
162195
}
163196
}
@@ -168,7 +201,10 @@ To use Managed Identity:
168201
| Property | Type | Required | Default | Description |
169202
|----------|------|----------|---------|-------------|
170203
| `Endpoint` | `string` | Yes || Azure AI Search endpoint URL. Format: `https://{service-name}.search.windows.net` |
171-
| `ApiKey` | `string` | No || Admin API key. When empty, `DefaultAzureCredential` is used for authentication. |
204+
| `AuthenticationType` | `string` | No | `Default` | Supported values: `Default`, `ApiKey`, `ManagedIdentity`. |
205+
| `ApiKey` | `string` | No || Admin API key. Required when `AuthenticationType` is `ApiKey`. |
206+
| `IdentityClientId` | `string` | No || Optional managed identity client ID used by `DefaultAzureCredential` or `ManagedIdentityCredential`. |
207+
| `IndexPrefix` | `string` | No || Optional prefix applied to framework-managed index names. |
172208

173209
:::info
174210
When `Endpoint` is provided, the framework registers a `SearchIndexClient` singleton that all keyed services share. If `Endpoint` is empty or null, no client is registered and the data source is effectively disabled.
@@ -187,6 +223,10 @@ curl -H "api-key: your-admin-api-key" \
187223

188224
A successful response returns a JSON list of indexes (possibly empty).
189225

226+
:::info
227+
The framework writes embeddings into the `embedding` vector field for AI Documents, AI Memory, and AI Data Sources. In Azure Search Explorer, make sure the vector field is retrievable and clear **Hide vector values in search results** if you want the raw float array to appear in the portal response.
228+
:::
229+
190230
### 2. Verify from the Application
191231

192232
Inject `ISearchIndexManager` (keyed by `"AzureAISearch"`) and check if the connection is live:

src/CrestApps.Core.Docs/docs/data-sources/index.md

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -433,21 +433,22 @@ All six services must be registered with the same `providerName` key. The framew
433433

434434
## Configuration Guide
435435

436-
Data source backends are configured in `appsettings.json` under the `CrestApps:Search` section. Each backend has its own configuration section:
436+
Data source backends are configured in `appsettings.json` under the `CrestApps` section. Each backend has its own configuration subsection:
437437

438438
```json
439439
{
440440
"CrestApps": {
441-
"Search": {
442-
"Elasticsearch": {
443-
"Url": "https://localhost:9200",
444-
"Username": "elastic",
445-
"Password": "your-password"
446-
},
447-
"AzureAISearch": {
448-
"Endpoint": "https://my-search.search.windows.net",
449-
"ApiKey": "your-admin-api-key"
450-
}
441+
"Elasticsearch": {
442+
"Url": "https://localhost:9200",
443+
"Username": "elastic",
444+
"Password": "your-password"
445+
},
446+
"AzureAISearch": {
447+
"Endpoint": "https://my-search.search.windows.net",
448+
"AuthenticationType": "ApiKey",
449+
"ApiKey": "your-admin-api-key",
450+
"IdentityClientId": "",
451+
"IndexPrefix": ""
451452
}
452453
}
453454
}

src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -260,15 +260,7 @@ context.Resource is not AIProfile ||
260260
return [];
261261
}
262262

263-
if (!indexProfile.TryGet(out DataSourceIndexProfileMetadata metadata))
264-
{
265-
if (_logger.IsEnabled(LogLevel.Debug))
266-
{
267-
_logger.LogDebug("Unable to retrieve embedding configuration from index profile '{IndexProfileName}'.", settings.IndexProfileName);
268-
}
269-
270-
return [];
271-
}
263+
indexProfile.TryGet(out DataSourceIndexProfileMetadata metadata);
272264

273265
var deploymentName = metadata?.EmbeddingDeploymentName ?? indexProfile.EmbeddingDeploymentName;
274266

src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileProvisioningService.cs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ public async Task<ValidationResultDetails> CreateAsync(SearchIndexProfile profil
100100
profile.IndexFullName.SanitizeForLog(),
101101
profile.ProviderName.SanitizeForLog());
102102

103-
return Fail($"Unable to validate whether the remote index '{profile.IndexFullName}' already exists.", nameof(SearchIndexProfile.IndexName));
103+
return Fail(GetRemoteIndexValidationErrorMessage(profile, ex), nameof(SearchIndexProfile.IndexName));
104104
}
105105

106106
try
@@ -141,4 +141,41 @@ private static ValidationResultDetails Fail(string message, params string[] memb
141141

142142
return result;
143143
}
144+
145+
private static string GetRemoteIndexValidationErrorMessage(SearchIndexProfile profile, Exception ex)
146+
{
147+
if (TryGetRequestFailedStatusCode(ex, out var statusCode) && (statusCode == 401 || statusCode == 403))
148+
{
149+
return $"Unable to validate whether the remote index '{profile.IndexFullName}' already exists because the remote search provider rejected the configured credentials. Verify the endpoint and use credentials with index management permissions.";
150+
}
151+
152+
return $"Unable to validate whether the remote index '{profile.IndexFullName}' already exists.";
153+
}
154+
155+
private static bool TryGetRequestFailedStatusCode(Exception ex, out int statusCode)
156+
{
157+
statusCode = default;
158+
159+
var exceptionType = ex.GetType();
160+
if (!string.Equals(exceptionType.FullName, "Azure.RequestFailedException", StringComparison.Ordinal))
161+
{
162+
return false;
163+
}
164+
165+
var statusProperty = exceptionType.GetProperty("Status");
166+
if (statusProperty?.PropertyType != typeof(int))
167+
{
168+
return false;
169+
}
170+
171+
var value = statusProperty.GetValue(ex);
172+
if (value is not int typedStatusCode)
173+
{
174+
return false;
175+
}
176+
177+
statusCode = typedStatusCode;
178+
179+
return true;
180+
}
144181
}

src/Primitives/CrestApps.Core.AI/Services/AIDataSourceAlignmentBackgroundService.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,9 @@ private bool ShouldRunAlignment(out DateOnly runDateUtc)
9090
_lastRunDateUtc != runDateUtc;
9191
}
9292

93-
private async Task AlignDataSourcesAsync(IServiceProvider services, CancellationToken cancellationToken)
93+
private async Task AlignDataSourcesAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken)
9494
{
95-
var dataSourceStore = services.GetService<IAIDataSourceStore>();
95+
var dataSourceStore = serviceProvider.GetService<IAIDataSourceStore>();
9696
if (dataSourceStore == null)
9797
{
9898
if (_logger.IsEnabled(LogLevel.Trace))
@@ -112,7 +112,7 @@ private async Task AlignDataSourcesAsync(IServiceProvider services, Cancellation
112112
return;
113113
}
114114

115-
var indexingService = services.GetRequiredService<IAIDataSourceIndexingService>();
115+
var indexingService = serviceProvider.GetRequiredService<IAIDataSourceIndexingService>();
116116

117117
if (_logger.IsEnabled(LogLevel.Information))
118118
{
Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System;
2+
13
namespace CrestApps.Core.Azure.AISearch;
24

35
/// <summary>
@@ -6,19 +8,97 @@ namespace CrestApps.Core.Azure.AISearch;
68
/// </summary>
79
public sealed class AzureAISearchConnectionOptions
810
{
11+
/// <summary>
12+
/// The default Azure AI Search authentication type value.
13+
/// </summary>
14+
public const string DefaultAuthenticationType = "Default";
15+
16+
/// <summary>
17+
/// The API key Azure AI Search authentication type value.
18+
/// </summary>
19+
public const string ApiKeyAuthenticationType = "ApiKey";
20+
21+
/// <summary>
22+
/// The managed identity Azure AI Search authentication type value.
23+
/// </summary>
24+
public const string ManagedIdentityAuthenticationType = "ManagedIdentity";
25+
926
/// <summary>
1027
/// The Azure AI Search service endpoint (e.g. "https://my-search.search.windows.net").
1128
/// </summary>
1229
public string Endpoint { get; set; }
1330

1431
/// <summary>
1532
/// The admin API key used for authentication.
16-
/// When empty, <c>DefaultAzureCredential</c> is used instead.
33+
/// When empty, <c>DefaultAzureCredential</c> is used instead unless <see cref="AuthenticationType"/>
34+
/// explicitly requires API key authentication.
1735
/// </summary>
1836
public string ApiKey { get; set; }
1937

2038
/// <summary>
2139
/// Optional prefix applied to MVC-managed remote index names.
2240
/// </summary>
2341
public string IndexPrefix { get; set; }
42+
43+
/// <summary>
44+
/// Optional authentication mode.
45+
/// Supported values are <c>Default</c>, <c>ApiKey</c>, and <c>ManagedIdentity</c>.
46+
/// </summary>
47+
public string AuthenticationType { get; set; }
48+
49+
/// <summary>
50+
/// Optional managed identity client ID used when <c>DefaultAzureCredential</c> authenticates with Azure.
51+
/// </summary>
52+
public string IdentityClientId { get; set; }
53+
54+
/// <summary>
55+
/// Backward-compatible alias for <see cref="IndexPrefix"/>.
56+
/// </summary>
57+
public string IndexesPrefix { get; set; }
58+
59+
/// <summary>
60+
/// Gets the configured index prefix, including backward-compatible aliases.
61+
/// </summary>
62+
public string GetResolvedIndexPrefix()
63+
{
64+
if (!string.IsNullOrWhiteSpace(IndexPrefix))
65+
{
66+
return IndexPrefix;
67+
}
68+
69+
return IndexesPrefix;
70+
}
71+
72+
/// <summary>
73+
/// Gets a value indicating whether API key authentication was selected explicitly.
74+
/// </summary>
75+
public bool UsesApiKeyAuthentication()
76+
{
77+
return string.Equals(GetAuthenticationType(), ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase);
78+
}
79+
80+
/// <summary>
81+
/// Gets the configured authentication type.
82+
/// </summary>
83+
public string GetAuthenticationType()
84+
{
85+
if (string.IsNullOrWhiteSpace(AuthenticationType))
86+
{
87+
return DefaultAuthenticationType;
88+
}
89+
90+
var normalizedAuthenticationType = AuthenticationType.Trim();
91+
92+
if (string.Equals(normalizedAuthenticationType, ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase))
93+
{
94+
return ApiKeyAuthenticationType;
95+
}
96+
97+
if (string.Equals(normalizedAuthenticationType, ManagedIdentityAuthenticationType, StringComparison.OrdinalIgnoreCase))
98+
{
99+
return ManagedIdentityAuthenticationType;
100+
}
101+
102+
return DefaultAuthenticationType;
103+
}
24104
}

0 commit comments

Comments
 (0)