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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user