Skip to content

Commit 6c1be4e

Browse files
fix(search,core): Skip vector indexing when no embedding profile is configured
Add HasDefaultProfileAsync to IAIProfileService so callers can check for a configured default profile without catching exceptions. Refactors GetDefaultProfileAsync to share resolution logic via TryGetDefaultProfileAsync. AIVectorIndexer now checks for a default embedding profile before attempting to index. On a fresh install with no profile configured, indexing is skipped with a debug log instead of throwing InvalidOperationException and cascading into SQLite errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 65120a9 commit 6c1be4e

4 files changed

Lines changed: 63 additions & 8 deletions

File tree

Umbraco.AI.Search/src/Umbraco.AI.Search.Core/Search/AIVectorIndexer.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
using Microsoft.Extensions.Options;
44

55
using Umbraco.AI.Core.Embeddings;
6+
using Umbraco.AI.Core.Models;
7+
using Umbraco.AI.Core.Profiles;
68
using Umbraco.AI.Search.Core.Chunking;
79
using Umbraco.AI.Search.Core.Configuration;
810
using Umbraco.AI.Search.Core.VectorStore;
@@ -23,19 +25,22 @@ namespace Umbraco.AI.Search.Core.Search;
2325
public sealed class AIVectorIndexer : IIndexer
2426
{
2527
private readonly IAIVectorStore _vectorStore;
28+
private readonly IAIProfileService _profileService;
2629
private readonly IAIEmbeddingService _embeddingService;
2730
private readonly IAITextChunker _textChunker;
2831
private readonly IOptions<AIVectorSearchOptions> _options;
2932
private readonly ILogger<AIVectorIndexer> _logger;
3033

3134
public AIVectorIndexer(
3235
IAIVectorStore vectorStore,
36+
IAIProfileService profileService,
3337
IAIEmbeddingService embeddingService,
3438
IAITextChunker textChunker,
3539
IOptions<AIVectorSearchOptions> options,
3640
ILogger<AIVectorIndexer> logger)
3741
{
3842
_vectorStore = vectorStore;
43+
_profileService = profileService;
3944
_embeddingService = embeddingService;
4045
_textChunker = textChunker;
4146
_options = options;
@@ -51,6 +56,14 @@ public async Task AddOrUpdateAsync(
5156
IEnumerable<IndexField> fields,
5257
ContentProtection? protection)
5358
{
59+
// On a fresh install, no embedding profile exists yet — skip indexing
60+
// until the administrator configures one.
61+
if (!await _profileService.HasDefaultProfileAsync(AICapability.Embedding))
62+
{
63+
_logger.LogDebug("No default embedding profile configured, skipping vector indexing for {IndexAlias}", indexAlias);
64+
return;
65+
}
66+
5467
var documentId = id.ToString("D");
5568

5669
// Group fields by culture to generate separate embeddings per language variant.

Umbraco.AI.Search/tests/Umbraco.AI.Search.Tests.Unit/Search/AIVectorIndexerTests.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
using Moq;
55
using Shouldly;
66
using Umbraco.AI.Core.Embeddings;
7+
using Umbraco.AI.Core.Models;
8+
using Umbraco.AI.Core.Profiles;
79
using Umbraco.AI.Search.Core.Chunking;
810
using Umbraco.AI.Search.Core.Configuration;
911
using Umbraco.AI.Search.Core.Search;
@@ -19,6 +21,7 @@ public class AIVectorIndexerTests
1921
private const string IndexAlias = "test-index";
2022

2123
private readonly InMemoryAIVectorStore _store = new();
24+
private readonly Mock<IAIProfileService> _profileServiceMock = new();
2225
private readonly Mock<IAIEmbeddingService> _embeddingServiceMock = new();
2326
private readonly Mock<IAITextChunker> _chunkerMock = new();
2427
private readonly AIVectorIndexer _indexer;
@@ -46,8 +49,14 @@ public AIVectorIndexerTests()
4649
return new GeneratedEmbeddings<Embedding<float>>(embeddings);
4750
});
4851

52+
// Default: embedding profile is configured
53+
_profileServiceMock
54+
.Setup(p => p.HasDefaultProfileAsync(AICapability.Embedding, It.IsAny<CancellationToken>()))
55+
.ReturnsAsync(true);
56+
4957
_indexer = new AIVectorIndexer(
5058
_store,
59+
_profileServiceMock.Object,
5160
_embeddingServiceMock.Object,
5261
_chunkerMock.Object,
5362
options,

Umbraco.AI/src/Umbraco.AI.Core/Profiles/AIProfileService.cs

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,40 @@ public async Task<IEnumerable<AIProfile>> GetProfilesAsync(
6060
CancellationToken cancellationToken = default)
6161
=> _repository.GetPagedAsync(filter, capability, skip, take, cancellationToken);
6262

63+
public async Task<bool> HasDefaultProfileAsync(
64+
AICapability capability,
65+
CancellationToken cancellationToken = default)
66+
{
67+
var profile = await TryGetDefaultProfileAsync(capability, cancellationToken);
68+
return profile is not null;
69+
}
70+
6371
public async Task<AIProfile> GetDefaultProfileAsync(
6472
AICapability capability,
6573
CancellationToken cancellationToken = default)
74+
{
75+
var profile = await TryGetDefaultProfileAsync(capability, cancellationToken);
76+
if (profile is not null)
77+
{
78+
return profile;
79+
}
80+
81+
// Produce a specific error message depending on what's missing
82+
var alias = capability switch
83+
{
84+
AICapability.Chat => _options.DefaultChatProfileAlias,
85+
AICapability.Embedding => _options.DefaultEmbeddingProfileAlias,
86+
_ => null
87+
};
88+
89+
throw alias is not null
90+
? new InvalidOperationException($"Default {capability} profile with alias '{alias}' not found.")
91+
: new InvalidOperationException($"Default {capability} profile is not configured.");
92+
}
93+
94+
private async Task<AIProfile?> TryGetDefaultProfileAsync(
95+
AICapability capability,
96+
CancellationToken cancellationToken)
6697
{
6798
// 1. Try database settings first
6899
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
@@ -92,16 +123,10 @@ public async Task<AIProfile> GetDefaultProfileAsync(
92123

93124
if (defaultProfileAlias is null)
94125
{
95-
throw new InvalidOperationException($"Default {capability} profile is not configured.");
96-
}
97-
98-
var profileByAlias = await _repository.GetByAliasAsync(defaultProfileAlias, cancellationToken);
99-
if (profileByAlias is null)
100-
{
101-
throw new InvalidOperationException($"Default {capability} profile with alias '{defaultProfileAlias}' not found.");
126+
return null;
102127
}
103128

104-
return profileByAlias;
129+
return await _repository.GetByAliasAsync(defaultProfileAlias, cancellationToken);
105130
}
106131

107132
public async Task<AIProfile> GetClassifierProfileAsync(

Umbraco.AI/src/Umbraco.AI.Core/Profiles/IAIProfileService.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ public interface IAIProfileService
5555
int take = 100,
5656
CancellationToken cancellationToken = default);
5757

58+
/// <summary>
59+
/// Checks whether a default profile is configured and exists for the specified capability.
60+
/// </summary>
61+
/// <param name="capability">The capability.</param>
62+
/// <param name="cancellationToken">Cancellation token.</param>
63+
/// <returns>True if a default profile is configured and exists, false otherwise.</returns>
64+
Task<bool> HasDefaultProfileAsync(AICapability capability, CancellationToken cancellationToken = default);
65+
5866
/// <summary>
5967
/// Gets the default profile for the specified capability.
6068
/// </summary>

0 commit comments

Comments
 (0)