Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,7 @@
]
},
{
"condition": "(signalR != true && database != PostgreSQL && database != SqlServer)",
"condition": "(database != PostgreSQL && database != SqlServer)",
"exclude": [
"src/Server/Boilerplate.Server.Api/Services/ProductEmbeddingService.cs"
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ private void PrepareGridDataProvider()
var queriedRequest = productController.WithQuery(query.ToString());
var data = await (string.IsNullOrWhiteSpace(searchQuery)
? queriedRequest.GetProducts(req.CancellationToken)
: queriedRequest.GetProductsBySearchQuery(searchQuery, req.CancellationToken));
: queriedRequest.SearchProducts(searchQuery, req.CancellationToken));

return BitDataGridItemsProviderResult.From(data!.Items!, (int)data!.TotalCount);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@
<PackageVersion Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') OR ('$(database)' == 'SqlServer' OR '$(database)' == '') " Include="Microsoft.Extensions.AI" Version="9.9.0" />
<PackageVersion Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') OR ('$(database)' == 'SqlServer' OR '$(database)' == '') " Include="Microsoft.Extensions.AI.OpenAI" Version="9.9.0-preview.1.25458.4" />
<PackageVersion Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') OR ('$(database)' == 'SqlServer' OR '$(database)' == '') " Include="Microsoft.Extensions.AI.AzureAIInference" Version="9.9.0-preview.1.25458.4" />
<PackageVersion Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') OR ('$(database)' == 'SqlServer' OR '$(database)' == '') " Include="SmartComponents.LocalEmbeddings.SemanticKernel" Version="0.1.0-preview10148" />
<PackageVersion Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') OR ('$(database)' == 'SqlServer' OR '$(database)' == '') " Include="Microsoft.SemanticKernel.Core" Version="1.65.0" />
<PackageVersion Condition=" ('$(database)' == 'PostgreSQL' OR '$(database)' == '') " Include="Pgvector.EntityFrameworkCore" Version="0.2.2" />
<PackageVersion Condition="'$(module)' == 'Admin' OR '$(module)' == ''" Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Condition=" '$(appInsights)' == 'true' OR '$(appInsights)' == '' " Include="Microsoft.Extensions.Logging.ApplicationInsights" Version="2.23.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@
<PackageReference Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') OR ('$(database)' == 'SqlServer' OR '$(database)' == '') " Include="Microsoft.Extensions.AI" />
<PackageReference Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') OR ('$(database)' == 'SqlServer' OR '$(database)' == '') " Include="Microsoft.Extensions.AI.AzureAIInference" />
<PackageReference Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') OR ('$(database)' == 'SqlServer' OR '$(database)' == '') " Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') OR ('$(database)' == 'SqlServer' OR '$(database)' == '') " Include="Microsoft.SemanticKernel.Core" />
<PackageReference Condition=" ('$(signalR)' == 'true' OR '$(signalR)' == '') OR ('$(database)' == 'PostgreSQL' OR '$(database)' == '') OR ('$(database)' == 'SqlServer' OR '$(database)' == '') " Include="SmartComponents.LocalEmbeddings.SemanticKernel" />
<PackageReference Condition=" ('$(database)' == 'PostgreSQL' OR '$(database)' == '') " Include="Pgvector.EntityFrameworkCore" />

<Using Include="Microsoft.EntityFrameworkCore.Migrations" />
<Using Include="Microsoft.EntityFrameworkCore.Metadata.Builders" />
<Using Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public partial class ProductController : AppControllerBase, IProductController
//#if (signalR == true)
[AutoInject] private IHubContext<AppHub> appHubContext = default!;
//#endif
//#if (signalR == true || database == "PostgreSQL" || database == "SqlServer")
//#if (database == "PostgreSQL" || database == "SqlServer")
[AutoInject] private ProductEmbeddingService productEmbeddingService = default!;
//#endif
[AutoInject] private ResponseCacheService responseCacheService = default!;
Expand All @@ -47,10 +47,10 @@ public async Task<PagedResult<ProductDto>> GetProducts(ODataQueryOptions<Product
}

