Skip to content

Commit bc6a8e6

Browse files
authored
Merge pull request #3833 from BrentOzarULTD/3670_sp_BlitzIndex_AI
3670 sp_BlitzIndex AI advice
2 parents bf8d932 + 9781676 commit bc6a8e6

2 files changed

Lines changed: 238 additions & 6 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,4 @@ x64/
132132
~$*
133133
~*.*
134134
/.vs
135+
/.claude

sp_BlitzIndex.sql

Lines changed: 237 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,11 @@ DECLARE
155155
@AIPayloadTemplate NVARCHAR(MAX),
156156
@AITimeoutSeconds TINYINT,
157157
@AIAdviceText NVARCHAR(MAX),
158-
@AIContext INT;
158+
@AIContext INT,
159+
@AIPayload NVARCHAR(MAX),
160+
@AIResponseJSON NVARCHAR(MAX),
161+
@AIReturnValue INT,
162+
@CurrentAIPrompt NVARCHAR(MAX);
159163

160164
/* If user was lazy and just used @ObjectName with a fully qualified table name, then lets parse out the various parts */
161165
SET @DatabaseName = COALESCE(@DatabaseName, PARSENAME(@ObjectName, 3)) /* 3 = Database name */
@@ -1110,11 +1114,21 @@ IF @AI > 0
11101114
SET @AITimeoutSeconds = 230;
11111115

11121116
IF @AISystemPrompt IS NULL OR @AISystemPrompt = N''
1113-
SET @AISystemPrompt = N'You are a very senior database developer working with Microsoft SQL Server and Azure SQL DB. You focus on real-world, actionable advice that will make a big difference, quickly. You value everyone''s time, and while you are friendly and courteous, you do not waste time with pleasantries or emoji because you work in a fast-paced corporate environment.
1117+
BEGIN
1118+
SET @AISystemPrompt = N'You are a very senior database developer working with Microsoft SQL Server and Azure SQL DB. You focus on real-world, actionable advice that will make a big difference, quickly. You value everyone''s time, and while you are friendly and courteous, you do not waste time with pleasantries or emoji because you work in a fast-paced corporate environment. Do not describe the table: you are working with other very senior database developers who understand SQL Server deeply, so get straight to the point with your recommendations and scripts.
1119+
1120+
You have been given the existing indexes, missing index suggestions from SQL Server, column data types, and foreign keys for a table. Your job is to recommend index changes: which indexes to add, which to remove as redundant or harmful, and which to modify. Focus on practical changes that will improve the most common query patterns shown by the usage statistics.
1121+
1122+
If indexes are not being used, drop them. If duplicate or near-duplicate indexes exist, merge them together or keep the widest ones. Existing indexes that start with different leading columns should not be considered duplicates.
11141123
1115-
You have a query that isn''t performing to end user expectations. You have been tasked with making serious improvements to it, quickly. You are not allowed to change server-level settings or make frivolous suggestions like updating statistics. Instead, you need to focus on query changes or index changes.
1124+
Include CREATE INDEX and DROP INDEX scripts. Include undo scripts in comments to back out your work if something goes wrong. Use the /* */ style for comments, not --, to make it easier for the customer to copy and paste your scripts without accidentally missing a line.
1125+
1126+
When working with missing index suggestions from SQL Server, keep in mind that they are ordered equality vs inequality search in the query, then by the column order of the table. The column order is nowhere near scientific, and can be rearranged if necessary for performance.
1127+
1128+
Focus only on nonclustered rowstore indexes. Do not suggest changes for clustered indexes, columnstore indexes, memory-optimized indexes, XML indexes, JSON indexes, or other specialized index types.
11161129
11171130
Do not offer followup options: the customer can only contact you once, so include all necessary information, tasks, and scripts in your initial reply. Render your output in Markdown, as it will be shown in plain text to the customer.';
1131+
END;
11181132

11191133
IF @AIModel LIKE 'gemini%' AND @AIPayloadTemplate IS NULL
11201134
SET @AIPayloadTemplate = N'{
@@ -3581,11 +3595,228 @@ BEGIN
35813595
OR (magic_benefit_number / CASE WHEN cd.create_days < @DaysUptime THEN cd.create_days ELSE @DaysUptime END) >= 100000)
35823596
ORDER BY magic_benefit_number DESC
35833597
OPTION ( RECOMPILE );
3584-
END;
3585-
ELSE
3598+
END;
3599+
ELSE
35863600
SELECT 'No missing indexes.' AS finding;
35873601

