feat(backend): add semantic matchmaking with embedding pipeline

Add a pluggable embedding provider with an offline deterministic default, vector indexing for problems and innovations, cosine semantic ranking, similar cases and solution suggestions, area filtering and an admin reindex endpoint. Refresh the index when innovations or submissions change.
This commit is contained in:
Rafał Tyński
2026-10-04 02:44:03 +02:00
parent 2bc083fb95
commit 25ce901021
13 changed files with 611 additions and 2 deletions
@@ -0,0 +1,19 @@
using TarninaHub.Application.Dtos;
namespace TarninaHub.Application.Abstractions;
public interface IMatchmakingService
{
Task IndexInnovationAsync(string innovationId, CancellationToken cancellationToken);
Task IndexProblemAsync(string problemId, CancellationToken cancellationToken);
Task IndexSubmissionAsync(string submissionId, CancellationToken cancellationToken);
Task<int> ReindexAsync(CancellationToken cancellationToken);
Task<MatchResponse?> MatchAsync(
string submissionId,
MatchRequest request,
CancellationToken cancellationToken);
}
@@ -0,0 +1,23 @@
namespace TarninaHub.Application.Dtos;
public sealed record MatchRequest(
int? MaxResults,
string? ChallengeId);
public sealed record SimilarCaseItem(
string Id,
string Title,
double Score);
public sealed record SolutionItem(
string InnovationId,
string Title,
string Stage,
string ChallengeId,
double Score,
string Rationale);
public sealed record MatchResponse(
IReadOnlyList<SimilarCaseItem> SimilarCases,
IReadOnlyList<SolutionItem> Solutions,
bool Fallback);
@@ -0,0 +1,7 @@
namespace TarninaHub.Application.Matchmaking;
public static class EmbeddingConstants
{
/// <summary>Embedding dimension; must match the HNSW index definition.</summary>
public const int Dimension = 1536;
}
@@ -0,0 +1,103 @@
using System.Text;
namespace TarninaHub.Application.Matchmaking;
/// <summary>
/// Deterministic, offline embedding provider (feature hashing). Produces
/// L2-normalized vectors so cosine similarity reflects shared vocabulary.
/// Interchangeable with a real LLM provider behind <see cref="IEmbeddingProvider"/>
/// (see ADR-0005). Used as the default until an external provider is wired.
/// </summary>
public sealed class HashingEmbeddingProvider : IEmbeddingProvider
{
public int Dimension => EmbeddingConstants.Dimension;
public Task<float[]> EmbedAsync(string text, CancellationToken cancellationToken)
{
return Task.FromResult(Embed(text));
}
public Task<IReadOnlyList<float[]>> EmbedBatchAsync(
IReadOnlyList<string> texts,
CancellationToken cancellationToken)
{
IReadOnlyList<float[]> result = texts.Select(Embed).ToList();
return Task.FromResult(result);
}
public float[] Embed(string text)
{
var vector = new float[Dimension];
if (string.IsNullOrWhiteSpace(text))
{
return vector;
}
foreach (var token in Tokenize(text))
{
var index = (int)(Fnv1a(token) % Dimension);
vector[index] += 1f;
}
Normalize(vector);
return vector;
}
private static IEnumerable<string> Tokenize(string text)
{
var buffer = new StringBuilder();
foreach (var character in text)
{
if (char.IsLetterOrDigit(character))
{
buffer.Append(char.ToLowerInvariant(character));
}
else if (buffer.Length > 0)
{
yield return buffer.ToString();
buffer.Clear();
}
}
if (buffer.Length > 0)
{
yield return buffer.ToString();
}
}
private static void Normalize(float[] vector)
{
double sumOfSquares = vector.Sum(value => (double)value * value);
var magnitude = Math.Sqrt(sumOfSquares);
if (magnitude <= 0)
{
return;
}
for (var i = 0; i < vector.Length; i++)
{
vector[i] = (float)(vector[i] / magnitude);
}
}
private static uint Fnv1a(string token)
{
const uint offset = 2166136261;
const uint prime = 16777619;
var hash = offset;
foreach (var character in token)
{
hash ^= character;
hash *= prime;
}
return hash;
}
}
@@ -0,0 +1,12 @@
namespace TarninaHub.Application.Matchmaking;
public interface IEmbeddingProvider
{
int Dimension { get; }
Task<float[]> EmbedAsync(string text, CancellationToken cancellationToken);
Task<IReadOnlyList<float[]>> EmbedBatchAsync(
IReadOnlyList<string> texts,
CancellationToken cancellationToken);
}
@@ -0,0 +1,20 @@
namespace TarninaHub.Application.Matchmaking;
public sealed record RankedItem<T>(T Item, double Score);
public static class SemanticRanker
{
public static IReadOnlyList<RankedItem<T>> Rank<T>(
IReadOnlyList<float> queryVector,
IEnumerable<(T Item, IReadOnlyList<float> Vector)> candidates,
int topK,
double minScore = 0)
{
return candidates
.Select(candidate => new RankedItem<T>(candidate.Item, VectorMath.CosineSimilarity(queryVector, candidate.Vector)))
.Where(ranked => ranked.Score >= minScore)
.OrderByDescending(ranked => ranked.Score)
.Take(topK)
.ToList();
}
}
@@ -0,0 +1,30 @@
namespace TarninaHub.Application.Matchmaking;
public static class VectorMath
{
public static double CosineSimilarity(IReadOnlyList<float> a, IReadOnlyList<float> b)
{
if (a.Count == 0 || a.Count != b.Count)
{
return 0;
}
double dot = 0;
double magnitudeA = 0;
double magnitudeB = 0;
for (var i = 0; i < a.Count; i++)
{
dot += (double)a[i] * b[i];
magnitudeA += (double)a[i] * a[i];
magnitudeB += (double)b[i] * b[i];
}
if (magnitudeA <= 0 || magnitudeB <= 0)
{
return 0;
}
return dot / (Math.Sqrt(magnitudeA) * Math.Sqrt(magnitudeB));
}
}
+10 -1
View File
@@ -15,10 +15,12 @@ namespace TarninaHub.Controllers;
public class InnovationsController : ControllerBase
{
private readonly IInnovationService _innovations;
private readonly IMatchmakingService _matchmakingService;
public InnovationsController(IInnovationService innovations)
public InnovationsController(IInnovationService innovations, IMatchmakingService matchmakingService)
{
_innovations = innovations;
_matchmakingService = matchmakingService;
}
[HttpGet]
@@ -56,6 +58,8 @@ public class InnovationsController : ControllerBase
var created = await _innovations.CreateAsync(request, cancellationToken);
await _matchmakingService.IndexInnovationAsync(created.Id, cancellationToken);
return CreatedAtAction(nameof(GetById), new { innovationId = created.Id }, created);
}
@@ -73,6 +77,11 @@ public class InnovationsController : ControllerBase
var updated = await _innovations.UpdateAsync(innovationId, request, cancellationToken);
if (updated is not null)
{
await _matchmakingService.IndexInnovationAsync(updated.Id, cancellationToken);
}
return updated is null ? NotFound() : Ok(updated);
}
@@ -0,0 +1,32 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Security;
namespace TarninaHub.Controllers;
[ApiController]
[ApiVersion("1.0")]
[Route("api/matchmaking")]
[Authorize]
public class MatchmakingController : ControllerBase
{
private readonly IMatchmakingService _matchmakingService;
public MatchmakingController(IMatchmakingService matchmakingService)
{
_matchmakingService = matchmakingService;
}
[HttpPost("reindex")]
[Authorize(Policy = Policies.AdminOnly)]
public async Task<ActionResult<MatchmakingReindexResponse>> Reindex(CancellationToken cancellationToken)
{
var indexed = await _matchmakingService.ReindexAsync(cancellationToken);
return Ok(new MatchmakingReindexResponse(indexed));
}
}
public sealed record MatchmakingReindexResponse(int Indexed);
+76 -1
View File
@@ -16,10 +16,14 @@ namespace TarninaHub.Controllers;
public class SubmissionsController : ControllerBase
{
private readonly ISubmissionService _submissionService;
private readonly IMatchmakingService _matchmakingService;
public SubmissionsController(ISubmissionService submissionService)
public SubmissionsController(
ISubmissionService submissionService,
IMatchmakingService matchmakingService)
{
_submissionService = submissionService;
_matchmakingService = matchmakingService;
}
[HttpPost]
@@ -36,6 +40,8 @@ public class SubmissionsController : ControllerBase
var created = await _submissionService.CreateAsync(userId, request, cancellationToken);
await _matchmakingService.IndexSubmissionAsync(created.Id, cancellationToken);
return CreatedAtAction(nameof(GetById), new { submissionId = created.Id }, created);
}
@@ -132,6 +138,75 @@ public class SubmissionsController : ControllerBase
return updated is null ? NotFound() : Ok(updated);
}
[HttpPost("{submissionId}/match")]
public async Task<ActionResult<MatchResponse>> Match(
string submissionId,
[FromBody] MatchRequest? request,
CancellationToken cancellationToken)
{
var access = await AuthorizeSubmissionAccessAsync(submissionId, cancellationToken);
if (access is not null)
{
return access;
}
var result = await _matchmakingService.MatchAsync(
submissionId,
request ?? new MatchRequest(null, null),
cancellationToken);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{submissionId}/matches")]
public async Task<ActionResult<MatchResponse>> Matches(
string submissionId,
[FromQuery] string? challengeId,
[FromQuery] int? maxResults,
CancellationToken cancellationToken)
{
var access = await AuthorizeSubmissionAccessAsync(submissionId, cancellationToken);
if (access is not null)
{
return access;
}
var result = await _matchmakingService.MatchAsync(
submissionId,
new MatchRequest(maxResults, challengeId),
cancellationToken);
return result is null ? NotFound() : Ok(result);
}
private async Task<ActionResult?> AuthorizeSubmissionAccessAsync(
string submissionId,
CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}
var submission = await _submissionService.GetAsync(submissionId, cancellationToken);
if (submission is null)
{
return NotFound();
}
if (!IsStaff() && !string.Equals(submission.AuthorId, userId, StringComparison.Ordinal))
{
return Forbid();
}
return null;
}
private bool IsStaff() =>
User.IsInRole(RoleNames.Operator) || User.IsInRole(RoleNames.Manager) || User.IsInRole(RoleNames.Admin);
}
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Matchmaking;
using TarninaHub.Infrastructure.Auth;
using TarninaHub.Infrastructure.Events;
using TarninaHub.Infrastructure.Persistence;
@@ -59,6 +60,8 @@ public static class DependencyInjection
services.AddSingleton<IKnowledgeItemService, KnowledgeItemService>();
services.AddSingleton<IInnovationService, InnovationService>();
services.AddSingleton<ITrendService, TrendService>();
services.AddSingleton<IEmbeddingProvider, HashingEmbeddingProvider>();
services.AddSingleton<IMatchmakingService, MatchmakingService>();
return services;
}
@@ -0,0 +1,252 @@
using SurrealDb.Net;
using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Dtos;
using TarninaHub.Application.Matchmaking;
using TarninaHub.Application.Text;
namespace TarninaHub.Infrastructure.Persistence;
public sealed class MatchmakingService : IMatchmakingService
{
private readonly ISurrealDbClient _client;
private readonly IEmbeddingProvider _embeddingProvider;
private readonly ISubmissionService _submissionService;
public MatchmakingService(
ISurrealDbClient client,
IEmbeddingProvider embeddingProvider,
ISubmissionService submissionService)
{
_client = client;
_embeddingProvider = embeddingProvider;
_submissionService = submissionService;
}
public async Task IndexInnovationAsync(string innovationId, CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(innovationId);
var response = await _client.RawQuery(
"SELECT type::string(id) AS id, title, summary, description, type::string(challenge) AS challenge " +
"FROM type::record($table, $id) LIMIT 1;",
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<InnovationRow>>(0) ?? [];
if (rows.Count == 0)
{
return;
}
var text = EmbeddingTextBuilder.Build(rows[0].Title, rows[0].Summary, rows[0].Description);
var embedding = await _embeddingProvider.EmbedAsync(text, cancellationToken);
var update = await _client.RawQuery(
"UPDATE type::record($table, $id) SET embedding = $embedding;",
new Dictionary<string, object?> { ["table"] = table, ["id"] = id, ["embedding"] = embedding },
cancellationToken);
update.EnsureAllOks();
}
public async Task IndexProblemAsync(string problemId, CancellationToken cancellationToken)
{
var (table, id) = SurrealRecordId.Split(problemId);
var response = await _client.RawQuery(
"SELECT type::string(id) AS id, title, description, type::string(challenge) AS challenge " +
"FROM type::record($table, $id) LIMIT 1;",
new Dictionary<string, object?> { ["table"] = table, ["id"] = id },
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<ProblemRow>>(0) ?? [];
if (rows.Count == 0)
{
return;
}
var text = EmbeddingTextBuilder.Build(rows[0].Title, null, rows[0].Description);
var embedding = await _embeddingProvider.EmbedAsync(text, cancellationToken);
var update = await _client.RawQuery(
"UPDATE type::record($table, $id) SET embedding = $embedding;",
new Dictionary<string, object?> { ["table"] = table, ["id"] = id, ["embedding"] = embedding },
cancellationToken);
update.EnsureAllOks();
}
public async Task IndexSubmissionAsync(string submissionId, CancellationToken cancellationToken)
{
var submission = await _submissionService.GetAsync(submissionId, cancellationToken);
if (submission is not null)
{
await IndexProblemAsync(submission.ProblemId, cancellationToken);
}
}
public async Task<int> ReindexAsync(CancellationToken cancellationToken)
{
var count = 0;
var innovations = await _client.RawQuery(
"SELECT type::string(id) AS id FROM innovation;",
cancellationToken: cancellationToken);
innovations.EnsureAllOks();
foreach (var row in innovations.GetValue<List<IdRow>>(0) ?? [])
{
await IndexInnovationAsync(row.Id, cancellationToken);
count++;
}
var problems = await _client.RawQuery(
"SELECT type::string(id) AS id FROM problem;",
cancellationToken: cancellationToken);
problems.EnsureAllOks();
foreach (var row in problems.GetValue<List<IdRow>>(0) ?? [])
{
await IndexProblemAsync(row.Id, cancellationToken);
count++;
}
return count;
}
public async Task<MatchResponse?> MatchAsync(
string submissionId,
MatchRequest request,
CancellationToken cancellationToken)
{
var submission = await _submissionService.GetAsync(submissionId, cancellationToken);
if (submission is null)
{
return null;
}
var maxResults = Math.Clamp(request.MaxResults ?? 5, 1, 20);
var queryText = EmbeddingTextBuilder.Build(
submission.Title,
null,
submission.Description);
if (submission.Keywords.Count > 0)
{
queryText += "\n" + string.Join(" ", submission.Keywords);
}
var queryVector = await _embeddingProvider.EmbedAsync(queryText, cancellationToken);
var solutions = await RankSolutionsAsync(submission, request.ChallengeId, queryVector, maxResults, cancellationToken);
var similarCases = await RankSimilarCasesAsync(submission, request.ChallengeId, queryVector, maxResults, cancellationToken);
return new MatchResponse(similarCases, solutions, Fallback: solutions.Count == 0);
}
private async Task<List<SolutionItem>> RankSolutionsAsync(
SubmissionResponse submission,
string? challengeFilter,
float[] queryVector,
int maxResults,
CancellationToken cancellationToken)
{
var clauses = new List<string> { "published = true", "embedding != NONE" };
var parameters = new Dictionary<string, object?>();
if (!string.IsNullOrWhiteSpace(challengeFilter))
{
var (table, id) = SurrealRecordId.Split(challengeFilter);
clauses.Add("challenge = type::record($challengeTable, $challengeId)");
parameters["challengeTable"] = table;
parameters["challengeId"] = id;
}
var response = await _client.RawQuery(
"SELECT type::string(id) AS id, title, summary, type::string(challenge) AS challenge, stage, embedding " +
"FROM innovation WHERE " + string.Join(" AND ", clauses) + ";",
parameters,
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<InnovationRow>>(0) ?? [];
var candidates = rows
.Where(row => row.Embedding is { Count: > 0 })
.Select(row => (Item: row, Vector: (IReadOnlyList<float>)row.Embedding!));
var ranked = SemanticRanker.Rank(queryVector, candidates, maxResults, minScore: 0.05);
return ranked
.Select(item => new SolutionItem(
item.Item.Id,
item.Item.Title,
item.Item.Stage,
NormalizeRecordId(item.Item.Challenge) ?? string.Empty,
Math.Round(item.Score, 3),
BuildRationale(item.Item.Challenge, submission.ChallengeId)))
.ToList();
}
private async Task<List<SimilarCaseItem>> RankSimilarCasesAsync(
SubmissionResponse submission,
string? challengeFilter,
float[] queryVector,
int maxResults,
CancellationToken cancellationToken)
{
var (problemTable, problemId) = SurrealRecordId.Split(submission.ProblemId);
var clauses = new List<string> { "embedding != NONE", "id != type::record($problemTable, $problemId)" };
var parameters = new Dictionary<string, object?>
{
["problemTable"] = problemTable,
["problemId"] = problemId,
};
if (!string.IsNullOrWhiteSpace(challengeFilter))
{
var (table, id) = SurrealRecordId.Split(challengeFilter);
clauses.Add("challenge = type::record($challengeTable, $challengeId)");
parameters["challengeTable"] = table;
parameters["challengeId"] = id;
}
var response = await _client.RawQuery(
"SELECT type::string(id) AS id, title, type::string(challenge) AS challenge, embedding " +
"FROM problem WHERE " + string.Join(" AND ", clauses) + ";",
parameters,
cancellationToken);
response.EnsureAllOks();
var rows = response.GetValue<List<ProblemRow>>(0) ?? [];
var candidates = rows
.Where(row => row.Embedding is { Count: > 0 })
.Select(row => (Item: row, Vector: (IReadOnlyList<float>)row.Embedding!));
var ranked = SemanticRanker.Rank(queryVector, candidates, maxResults, minScore: 0.05);
return ranked
.Select(item => new SimilarCaseItem(
item.Item.Id,
item.Item.Title,
Math.Round(item.Score, 3)))
.ToList();
}
private static string BuildRationale(string challenge, string queryChallengeId)
{
var normalized = NormalizeRecordId(challenge);
return string.Equals(normalized, queryChallengeId, StringComparison.Ordinal)
? "Pasuje obszarowo do zgłoszenia."
: "Wysoka zbieżność semantyczna opisu.";
}
private static string? NormalizeRecordId(string? value) =>
string.IsNullOrWhiteSpace(value) || value is "NONE" or "NULL" ? null : value;
}
@@ -118,6 +118,25 @@ internal sealed class InnovationRow
[CborProperty("published")] public bool Published { get; set; }
[CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; }
[CborProperty("embedding")] public List<float>? Embedding { get; set; }
}
internal sealed class ProblemRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
[CborProperty("title")] public string Title { get; set; } = string.Empty;
[CborProperty("description")] public string Description { get; set; } = string.Empty;
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
[CborProperty("location")] public string Location { get; set; } = string.Empty;
[CborProperty("author")] public string Author { get; set; } = string.Empty;
[CborProperty("embedding")] public List<float>? Embedding { get; set; }
}
internal sealed class TrendRow
@@ -131,3 +150,8 @@ internal sealed class TrendSubmissionRow
{
[CborProperty("challenge")] public string Challenge { get; set; } = string.Empty;
}
internal sealed class IdRow
{
[CborProperty("id")] public string Id { get; set; } = string.Empty;
}