[HttpGet("{searchQuery}")]
public async Task<PagedResult<ProductDto>> GetProductsBySearchQuery(string searchQuery, ODataQueryOptions<ProductDto> odataQuery, CancellationToken cancellationToken)
public async Task<PagedResult<ProductDto>> SearchProducts(string searchQuery, ODataQueryOptions<ProductDto> odataQuery, CancellationToken cancellationToken)
{
//#if (database == "PostgreSQL" || database == "SqlServer")
var query = (IQueryable<ProductDto>)odataQuery.ApplyTo((await (productEmbeddingService.GetProductsBySearchQuery(searchQuery, cancellationToken))).Project(),
var query = (IQueryable<ProductDto>)odataQuery.ApplyTo((await (productEmbeddingService.SearchProducts(searchQuery, cancellationToken))).Project(),
ignoreQueryOptions: AllowedQueryOptions.Top | AllowedQueryOptions.Skip | AllowedQueryOptions.OrderBy /* Ordering can disrupt the results of the embedding service. */);
var totalCount = await query.LongCountAsync(cancellationToken);

Expand All @@ -59,9 +59,7 @@ public async Task<PagedResult<ProductDto>> GetProductsBySearchQuery(string searc

return new PagedResult<ProductDto>(await query.ToArrayAsync(cancellationToken), totalCount);
//#else
// Embedding based search is only implemented for PostgreSQL.
// Simply return whole products list.
return await GetProducts(odataQuery, cancellationToken);
throw new NotImplementedException(); // Embedding based search is only implemented for PostgreSQL and SQL Server only.
//#endif
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ These are the primary functional areas of the application beyond account managem
- If the user asks multiple questions, list them back to the user to confirm understanding, then address each one separately with clear headings. If needed, ask them to prioritize: ""I see you have multiple questions. Which issue would you like me to address first?""

- Never request sensitive information (e.g., passwords, PINs). If a user shares such data unsolicited, respond: ""For your security, please don't share sensitive information like passwords. Rest assured, your data is safe with us."" " +
//#if (module == 'Sales')
//#if (module == "Sales")
//#if (database == "PostgreSQL" || database == "SqlServer")
@"### Handling Car Recommendation Requests:
**[[[CAR_RECOMMENDATION_RULES_BEGIN]]]**
* **If a user asks for help choosing a car, for recommendations, or expresses purchase intent (e.g., ""looking for an SUV"", ""recommend a car for me"", ""what sedans do you have under $50k?""):**
Expand All @@ -195,6 +196,7 @@ These are the primary functional areas of the application beyond account managem
**[[[CAR_RECOMMENDATION_RULES_END]]]**
" +
//#endif
//#endif
//#if (ads == true)
@"### Handling advertisement trouble requests:
**[[[ADS_TROUBLE_RULES_BEGIN]]]""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ public void Configure(EntityTypeBuilder<Product> builder)
//#if (database == "PostgreSQL" || database == "SqlServer")
if (AppDbContext.IsEmbeddingEnabled)
{
builder.Property(p => p.Embedding).HasColumnType("vector(1536)"); // 1536 for text-embedding-3-small
builder.Property(p => p.Embedding).HasColumnType("vector(384)"); // Checkout appsettings.json's AI:EmbeddingOptions:Dimensions
//#if (database == "PostgreSQL")
builder.HasIndex(m => m.Embedding)
.HasMethod("hnsw") // ivfflat
.HasOperators("vector_cosine_ops");
//#endif
}
else
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
using System.IO.Compression;
//#if (signalR == true || database == "PostgreSQL" || database == "SqlServer")
using System.ClientModel.Primitives;
using Microsoft.SemanticKernel.Embeddings;
using SmartComponents.LocalEmbeddings.SemanticKernel;
//#endif
//#if (database == "Sqlite")
using Microsoft.Data.Sqlite;
Expand Down Expand Up @@ -67,7 +69,7 @@ public static void AddServerApiProjectServices(this WebApplicationBuilder builde
services.AddScoped<PhoneService>();
services.AddScoped<PhoneServiceJobsRunner>();
//#if (module == "Sales" || module == "Admin")
//#if (signalR == true || database == "PostgreSQL" || database == "SqlServer")
//#if (database == "PostgreSQL" || database == "SqlServer")
services.AddScoped<ProductEmbeddingService>();
//#endif
//#endif
Expand Down Expand Up @@ -434,6 +436,10 @@ void AddDbContext(DbContextOptionsBuilder options)
Endpoint = appSettings.AI.OpenAI.EmbeddingEndpoint,
Transport = new HttpClientPipelineTransport(sp.GetRequiredService<IHttpClientFactory>().CreateClient("AI"))
}).AsIEmbeddingGenerator())
.ConfigureOptions(options =>
{
configuration.GetRequiredSection("AI:EmbeddingOptions").Bind(options);
})
.UseLogging()
.UseOpenTelemetry();
// .UseDistributedCache()
Expand All @@ -446,10 +452,26 @@ void AddDbContext(DbContextOptionsBuilder options)
{
Transport = new Azure.Core.Pipeline.HttpClientTransport(sp.GetRequiredService<IHttpClientFactory>().CreateClient("AI"))
}).AsIEmbeddingGenerator(appSettings.AI.AzureOpenAI.EmbeddingModel))
.ConfigureOptions(options =>
{
configuration.GetRequiredSection("AI:EmbeddingOptions").Bind(options);
})
.UseLogging()
.UseOpenTelemetry();
// .UseDistributedCache()
}
else
{
services.AddEmbeddingGenerator(sp => new LocalTextEmbeddingGenerationService()
.AsEmbeddingGenerator())
.ConfigureOptions(options =>
{
configuration.GetRequiredSection("AI:EmbeddingOptions").Bind(options);
})
.UseLogging()
.UseOpenTelemetry();
// .UseDistributedCache()
}
//#endif