3588-
SELECT
3602+
/* @AskAI: Index analysis via AI provider */
3603+
IF @AI >= 1 AND @TableName IS NOT NULL
3604+
BEGIN
3605+
RAISERROR(N'Building AI prompt for index analysis', 0, 1) WITH NOWAIT;
3606+
3607+
/* Constitution lookup */
3608+
DECLARE @ai_constitution NVARCHAR(MAX) = NULL;
3609+
BEGIN TRY
3610+
SET @StringToExecute = N'SELECT @c = CAST(value AS NVARCHAR(MAX))
3611+
FROM ' + QUOTENAME(@DatabaseName) + N'.sys.extended_properties
3612+
WHERE class = 0 AND major_id = 0 AND minor_id = 0
3613+
AND name = N''CONSTITUTION.md'';';
3614+
EXEC sp_executesql @StringToExecute, N'@c NVARCHAR(MAX) OUTPUT', @c = @ai_constitution OUTPUT;
3615+
END TRY
3616+
BEGIN CATCH
3617+
/* If we can't read it (permissions, offline, etc), just skip. */
3618+
END CATCH;
3619+
3620+
/* Build the prompt header */
3621+
SET @CurrentAIPrompt = N'I need help analyzing the indexes on the table '
3622+
+ QUOTENAME(@DatabaseName) + N'.' + QUOTENAME(@SchemaName) + N'.' + QUOTENAME(@TableName)
3623+
+ N'.' + CHAR(13) + CHAR(10) + CHAR(13) + CHAR(10);
3624+
3625+
/* Prepend constitution if found */
3626+
IF @ai_constitution IS NOT NULL AND LEN(@ai_constitution) > 0
3627+
SET @CurrentAIPrompt = N'---' + CHAR(13) + CHAR(10)
3628+
+ N'This database has an extended property named CONSTITUTION.md that provides additional guidance for AI analysis. Here is the content of that property:' + CHAR(13) + CHAR(10)
3629+
+ N'---' + CHAR(13) + CHAR(10) + @ai_constitution + CHAR(13) + CHAR(10)
3630+
+ N'---' + CHAR(13) + CHAR(10) + @CurrentAIPrompt;
3631+
3632+
/* Section 1: Existing Indexes
3633+
Use FOR XML PATH to reliably concatenate all rows into a single string. */
3634+
SET @CurrentAIPrompt = @CurrentAIPrompt + N'EXISTING INDEXES:' + CHAR(13) + CHAR(10);
3635+
3636+
SET @CurrentAIPrompt = @CurrentAIPrompt + ISNULL((
3637+
SELECT
3638+
N'Index: ' + ISNULL(s.index_name, N'[HEAP]') + N' (IndexID: ' + CAST(s.index_id AS NVARCHAR(10)) + N')' + CHAR(13) + CHAR(10)
3639+
+ N' Type: ' + CASE s.index_id WHEN 0 THEN N'HEAP' WHEN 1 THEN N'CLUSTERED' ELSE N'NONCLUSTERED' END
3640+
+ CASE WHEN s.is_NC_columnstore = 1 THEN N' COLUMNSTORE' WHEN s.is_CX_columnstore = 1 THEN N' CLUSTERED COLUMNSTORE' ELSE N'' END + CHAR(13) + CHAR(10)
3641+
+ N' Key Columns: ' + ISNULL(s.key_column_names_with_sort_order, N'N/A') + CHAR(13) + CHAR(10)
3642+
+ N' Include Columns: ' + ISNULL(s.include_column_names, N'None') + CHAR(13) + CHAR(10)
3643+
+ CASE WHEN s.filter_definition <> N'' THEN N' Filter: ' + s.filter_definition + CHAR(13) + CHAR(10) ELSE N'' END
3644+
+ N' Is Primary Key: ' + CASE WHEN s.is_primary_key = 1 THEN N'Yes' ELSE N'No' END + CHAR(13) + CHAR(10)
3645+
+ N' Is Unique: ' + CASE WHEN s.is_unique = 1 THEN N'Yes' ELSE N'No' END + CHAR(13) + CHAR(10)
3646+
+ N' Is Disabled: ' + CASE WHEN s.is_disabled = 1 THEN N'Yes' ELSE N'No' END + CHAR(13) + CHAR(10)
3647+
+ N' Usage - Seeks: ' + CAST(s.user_seeks AS NVARCHAR(30)) + N', Scans: ' + CAST(s.user_scans AS NVARCHAR(30))
3648+
+ N', Lookups: ' + CAST(s.user_lookups AS NVARCHAR(30)) + N', Writes: ' + ISNULL(CAST(s.user_updates AS NVARCHAR(30)), N'0') + CHAR(13) + CHAR(10)
3649+
+ N' Rows: ' + ISNULL(CAST(sz.total_rows AS NVARCHAR(30)), N'N/A') + CHAR(13) + CHAR(10) + CHAR(13) + CHAR(10)
3650+
FROM #IndexSanity s
3651+
LEFT JOIN #IndexSanitySize sz ON s.index_sanity_id = sz.index_sanity_id
3652+
WHERE s.[object_id] = @ObjectID
3653+
ORDER BY s.index_id
3654+
FOR XML PATH(''), TYPE
3655+
).value('.', 'NVARCHAR(MAX)'), N'No indexes on this table (heap with no nonclustered indexes).' + CHAR(13) + CHAR(10) + CHAR(13) + CHAR(10));
3656+
3657+
/* Section 2: Missing Index Suggestions */
3658+
SET @CurrentAIPrompt = @CurrentAIPrompt + N'MISSING INDEX SUGGESTIONS FROM SQL SERVER:' + CHAR(13) + CHAR(10);
3659+
3660+
SET @CurrentAIPrompt = @CurrentAIPrompt + ISNULL((
3661+
SELECT
3662+
N'Equality Columns: ' + ISNULL(equality_columns_with_data_type, N'None') + CHAR(13) + CHAR(10)
3663+
+ N'Inequality Columns: ' + ISNULL(inequality_columns_with_data_type, N'None') + CHAR(13) + CHAR(10)
3664+
+ N'Include Columns: ' + ISNULL(included_columns_with_data_type, N'None') + CHAR(13) + CHAR(10)
3665+
+ N'Benefit Number: ' + CAST(magic_benefit_number AS NVARCHAR(30)) + CHAR(13) + CHAR(10)
3666+
+ N'User Seeks: ' + CAST(user_seeks AS NVARCHAR(30)) + N', User Scans: ' + CAST(user_scans AS NVARCHAR(30)) + CHAR(13) + CHAR(10)
3667+
+ N'Avg User Impact: ' + CAST(avg_user_impact AS NVARCHAR(30)) + N'%' + CHAR(13) + CHAR(10)
3668+
+ N'Create TSQL: ' + create_tsql + CHAR(13) + CHAR(10) + CHAR(13) + CHAR(10)
3669+
FROM #MissingIndexes
3670+
WHERE [object_id] = @ObjectID
3671+
ORDER BY magic_benefit_number DESC
3672+
FOR XML PATH(''), TYPE
3673+
).value('.', 'NVARCHAR(MAX)'), N'No missing index suggestions from SQL Server.' + CHAR(13) + CHAR(10) + CHAR(13) + CHAR(10));
3674+
3675+
/* Section 3: Column Data Types */
3676+
SET @CurrentAIPrompt = @CurrentAIPrompt + N'COLUMN DATA TYPES:' + CHAR(13) + CHAR(10);
3677+
3678+
SET @CurrentAIPrompt = @CurrentAIPrompt + ISNULL((
3679+
SELECT
3680+
N'Column: ' + column_name
3681+
+ N', Type: ' + system_type_name
3682+
+ CASE WHEN max_length = -1 THEN N'(max)'
3683+
WHEN system_type_name IN (N'char', N'varchar', N'binary', N'varbinary') THEN N'(' + CAST(max_length AS NVARCHAR(20)) + N')'
3684+
WHEN system_type_name IN (N'nchar', N'nvarchar') THEN N'(' + CAST(max_length / 2 AS NVARCHAR(20)) + N')'
3685+
WHEN system_type_name IN (N'decimal', N'numeric') THEN N'(' + CAST([precision] AS NVARCHAR(20)) + N',' + CAST([scale] AS NVARCHAR(20)) + N')'
3686+
ELSE N'' END
3687+
+ N', Nullable: ' + CASE WHEN is_nullable = 1 THEN N'Yes' ELSE N'No' END
3688+
+ N', Identity: ' + CASE WHEN is_identity = 1 THEN N'Yes' ELSE N'No' END
3689+
+ CHAR(13) + CHAR(10)
3690+
FROM #IndexColumns
3691+
WHERE [object_id] = @ObjectID
3692+
AND index_id IN (0, 1)
3693+
ORDER BY column_name
3694+
FOR XML PATH(''), TYPE
3695+
).value('.', 'NVARCHAR(MAX)'), N'');
3696+
3697+
/* Section 4: Foreign Keys */
3698+
SET @CurrentAIPrompt = @CurrentAIPrompt + CHAR(13) + CHAR(10) + N'FOREIGN KEYS:' + CHAR(13) + CHAR(10);
3699+
3700+
SET @CurrentAIPrompt = @CurrentAIPrompt + ISNULL((
3701+
SELECT
3702+
N'FK: ' + foreign_key_name + CHAR(13) + CHAR(10)
3703+
+ N' Parent: ' + parent_object_name + N' (' + parent_fk_columns + N')' + CHAR(13) + CHAR(10)
3704+
+ N' References: ' + referenced_object_name + N' (' + referenced_fk_columns + N')' + CHAR(13) + CHAR(10)
3705+
+ N' Disabled: ' + CASE WHEN is_disabled = 1 THEN N'Yes' ELSE N'No' END
3706+
+ N', Not Trusted: ' + CASE WHEN is_not_trusted = 1 THEN N'Yes' ELSE N'No' END + CHAR(13) + CHAR(10) + CHAR(13) + CHAR(10)
3707+
FROM #ForeignKeys
3708+
ORDER BY foreign_key_name
3709+
FOR XML PATH(''), TYPE
3710+
).value('.', 'NVARCHAR(MAX)'), N'No foreign keys on this table.' + CHAR(13) + CHAR(10));
3711+
3712+
/* Closing instruction */
3713+
SET @CurrentAIPrompt = @CurrentAIPrompt + CHAR(13) + CHAR(10)
3714+
+ N'Based on the above data, please provide index recommendations for this table. Consider which indexes are redundant, which missing indexes should be created, and whether the current indexing strategy is appropriate for the workload pattern shown by the usage statistics.';
3715+
3716+
/* @AI = 1: Call the AI provider */
3717+
IF @AI = 1
3718+
BEGIN
3719+
BEGIN TRY
3720+
SET @AIResponseJSON = NULL;
3721+
3722+
/* Build payload using the template */
3723+
SET @AIPayload = REPLACE(@AIPayloadTemplate, N'@AIModel', @AIModel);
3724+
SET @AIPayload = REPLACE(@AIPayload, N'@AISystemPrompt', REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(@AISystemPrompt, '\', '\\'), '"', '\"'), CHAR(13), '\r'), CHAR(10), '\n'), CHAR(9), '\t'));
3725+
SET @AIPayload = REPLACE(@AIPayload, N'@CurrentAIPrompt', REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(@CurrentAIPrompt, '\', '\\'), '"', '\"'), CHAR(13), '\r'), CHAR(10), '\n'), CHAR(9), '\t'));
3726+
3727+
IF @Debug = 2
3728+
SELECT @AIPayload AS AIPayload, LEN(@AIPayload) AS AIPayload_Length, DATALENGTH(@AIPayload) AS AIPayload_DataLength;
3729+
3730+
RAISERROR('Calling AI endpoint for index analysis', 0, 1) WITH NOWAIT;
3731+
3732+
EXEC @AIReturnValue = sp_invoke_external_rest_endpoint
3733+
@url = @AIURL,
3734+
@method = 'POST',
3735+
@payload = @AIPayload,
3736+
@headers = N'{"Content-Type":"application/json"}',
3737+
@credential = @AICredential,
3738+
@timeout = @AITimeoutSeconds,
3739+
@response = @AIResponseJSON OUTPUT;
3740+
3741+
IF @Debug = 2
3742+
PRINT N'API Response (first 4000 chars): ' + CHAR(13) + CHAR(10) + LEFT(ISNULL(@AIResponseJSON, N'NULL'), 4000);
3743+
3744+
/* Parse the response to extract the AI's advice */
3745+
IF @AIResponseJSON IS NOT NULL
3746+
BEGIN
3747+
/* Try OpenAI ChatGPT chat completion by default: */
3748+
SET @AIAdviceText = (SELECT c.Content
3749+
FROM OPENJSON(@AIResponseJSON, '$.result.choices')
3750+
WITH (
3751+
Content NVARCHAR(MAX) '$.message.content'
3752+
) AS c);
3753+
3754+
/* No data? How about Google Gemini: */
3755+
IF @AIAdviceText IS NULL
3756+
SET @AIAdviceText = (SELECT TOP 1 p.[text]
3757+
FROM OPENJSON(@AIResponseJSON, '$.result.candidates') AS cand
3758+
CROSS APPLY OPENJSON(cand.value, '$.content.parts')
3759+
WITH ([text] NVARCHAR(MAX) '$.text') AS p);
3760+
3761+
/* If we still couldn't parse it, check for error codes */
3762+
IF @AIAdviceText IS NULL
3763+
BEGIN
3764+
DECLARE @AIErrorMessage NVARCHAR(MAX);
3765+
SELECT @AIErrorMessage = JSON_VALUE(@AIResponseJSON, '$.result.error.message');
3766+
3767+
IF @AIErrorMessage IS NULL
3768+
SELECT @AIErrorMessage = JSON_VALUE(@AIResponseJSON, '$.error.message');
3769+
3770+
IF @AIErrorMessage IS NOT NULL
3771+
SET @AIAdviceText = N'API Error: ' + @AIErrorMessage;
3772+
ELSE
3773+
SET @AIAdviceText = N'Unable to parse API response. Raw response stored for debugging.';
3774+
END;
3775+
END
3776+
ELSE
3777+
BEGIN
3778+
SET @AIAdviceText = N'No response received from AI service.';
3779+
END;
3780+
3781+
END TRY
3782+
BEGIN CATCH
3783+
SET @AIAdviceText = N'Error calling AI service: ' + ERROR_MESSAGE();
3784+
3785+
IF @Debug = 1
3786+
PRINT @AIAdviceText;
3787+
END CATCH;
3788+
END
3789+
ELSE
3790+
BEGIN
3791+
/* @AI = 2: Just build the prompt, don't call AI */
3792+
SET @AIAdviceText = N'AI prompt generated but not sent (running with @AI = 2). Review the AI Prompt result set.';
3793+
END;
3794+
3795+
RAISERROR(N'Returning AI results', 0, 1) WITH NOWAIT;
3796+
3797+
/* Return advice, payload, and raw response when @AI = 1 */
3798+
IF @AI = 1
3799+
BEGIN
3800+
SELECT
3801+
[AI Advice] = CASE WHEN @AIAdviceText IS NULL THEN NULL ELSE (
3802+
SELECT @AIAdviceText AS [text()] FOR XML PATH('ai_advice'), TYPE) END,
3803+
[AI Prompt] = (SELECT (@AISystemPrompt + NCHAR(13) + NCHAR(10) + NCHAR(13) + NCHAR(10) + @CurrentAIPrompt)
3804+
AS [text()] FOR XML PATH('ai_prompt'), TYPE),
3805+
[AI Payload] = CASE WHEN @AIPayload IS NULL THEN NULL ELSE (
3806+
SELECT @AIPayload AS [text()] FOR XML PATH('ai_payload'), TYPE) END,
3807+
[AI Raw Response] = CASE WHEN @AIResponseJSON IS NULL THEN NULL ELSE (
3808+
SELECT @AIResponseJSON AS [text()] FOR XML PATH('ai_raw_response'), TYPE) END;
3809+
END
3810+
ELSE
3811+
BEGIN
3812+
SELECT [AI Prompt] = (
3813+
SELECT (@AISystemPrompt + NCHAR(13) + NCHAR(10) + NCHAR(13) + NCHAR(10) + @CurrentAIPrompt)
3814+
AS [text()] FOR XML PATH('ai_prompt'), TYPE);
3815+
END;
3816+
3817+
END; /* IF @AI >= 1 AND @TableName IS NOT NULL */
3818+
3819+
SELECT
35893820
column_name AS [Column Name],
35903821
(SELECT COUNT(*)
35913822
FROM #IndexColumns c2

0 commit comments

Comments
 (0)