diff --git a/scripts/deploy/deploy-azure.ps1 b/scripts/deploy/deploy-azure.ps1 index dfe421209..4e6e712bf 100644 --- a/scripts/deploy/deploy-azure.ps1 +++ b/scripts/deploy/deploy-azure.ps1 @@ -44,11 +44,15 @@ param( # SKU for the Azure App Service plan $WebAppServiceSku = "B1", - [ValidateSet("Volatile", "AzureCognitiveSearch", "Qdrant")] + [ValidateSet("Volatile", "AzureCognitiveSearch", "Qdrant", "Postgres")] [string] # What method to use to persist embeddings $MemoryStore = "AzureCognitiveSearch", + [SecureString] + # Password for the Postgres database + $SqlAdminPassword = "", + [switch] # Don't deploy Cosmos DB for chat storage - Use volatile memory instead $NoCosmosDb, @@ -87,6 +91,11 @@ if ($AIService -eq "OpenAI" -and !$AIApiKey) { exit 1 } +if ($MemoryStore -eq "Postgres" -and !$SqlAdminPassword) { + Write-Host "When MemoryStore is Postgres, SqlAdminPassword must be set" + exit 1 +} + $jsonConfig = " { `\`"webAppServiceSku`\`": { `\`"value`\`": `\`"$WebAppServiceSku`\`" }, @@ -97,7 +106,8 @@ $jsonConfig = " `\`"deployNewAzureOpenAI`\`": { `\`"value`\`": $(If ($DeployAzureOpenAI) {"true"} Else {"false"}) }, `\`"memoryStore`\`": { `\`"value`\`": `\`"$MemoryStore`\`" }, `\`"deployCosmosDB`\`": { `\`"value`\`": $(If (!($NoCosmosDb)) {"true"} Else {"false"}) }, - `\`"deploySpeechServices`\`": { `\`"value`\`": $(If (!($NoSpeechServices)) {"true"} Else {"false"}) } + `\`"deploySpeechServices`\`": { `\`"value`\`": $(If (!($NoSpeechServices)) {"true"} Else {"false"}) }, + `\`"sqlAdminPassword`\`": { `\`"value`\`": `\`"$(ConvertFrom-SecureString $SqlAdminPassword -AsPlainText)`\`" } } " diff --git a/scripts/deploy/deploy-azure.sh b/scripts/deploy/deploy-azure.sh index fa60aec93..09e704d6b 100755 --- a/scripts/deploy/deploy-azure.sh +++ b/scripts/deploy/deploy-azure.sh @@ -19,7 +19,8 @@ usage() { echo " -wr, --web-app-region WEB_APP_REGION Region to deploy to the static web app into. This must be a region that supports static web apps. (default: \"West US 2\")" echo " -a, --app-service-sku WEB_APP_SVC_SKU SKU for the Azure App Service plan (default: \"B1\")" echo " -ms, --memory-store Method to use to persist embeddings. Valid values are" - echo " \"AzureCognitiveSearch\" (default), \"Qdrant\" and \"Volatile\"" + echo " \"AzureCognitiveSearch\" (default), \"Qdrant\", \"Postgres\" and \"Volatile\"" + echo " -sap, --sql-admin-password Password for the PostgreSQL Server admin user" echo " -nc, --no-cosmos-db Don't deploy Cosmos DB for chat storage - Use volatile memory instead" echo " -ns, --no-speech-services Don't deploy Speech Services to enable speech as chat input" echo " -dd, --debug-deployment Switches on verbose template deployment output" @@ -84,6 +85,11 @@ while [[ $# -gt 0 ]]; do MEMORY_STORE=="$2" shift ;; + -sap|--sql-admin-password) + SQL_ADMIN_PASSWORD="$2" + shift + shift + ;; -nc|--no-cosmos-db) NO_COSMOS_DB=true shift @@ -152,6 +158,13 @@ if [[ "${AI_SERVICE_TYPE,,}" = "openai" ]] && [[ -z "$AI_SERVICE_KEY" ]]; then exit 1 fi +# If MEMORY_STORE is Postges, then SQL_ADMIN_PASSWORD is mandatory +if [[ "${MEMORY_STORE,,}" = "postgres" ]] && [[ -z "$SQL_ADMIN_PASSWORD" ]]; then + echo "When --memory-store is 'Postgres', --sql-admin-password must be set." + usage + exit 1 +fi + # If resource group is not set, then set it to rg-DEPLOYMENT_NAME if [ -z "$RESOURCE_GROUP" ]; then RESOURCE_GROUP="rg-${DEPLOYMENT_NAME}" @@ -187,6 +200,7 @@ JSON_CONFIG=$(cat << EOF "aiEndpoint": { "value": "$([ ! -z "$AI_ENDPOINT" ] && echo "$AI_ENDPOINT")" }, "deployNewAzureOpenAI": { "value": $([ "$NO_NEW_AZURE_OPENAI" = true ] && echo "false" || echo "true") }, "memoryStore": { "value": "$MEMORY_STORE" }, + "sqlAdminPassword": { "value": "$SQL_ADMIN_PASSWORD" }, "deployCosmosDB": { "value": $([ "$NO_COSMOS_DB" = true ] && echo "false" || echo "true") }, "deploySpeechServices": { "value": $([ "$NO_SPEECH_SERVICES" = true ] && echo "false" || echo "true") } } diff --git a/scripts/deploy/main.bicep b/scripts/deploy/main.bicep index 569ebc312..1703d7548 100644 --- a/scripts/deploy/main.bicep +++ b/scripts/deploy/main.bicep @@ -54,6 +54,7 @@ param deployCosmosDB bool = true 'Volatile' 'AzureCognitiveSearch' 'Qdrant' + 'Postgres' ]) param memoryStore string = 'Volatile' @@ -78,6 +79,10 @@ var uniqueName = '${name}-${rgIdHash}' @description('Name of the Azure Storage file share to create') var storageFileShareName = 'aciqdrantshare' +@description('PostgreSQL admin password') +@secure() +param sqlAdminPassword string = newGuid() + resource openAI 'Microsoft.CognitiveServices/accounts@2022-12-01' = if (deployNewAzureOpenAI) { name: 'ai-${uniqueName}' location: location @@ -250,6 +255,10 @@ resource appServiceWebConfig 'Microsoft.Web/sites/config@2022-09-01' = { name: 'MemoryStore:AzureCognitiveSearch:Key' value: memoryStore == 'AzureCognitiveSearch' ? azureCognitiveSearch.listAdminKeys().primaryKey : '' } + { + name: 'MemoryStore:Postgres:ConnectionString' + value: memoryStore == 'Postgres' ? 'Host=${postgreServerGroup.properties.serverNames[0].fullyQualifiedDomainName}:5432;Username=citus;Password=${sqlAdminPassword};Database=citus' : '' + } { name: 'AzureSpeech:Region' value: location @@ -501,6 +510,16 @@ resource virtualNetwork 'Microsoft.Network/virtualNetworks@2021-05-01' = { privateLinkServiceNetworkPolicies: 'Enabled' } } + { + name: 'postgresSubnet' + properties: { + addressPrefix: '10.0.3.0/24' + serviceEndpoints: [] + delegations: [] + privateEndpointNetworkPolicies: 'Disabled' + privateLinkServiceNetworkPolicies: 'Enabled' + } + } ] } } @@ -703,6 +722,76 @@ resource memorySourcesContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDataba } } +resource postgreServerGroup 'Microsoft.DBforPostgreSQL/serverGroupsv2@2022-11-08' = if (memoryStore == 'Postgres') { + name: 'pg-${uniqueName}' + location: location + properties: { + postgresqlVersion: '15' + administratorLoginPassword: sqlAdminPassword + enableHa: false + coordinatorVCores: 1 + coordinatorServerEdition: 'BurstableMemoryOptimized' + coordinatorStorageQuotaInMb: 32768 + nodeVCores: 4 + nodeCount: 0 + nodeStorageQuotaInMb: 524288 + nodeEnablePublicIpAccess: false + } +} + +resource postgresDNSZone 'Microsoft.Network/privateDnsZones@2020-06-01' = if (memoryStore == 'Postgres') { + name: 'privatelink.postgres.cosmos.azure.com' + location: 'global' +} + +resource postgresPrivateEndpoint 'Microsoft.Network/privateEndpoints@2023-04-01' = if (memoryStore == 'Postgres') { + name: 'pg-${uniqueName}-pe' + location: location + properties: { + subnet: { + id: virtualNetwork.properties.subnets[2].id + } + privateLinkServiceConnections: [ + { + name: 'postgres' + properties: { + privateLinkServiceId: postgreServerGroup.id + groupIds: [ + 'coordinator' + ] + } + } + ] + } +} + +resource postgresVirtualNetworkLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = if (memoryStore == 'Postgres') { + parent: postgresDNSZone + name: 'pg-${uniqueName}-vnl' + location: 'global' + properties: { + virtualNetwork: { + id: virtualNetwork.id + } + registrationEnabled: true + } +} + +resource postgresPrivateDnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2023-04-01' = if (memoryStore == 'Postgres') { + #disable-next-line use-parent-property + name: '${postgresPrivateEndpoint.name}/default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'postgres' + properties: { + privateDnsZoneId: postgresDNSZone.id + } + } + ] + } +} + resource speechAccount 'Microsoft.CognitiveServices/accounts@2022-12-01' = if (deploySpeechServices) { name: 'cog-${uniqueName}' location: location diff --git a/scripts/deploy/main.json b/scripts/deploy/main.json index 3e27026dd..7b2a045f5 100644 --- a/scripts/deploy/main.json +++ b/scripts/deploy/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.20.4.51522", - "templateHash": "15530074115758293206" + "templateHash": "1330294935556235998" } }, "parameters": { @@ -113,7 +113,8 @@ "allowedValues": [ "Volatile", "AzureCognitiveSearch", - "Qdrant" + "Qdrant", + "Postgres" ], "metadata": { "description": "What method to use to persist embeddings" @@ -146,6 +147,13 @@ "metadata": { "description": "Region for the webapp frontend" } + }, + "sqlAdminPassword": { + "type": "securestring", + "defaultValue": "[newGuid()]", + "metadata": { + "description": "PostgreSQL admin password" + } } }, "variables": { @@ -359,6 +367,10 @@ "name": "MemoryStore:AzureCognitiveSearch:Key", "value": "[if(equals(parameters('memoryStore'), 'AzureCognitiveSearch'), listAdminKeys(resourceId('Microsoft.Search/searchServices', format('acs-{0}', variables('uniqueName'))), '2022-09-01').primaryKey, '')]" }, + { + "name": "MemoryStore:Postgres:ConnectionString", + "value": "[if(equals(parameters('memoryStore'), 'Postgres'), format('Host={0}:5432;Username=citus;Password={1};Database=citus', reference(resourceId('Microsoft.DBforPostgreSQL/serverGroupsv2', format('pg-{0}', variables('uniqueName'))), '2022-11-08').serverNames[0].fullyQualifiedDomainName, parameters('sqlAdminPassword')), '')]" + }, { "name": "AzureSpeech:Region", "value": "[parameters('location')]" @@ -416,6 +428,7 @@ "[resourceId('Microsoft.Search/searchServices', format('acs-{0}', variables('uniqueName')))]", "[resourceId('Microsoft.DocumentDB/databaseAccounts', toLower(format('cosmos-{0}', variables('uniqueName'))))]", "[resourceId('Microsoft.CognitiveServices/accounts', format('ai-{0}', variables('uniqueName')))]", + "[resourceId('Microsoft.DBforPostgreSQL/serverGroupsv2', format('pg-{0}', variables('uniqueName')))]", "[resourceId('Microsoft.CognitiveServices/accounts', format('cog-{0}', variables('uniqueName')))]" ] }, @@ -636,6 +649,16 @@ "privateEndpointNetworkPolicies": "Disabled", "privateLinkServiceNetworkPolicies": "Enabled" } + }, + { + "name": "postgresSubnet", + "properties": { + "addressPrefix": "10.0.3.0/24", + "serviceEndpoints": [], + "delegations": [], + "privateEndpointNetworkPolicies": "Disabled", + "privateLinkServiceNetworkPolicies": "Enabled" + } } ] }, @@ -878,6 +901,96 @@ "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', toLower(format('cosmos-{0}', variables('uniqueName'))), 'CopilotChat')]" ] }, + { + "condition": "[equals(parameters('memoryStore'), 'Postgres')]", + "type": "Microsoft.DBforPostgreSQL/serverGroupsv2", + "apiVersion": "2022-11-08", + "name": "[format('pg-{0}', variables('uniqueName'))]", + "location": "[parameters('location')]", + "properties": { + "postgresqlVersion": "15", + "administratorLoginPassword": "[parameters('sqlAdminPassword')]", + "enableHa": false, + "coordinatorVCores": 1, + "coordinatorServerEdition": "BurstableMemoryOptimized", + "coordinatorStorageQuotaInMb": 32768, + "nodeVCores": 4, + "nodeCount": 0, + "nodeStorageQuotaInMb": 524288, + "nodeEnablePublicIpAccess": false + } + }, + { + "condition": "[equals(parameters('memoryStore'), 'Postgres')]", + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "privatelink.postgres.cosmos.azure.com", + "location": "global" + }, + { + "condition": "[equals(parameters('memoryStore'), 'Postgres')]", + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2023-04-01", + "name": "[format('pg-{0}-pe', variables('uniqueName'))]", + "location": "[parameters('location')]", + "properties": { + "subnet": { + "id": "[reference(resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName'))), '2021-05-01').subnets[2].id]" + }, + "privateLinkServiceConnections": [ + { + "name": "postgres", + "properties": { + "privateLinkServiceId": "[resourceId('Microsoft.DBforPostgreSQL/serverGroupsv2', format('pg-{0}', variables('uniqueName')))]", + "groupIds": [ + "coordinator" + ] + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.DBforPostgreSQL/serverGroupsv2', format('pg-{0}', variables('uniqueName')))]", + "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName')))]" + ] + }, + { + "condition": "[equals(parameters('memoryStore'), 'Postgres')]", + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2020-06-01", + "name": "[format('{0}/{1}', 'privatelink.postgres.cosmos.azure.com', format('pg-{0}-vnl', variables('uniqueName')))]", + "location": "global", + "properties": { + "virtualNetwork": { + "id": "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName')))]" + }, + "registrationEnabled": true + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', 'privatelink.postgres.cosmos.azure.com')]", + "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', variables('uniqueName')))]" + ] + }, + { + "condition": "[equals(parameters('memoryStore'), 'Postgres')]", + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2023-04-01", + "name": "[format('{0}/default', format('pg-{0}-pe', variables('uniqueName')))]", + "properties": { + "privateDnsZoneConfigs": [ + { + "name": "postgres", + "properties": { + "privateDnsZoneId": "[resourceId('Microsoft.Network/privateDnsZones', 'privatelink.postgres.cosmos.azure.com')]" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', 'privatelink.postgres.cosmos.azure.com')]", + "[resourceId('Microsoft.Network/privateEndpoints', format('pg-{0}-pe', variables('uniqueName')))]" + ] + }, { "condition": "[parameters('deploySpeechServices')]", "type": "Microsoft.CognitiveServices/accounts", diff --git a/webapi/CopilotChatWebApi.csproj b/webapi/CopilotChatWebApi.csproj index 0ce24a154..02de574b8 100644 --- a/webapi/CopilotChatWebApi.csproj +++ b/webapi/CopilotChatWebApi.csproj @@ -13,13 +13,22 @@ - - - - - - - + + + + + + + + @@ -80,4 +89,4 @@ PreserveNewest - + \ No newline at end of file diff --git a/webapi/Extensions/SemanticKernelExtensions.cs b/webapi/Extensions/SemanticKernelExtensions.cs index 034968784..a71a54689 100644 --- a/webapi/Extensions/SemanticKernelExtensions.cs +++ b/webapi/Extensions/SemanticKernelExtensions.cs @@ -17,11 +17,14 @@ using Microsoft.SemanticKernel.Connectors.AI.OpenAI.TextEmbedding; using Microsoft.SemanticKernel.Connectors.Memory.AzureCognitiveSearch; using Microsoft.SemanticKernel.Connectors.Memory.Chroma; +using Microsoft.SemanticKernel.Connectors.Memory.Postgres; using Microsoft.SemanticKernel.Connectors.Memory.Qdrant; using Microsoft.SemanticKernel.Memory; using Microsoft.SemanticKernel.Orchestration; using Microsoft.SemanticKernel.Skills.Core; using Microsoft.SemanticKernel.TemplateEngine; +using Npgsql; +using Pgvector.Npgsql; using static CopilotChat.WebApi.Options.MemoryStoreOptions; namespace CopilotChat.WebApi.Extensions; @@ -221,6 +224,25 @@ private static void AddSemanticTextMemory(this IServiceCollection services) }); break; + case MemoryStoreOptions.MemoryStoreType.Postgres: + if (config.Postgres == null) + { + throw new InvalidOperationException("MemoryStore type is Cosmos and Cosmos configuration is null."); + } + + var dataSourceBuilder = new NpgsqlDataSourceBuilder(config.Postgres.ConnectionString); + dataSourceBuilder.UseVector(); + + services.AddSingleton(sp => + { + return new PostgresMemoryStore( + dataSource: dataSourceBuilder.Build(), + vectorSize: config.Postgres.VectorSize + ); + }); + + break; + default: throw new InvalidOperationException($"Invalid 'MemoryStore' type '{config.Type}'."); } diff --git a/webapi/Options/MemoryStoreOptions.cs b/webapi/Options/MemoryStoreOptions.cs index a55da230a..de6b279ca 100644 --- a/webapi/Options/MemoryStoreOptions.cs +++ b/webapi/Options/MemoryStoreOptions.cs @@ -32,7 +32,12 @@ public enum MemoryStoreType /// /// Chroma DB persistent memory store. /// - Chroma + Chroma, + + /// + /// Cosmos DB persistent memory store. + /// + Postgres, } /// @@ -57,4 +62,10 @@ public enum MemoryStoreType /// [RequiredOnPropertyValue(nameof(Type), MemoryStoreType.AzureCognitiveSearch)] public AzureCognitiveSearchOptions? AzureCognitiveSearch { get; set; } + + /// + /// Gets or sets the configuration for the Cosmos memory store. + /// + [RequiredOnPropertyValue(nameof(Type), MemoryStoreType.Postgres)] + public PostgresOptions? Postgres { get; set; } } diff --git a/webapi/Options/PostgresOptions.cs b/webapi/Options/PostgresOptions.cs new file mode 100644 index 000000000..d131a16d1 --- /dev/null +++ b/webapi/Options/PostgresOptions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel.DataAnnotations; + +namespace CopilotChat.WebApi.Options; + +/// +/// Configuration settings for connecting to Postgres. +/// +public class PostgresOptions +{ + /// + /// Gets or sets the Postgres connection string. + /// + [Required, NotEmptyOrWhitespace] + public string ConnectionString { get; set; } = string.Empty; + + /// + /// Gets or sets the vector size. + /// + [Required, Range(1, int.MaxValue)] + public int VectorSize { get; set; } +} diff --git a/webapi/appsettings.json b/webapi/appsettings.json index c4b65326d..c336a24be 100644 --- a/webapi/appsettings.json +++ b/webapi/appsettings.json @@ -23,7 +23,7 @@ // Default AI service configuration for generating AI responses and embeddings from the user's input. // https://platform.openai.com/docs/guides/chat // To use Azure OpenAI as the AI completion service: - // - Set "Type" to "AzureOpenAI" + // - Set "Type" to "AzureOpenAI" // - Set "Endpoint" to the endpoint of your Azure OpenAI instance (e.g., "https://contoso.openai.azure.com") // - Set "Key" using dotnet's user secrets (see above) // (i.e. dotnet user-secrets set "AIService:Key" "MY_AZURE_OPENAI_KEY") @@ -53,12 +53,12 @@ // - Set Planner:Type to "Sequential" to enable the multi-step SequentialPlanner // Note: SequentialPlanner works best with `gpt-4`. See the "Enabling Sequential Planner" section in webapi/README.md for configuration instructions. // - Set Planner:Type to "Stepwise" to enable MRKL style planning - // - Set Planner:RelevancyThreshold to a decimal between 0 and 1.0. + // - Set Planner:RelevancyThreshold to a decimal between 0 and 1.0. // "Planner": { "Type": "Sequential", // The minimum relevancy score for a function to be considered. - // Set RelevancyThreshold to a value between 0 and 1 if using the SequentialPlanner or Stepwise planner with gpt-3.5-turbo. + // Set RelevancyThreshold to a value between 0 and 1 if using the SequentialPlanner or Stepwise planner with gpt-3.5-turbo. // Ignored when Planner:Type is "Action" "RelevancyThreshold": "0.25", // Whether to allow missing functions in the plan on creation then sanitize output. Functions are considered missing if they're not available in the planner's kernel's context. @@ -125,12 +125,14 @@ }, // // Memory stores are used for storing new memories and retrieving semantically similar memories. - // - Supported Types are "volatile", "qdrant", "azurecognitivesearch", or "chroma". + // - Supported Types are "volatile", "qdrant", "azurecognitivesearch", "postgres", or "chroma". // - When using Qdrant or Azure Cognitive Search, see ./README.md for deployment instructions. // - Set "MemoryStore:AzureCognitiveSearch:Key" using dotnet's user secrets (see above) // (i.e. dotnet user-secrets set "MemoryStore:AzureCognitiveSearch:Key" "MY_AZCOGSRCH_KEY") // - Set "MemoryStore:Qdrant:Key" using dotnet's user secrets (see above) if you are using a Qdrant Cloud instance. // (i.e. dotnet user-secrets set "MemoryStore:Qdrant:Key" "MY_QDRANTCLOUD_KEY") + // - Set "MemoryStore:Postgres:ConnectionString" using dotnet's user secrets (see above) if you are using a PostgreSQL database (or CosmosDB for PostgreSQL instance). + // (i.e. dotnet user-secrets set "MemoryStore:Postgres:ConnectionString" "MY_POSTGRES_CONNECTION_STRING") // "MemoryStore": { "Type": "volatile", @@ -147,6 +149,10 @@ "Chroma": { "Host": "http://localhost", "Port": "8000" + }, + "Postgres": { + "VectorSize": 1536 + // "ConnectionString": // dotnet user-secrets set "MemoryStore:Postgres:ConnectionString" "MY_POSTGRES_CONNECTION_STRING" } }, // @@ -170,7 +176,7 @@ // OCR support is used for allowing end users to upload images containing text in addition to text based documents. // - Supported Types are "none", "azureformrecognizer", "tesseract". // - When using Tesseract OCR Support (In order to upload image file formats such as png, jpg and tiff) - // - Obtain language data files here: https://github.com/tesseract-ocr/tessdata . + // - Obtain language data files here: https://github.com/tesseract-ocr/tessdata . // - Add these files to your `data` folder or the path specified in the "FilePath" property and set the "Copy to Output Directory" value to "Copy if newer". // - When using Azure Form Recognizer OCR Support // - Set "OcrSupport:AzureFormRecognizer:Key" using dotnet's user secrets (see above)