builder.Services.AddHangfire(configuration =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,98 +11,106 @@ namespace Boilerplate.Server.Api.Services;
/// 1- Simple string matching (e.g., `Contains` method).
/// 2- Full-text search using database capabilities (e.g., PostgreSQL's full-text search).
/// 3- Vector-based search using embeddings (e.g., using OpenAI's embeddings).
/// This service implements vector-based search using embeddings that has the following advantages:
/// - More accurate search results based on semantic meaning rather than just similarity matching.
/// - Multi-language support, as embeddings can capture the meaning of words across different languages.
/// And has the following disadvantages:
/// - Requires additional processing to generate embeddings for the text.
/// - Require more storage space for embeddings compared to simple text search.
/// The simple full-text search would be enough for product search case, but we have implemented the vector-based search to demonstrate how to use embeddings in the project.
/// 4- Hybrid approach combining full-text search and vector-based search.
/// The vector-based search is overkill for products search, but we implemented it here so you can see how to implement it in case you need it for other scenarios.
/// </summary>
public partial class ProductEmbeddingService
{
private const float SIMILARITY_THRESHOLD = 0.85f;
private const float DISTANCE_THRESHOLD = 0.65f;

[AutoInject] private AppDbContext dbContext = default!;
[AutoInject] private IWebHostEnvironment env = default!;
[AutoInject] private IServiceProvider serviceProvider = default!;
[AutoInject] private IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator = default!;

public async Task<IQueryable<Product>> GetProductsBySearchQuery(string searchQuery, CancellationToken cancellationToken)
public async Task<IQueryable<Product>> SearchProducts(string searchQuery, CancellationToken cancellationToken)
{
//#if (database != "PostgreSQL" && database != "SqlServer")
// The RAG has been implemented for PostgreSQL / SQL Server only. Check out https://github.com/bitfoundation/bitplatform/blob/develop/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/ProductEmbeddingService.cs
return dbContext.Products.Where(p => p.Name!.Contains(searchQuery) || p.Category!.Name!.Contains(searchQuery));
//#else
var embeddedUserQuery = await EmbedText(searchQuery, cancellationToken);
if (embeddedUserQuery is null)
return dbContext.Products.Where(p => p.Name!.Contains(searchQuery) || p.Category!.Name!.Contains(searchQuery));
if (AppDbContext.IsEmbeddingEnabled is false)
throw new InvalidOperationException("Embeddings are not enabled. Please enable them to use this feature.");

// It would be a good idea to try finding products using full-text search first, and if not enough results are found, then use the vector-based search.
// Note that test products data that have been seeded do not have embeddings, so searching for them will not return any results.

var embeddedSearchQuery = await embeddingGenerator.GenerateAsync(searchQuery, cancellationToken: cancellationToken);

//#if (database == "PostgreSQL")
var value = new Pgvector.Vector(embeddedUserQuery.Value);
var value = new Pgvector.Vector(embeddedSearchQuery.Vector);
//#else
//#if (IsInsideProjectTemplate == true)
/*
//#endif
var value = new Microsoft.Data.SqlTypes.SqlVector<float>(embeddedUserQuery.Value);
var value = new Microsoft.Data.SqlTypes.SqlVector<float>(embeddedSearchQuery.Vector);
//#if (IsInsideProjectTemplate == true)
*/
//#endif
//#endif
return dbContext.Products
//#if (database == "PostgreSQL")
.Where(p => p.Embedding!.CosineDistance(value!) < SIMILARITY_THRESHOLD).OrderBy(p => p.Embedding!.CosineDistance(value!));
.Where(p => p.Embedding!.CosineDistance(value!) < DISTANCE_THRESHOLD).OrderBy(p => p.Embedding!.CosineDistance(value!));
//#elif (database == "SqlServer")
//#if (IsInsideProjectTemplate == true)
/*
//#endif
.Where(p => p.Embedding.HasValue && EF.Functions.VectorDistance("cosine", p.Embedding.Value, value) < SIMILARITY_THRESHOLD).OrderBy(p => EF.Functions.VectorDistance("cosine", p.Embedding!.Value, value!));
.Where(p => p.Embedding.HasValue && EF.Functions.VectorDistance("cosine", p.Embedding.Value, value) < DISTANCE_THRESHOLD).OrderBy(p => EF.Functions.VectorDistance("cosine", p.Embedding!.Value, value!));
//#if (IsInsideProjectTemplate == true)
*/
//#endif
//#endif
//#endif
}

public async Task Embed(Product product, CancellationToken cancellationToken)
{
//#if (database != "PostgreSQL" && database != "SqlServer")
return; // The RAG has been implemented for PostgreSQL / SQL Server only.
//#else
await dbContext.Entry(product).Reference(p => p.Category).LoadAsync(cancellationToken);
if (AppDbContext.IsEmbeddingEnabled is false)
throw new InvalidOperationException("Embeddings are not enabled. Please enable them to use this feature.");

List<(string text, float weight)> inputs = [];

// TODO: Needs to be improved.
var embedding = await EmbedText($@"
Name: **{product.Name}**
Manufacture: **{product.Category!.Name}**
Description: {product.DescriptionText}
Appearance: {product.PrimaryImageAltText}", cancellationToken);
await dbContext.Entry(product)
.Reference(p => p.Category)
.LoadAsync(cancellationToken);

if (embedding.HasValue)
inputs.Add(($"Id: {product.ShortId}", 0.9f));
inputs.Add(($"Name: {product.Name}", 0.9f));
if (string.IsNullOrEmpty(product.DescriptionText) is false)
{
product.Embedding = new(embedding.Value);
inputs.Add((product.DescriptionText, 0.7f));
}
//#endif
}
if (string.IsNullOrEmpty(product.PrimaryImageAltText) is false)
{
inputs.Add((product.PrimaryImageAltText, 0.5f));
}
inputs.Add((product.Category!.Name!, 0.9f));

private async Task<ReadOnlyMemory<float>?> EmbedText(string input, CancellationToken cancellationToken)
{
//#if (database != "PostgreSQL" && database != "SqlServer")
return null; // The RAG has been implemented for PostgreSQL / SQL Server only.
//#else
if (AppDbContext.IsEmbeddingEnabled is false)
return null;
var embeddingGenerator = serviceProvider.GetService<IEmbeddingGenerator<string, Embedding<float>>>();
if (embeddingGenerator is null)
return env.IsDevelopment() ? null : throw new InvalidOperationException("Embedding generator is not registered.");
var texts = inputs.Select(i => i.text).ToArray();

input = $@"
Name: **{input}**
Manufacture: **{input}**
Description: {input}
Appearance: {input}";
var embeddingsResponse = await embeddingGenerator.GenerateAsync(texts, cancellationToken: cancellationToken);

var vectors = embeddingsResponse.Select(e => e.Vector.ToArray()).ToArray();
var weights = inputs.Select(t => t.weight).ToArray();

var embedding = await embeddingGenerator.GenerateVectorAsync(input, options: new() { }, cancellationToken);
return embedding.ToArray();
//#endif
if (vectors.Any(v => v.Length != vectors[0].Length))
{
throw new InvalidOperationException("All embedding vectors must have the same length.");
}

var embedding = new float[vectors[0].Length];
for (int i = 0; i < embedding.Length; i++)
{
embedding[i] = 0f;
for (int j = 0; j < vectors.Length; j++)
{
embedding[i] += weights[j] * vectors[j][i];
}
}

// L2 normalize the embedding for cosine distance stability
float norm = (float)Math.Sqrt(embedding.Sum(v => v * v));
if (norm > 0)
{
for (int i = 0; i < embedding.Length; i++)
{
embedding[i] /= norm;
}
}

product.Embedding = new(embedding);
}
}
Loading
Loading