feat: Semantische Suche über Chat-History (memory_search)

- Lokales Embedding-Model (all-MiniLM-L6-v2, ONNX, CPU)
- EmbeddingService: WordPiece-Tokenizer + Mean Pooling (384 Dim)
- VectorSearchService: SQLite-basierte Vektor-Speicherung + Cosine Similarity
- EmbeddingBackgroundService: Backfill bestehender Messages + Live-Queue
- Neues Tool memory_search: Semantische Suche über alle Sessions (inkl. archivierte)
- ~9000 Messages embedded, Suche über komplette Chat-History
This commit is contained in:
Meleki committed 2026-03-11 09:22:11 +01:00
1 parent baf53b7dac
commit add111ffe1
9 files changed
+727 -15

No files matched your search

+15 -8
View File
@@ -22,8 +22,10 @@ public class AnthropicService
private readonly MemoryService _memoryService;
private readonly bool _payloadLogging;
private readonly bool _payloadLoggingGzip;
private readonly VectorSearchService _vectorSearch;
private readonly EmbeddingBackgroundService _embeddingBg;
public AnthropicService(IHttpClientFactory httpFactory, string token, ToolService toolService, DatabaseService db, MemoryService memoryService = null, bool payloadLogging = false, bool payloadLoggingGzip = false)
public AnthropicService(IHttpClientFactory httpFactory, string token, ToolService toolService, DatabaseService db, MemoryService memoryService = null, bool payloadLogging = false, bool payloadLoggingGzip = false, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null)
{
_httpFactory = httpFactory;
_token = token;
@@ -32,6 +34,8 @@ public class AnthropicService
_memoryService = memoryService;
_payloadLogging = payloadLogging;
_payloadLoggingGzip = payloadLoggingGzip;
_vectorSearch = vectorSearch;
_embeddingBg = embeddingBg;
}
public async Task StreamChat(
@@ -153,12 +157,14 @@ public class AnthropicService
if (blockType == "text")
{
await _db.SaveMessage(new MessageRecord
var textContent = el.GetProperty("text").GetString();
var msgId = await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = iterations,
Role = "assistant", Type = "text",
Content = el.GetProperty("text").GetString()
Content = textContent
});
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", textContent ?? "");
}
else if (blockType == "tool_use")
{
@@ -832,7 +838,7 @@ public class AnthropicService
string toolResult;
bool isError = false;
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, workCt); }
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, agentWorkspace, _memoryService, agentId, workCt, vectorSearch: _vectorSearch); }
catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -1038,7 +1044,7 @@ public class AnthropicService
string toolResult;
bool isError = false;
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId); }
try { toolResult = await _toolService.ExecuteTool(toolName, toolInput, ctx.Workspace, _memoryService, ctx.AgentId, vectorSearch: _vectorSearch); }
catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -1339,7 +1345,7 @@ public class AnthropicService
string toolResult;
bool isError = false;
try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt, remoteMgr: remoteMgr, sessionId: sessionId); }
try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt, remoteMgr: remoteMgr, sessionId: sessionId, vectorSearch: _vectorSearch); }
catch (OperationCanceledException) { throw; }
catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; }
@@ -1516,7 +1522,8 @@ public class AnthropicService
{
var hasWorkspace = !string.IsNullOrEmpty(workspace);
var includeMemory = useMemory && _memoryService != null && !string.IsNullOrEmpty(agentId);
var hasTools = hasWorkspace || includeMemory || includeRemote;
var includeMemorySearch = _vectorSearch != null && _vectorSearch.GetEmbeddingCount() > 0;
var hasTools = hasWorkspace || includeMemory || includeRemote || includeMemorySearch;
var hasWebSearch = !string.IsNullOrEmpty(webSearchVersion);
if (!hasTools && !hasWebSearch) return;
@@ -1524,7 +1531,7 @@ public class AnthropicService
var tools = new List<object>();
if (hasTools)
{
var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice, includeRemote: includeRemote);
var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice, includeRemote: includeRemote, includeMemorySearch: includeMemorySearch);
if (planMode)
allTools = _toolService.FilterReadOnlyTools(allTools);
tools.AddRange(allTools);
+148
View File
@@ -0,0 +1,148 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Hosting;
namespace Gott.Services;
/// <summary>
/// Background-Service der Messages embedded:
/// 1. Beim Start: Backfill aller bestehenden Text-Messages
/// 2. Laufend: Neue Messages über Queue verarbeiten
/// </summary>
public class EmbeddingBackgroundService : BackgroundService
{
readonly VectorSearchService _vectorSearch;
readonly EmbeddingService _embedding;
readonly string _dbConnectionString;
readonly ConcurrentQueue<EmbeddingJob> _queue = new();
readonly SemaphoreSlim _signal = new(0);
public EmbeddingBackgroundService(VectorSearchService vectorSearch, EmbeddingService embedding, string dbPath)
{
_vectorSearch = vectorSearch;
_embedding = embedding;
_dbConnectionString = $"Data Source={dbPath}";
}
/// <summary>Enqueue eine neue Message zum Embedden.</summary>
public void Enqueue(long messageId, string chainId, string sessionId, string agentId, string role, string text)
{
_queue.Enqueue(new EmbeddingJob(messageId, chainId, sessionId, agentId, role, text));
_signal.Release();
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
if (!_embedding.IsAvailable)
{
Console.WriteLine("[EmbeddingBG] Embedding model not available – service disabled.");
return;
}
// Kurz warten bis App vollständig gestartet
await Task.Delay(3000, ct);
// Backfill bestehender Messages
await BackfillAsync(ct);
// Live-Embedding neuer Messages
Console.WriteLine("[EmbeddingBG] Waiting for new messages...");
while (!ct.IsCancellationRequested)
{
try
{
await _signal.WaitAsync(ct);
while (_queue.TryDequeue(out var job))
{
if (ct.IsCancellationRequested) break;
try
{
await _vectorSearch.EmbedMessageAsync(job.MessageId, job.ChainId, job.SessionId,
job.AgentId, job.Role, job.Text);
}
catch (Exception ex)
{
Console.WriteLine($"[EmbeddingBG] Error embedding message {job.MessageId}: {ex.Message}");
}
}
}
catch (OperationCanceledException) { break; }
}
}
async Task BackfillAsync(CancellationToken ct)
{
Console.WriteLine("[EmbeddingBG] Starting backfill of existing messages...");
var existingIds = await _vectorSearch.GetEmbeddedMessageIdsAsync();
Console.WriteLine($"[EmbeddingBG] Already embedded: {existingIds.Count} messages");
using var conn = new SqliteConnection(_dbConnectionString);
await conn.OpenAsync(ct);
// Alle text-Messages laden (user + assistant, inkl. archivierte Sessions)
var messages = await conn.QueryAsync<BackfillRow>(@"
SELECT m.id, m.chain_id, c.session_id, c.agent_id, m.role, m.content, m.type
FROM messages m
JOIN chains c ON m.chain_id = c.chain_id
WHERE m.type = 'text'
AND m.role IN ('user', 'assistant')
AND m.content IS NOT NULL
AND LENGTH(m.content) >= 10
AND m.deleted_on IS NULL
ORDER BY m.id ASC
");
int total = 0;
int embedded = 0;
int skipped = 0;
foreach (var msg in messages)
{
if (ct.IsCancellationRequested) break;
total++;
if (existingIds.Contains(msg.Id))
{
skipped++;
continue;
}
try
{
// SessionId kann auch aus archive kommen
var sessionId = msg.SessionId ?? "";
await _vectorSearch.EmbedMessageAsync(msg.Id, msg.ChainId, sessionId,
msg.AgentId, msg.Role, msg.Content);
embedded++;
if (embedded % 100 == 0)
Console.WriteLine($"[EmbeddingBG] Backfill progress: {embedded} embedded, {skipped} skipped, {total} total");
}
catch (Exception ex)
{
Console.WriteLine($"[EmbeddingBG] Error backfilling message {msg.Id}: {ex.Message}");
}
}
Console.WriteLine($"[EmbeddingBG] Backfill complete: {embedded} new embeddings, {skipped} skipped, {total} total messages.");
}
record EmbeddingJob(long MessageId, string ChainId, string SessionId, string AgentId, string Role, string Text);
class BackfillRow
{
public long Id { get; set; }
public string ChainId { get; set; }
public string SessionId { get; set; }
public string AgentId { get; set; }
public string Role { get; set; }
public string Content { get; set; }
public string Type { get; set; }
}
}
+285
View File
@@ -0,0 +1,285 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
namespace Gott.Services;
/// <summary>
/// Lokaler Embedding-Service basierend auf all-MiniLM-L6-v2 (ONNX).
/// Generiert 384-dimensionale Sentence-Embeddings für semantische Suche.
/// </summary>
public sealed class EmbeddingService : IDisposable
{
readonly InferenceSession _session;
readonly Dictionary<string, int> _vocab;
readonly int _clsId;
readonly int _sepId;
readonly int _unkId;
readonly int _maxSeqLen = 128;
bool _disposed;
public bool IsAvailable { get; }
public int Dimensions => 384;
public EmbeddingService()
{
var modelDir = Path.Combine(AppContext.BaseDirectory, "data", "models", "all-MiniLM-L6-v2");
var modelPath = Path.Combine(modelDir, "model.onnx");
var vocabPath = Path.Combine(modelDir, "vocab.txt");
if (!File.Exists(modelPath) || !File.Exists(vocabPath))
{
Console.WriteLine("[Embedding] Model not found at " + modelDir + " – service disabled.");
_vocab = new Dictionary<string, int>();
IsAvailable = false;
return;
}
try
{
var opts = new SessionOptions();
opts.InterOpNumThreads = 1;
opts.IntraOpNumThreads = 2;
opts.GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL;
_session = new InferenceSession(modelPath, opts);
_vocab = new Dictionary<string, int>(50000);
var lines = File.ReadAllLines(vocabPath);
for (int i = 0; i < lines.Length; i++)
_vocab[lines[i]] = i;
_clsId = _vocab.GetValueOrDefault("[CLS]", 101);
_sepId = _vocab.GetValueOrDefault("[SEP]", 102);
_unkId = _vocab.GetValueOrDefault("[UNK]", 100);
IsAvailable = true;
Console.WriteLine($"[Embedding] Model loaded. Vocab: {_vocab.Count} tokens.");
}
catch (Exception ex)
{
Console.WriteLine($"[Embedding] Failed to load model: {ex.Message}");
_vocab = new Dictionary<string, int>();
IsAvailable = false;
}
}
/// <summary>Generiert ein 384-dim Embedding für einen Text.</summary>
public float[] GenerateEmbedding(string text)
{
if (!IsAvailable) return Array.Empty<float>();
var tokenIds = Tokenize(text);
var seqLen = tokenIds.Count;
var inputIds = new long[seqLen];
var attentionMask = new long[seqLen];
var tokenTypeIds = new long[seqLen];
for (int i = 0; i < seqLen; i++)
{
inputIds[i] = tokenIds[i];
attentionMask[i] = 1;
tokenTypeIds[i] = 0;
}
var shape = new long[] { 1, seqLen };
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor("input_ids",
new DenseTensor<long>(inputIds, new[] { 1, seqLen })),
NamedOnnxValue.CreateFromTensor("attention_mask",
new DenseTensor<long>(attentionMask, new[] { 1, seqLen })),
NamedOnnxValue.CreateFromTensor("token_type_ids",
new DenseTensor<long>(tokenTypeIds, new[] { 1, seqLen }))
};
using var results = _session.Run(inputs);
var output = results.First().AsTensor<float>();
// Mean Pooling: Mittelwert über alle Token-Embeddings (attention_mask berücksichtigen)
var embedding = new float[Dimensions];
for (int t = 0; t < seqLen; t++)
{
for (int d = 0; d < Dimensions; d++)
embedding[d] += output[0, t, d];
}
for (int d = 0; d < Dimensions; d++)
embedding[d] /= seqLen;
// L2 Normalize
Normalize(embedding);
return embedding;
}
/// <summary>WordPiece-Tokenisierung mit BERT-Normalisierung.</summary>
List<int> Tokenize(string text)
{
// BERT Normalization: lowercase, clean control chars
text = text.ToLowerInvariant();
var sb = new StringBuilder(text.Length);
foreach (var c in text)
{
if (c == 0 || c == 0xFFFD || char.IsControl(c)) continue;
if (IsChineseChar(c)) { sb.Append(' '); sb.Append(c); sb.Append(' '); }
else sb.Append(c);
}
text = sb.ToString();
// BERT Pre-tokenize: split on whitespace and punctuation
var words = BertPreTokenize(text);
var tokenIds = new List<int> { _clsId };
int maxTokens = _maxSeqLen - 2; // Reserve [CLS] and [SEP]
foreach (var word in words)
{
if (tokenIds.Count - 1 >= maxTokens) break;
var subTokens = WordPieceTokenize(word);
foreach (var st in subTokens)
{
if (tokenIds.Count - 1 >= maxTokens) break;
tokenIds.Add(st);
}
}
tokenIds.Add(_sepId);
return tokenIds;
}
List<int> WordPieceTokenize(string word)
{
if (word.Length > 100)
return new List<int> { _unkId };
var tokens = new List<int>();
int start = 0;
while (start < word.Length)
{
int end = word.Length;
int foundId = -1;
while (start < end)
{
var substr = word.Substring(start, end - start);
if (start > 0) substr = "##" + substr;
if (_vocab.TryGetValue(substr, out var id))
{
foundId = id;
break;
}
end--;
}
if (foundId < 0)
{
tokens.Add(_unkId);
break;
}
tokens.Add(foundId);
start = end;
}
return tokens;
}
static List<string> BertPreTokenize(string text)
{
var words = new List<string>();
var current = new StringBuilder();
foreach (var c in text)
{
if (char.IsWhiteSpace(c))
{
if (current.Length > 0) { words.Add(current.ToString()); current.Clear(); }
}
else if (IsPunctuation(c))
{
if (current.Length > 0) { words.Add(current.ToString()); current.Clear(); }
words.Add(c.ToString());
}
else
{
current.Append(c);
}
}
if (current.Length > 0) words.Add(current.ToString());
return words;
}
static bool IsPunctuation(char c)
{
var cat = char.GetUnicodeCategory(c);
return cat == UnicodeCategory.ConnectorPunctuation ||
cat == UnicodeCategory.DashPunctuation ||
cat == UnicodeCategory.OpenPunctuation ||
cat == UnicodeCategory.ClosePunctuation ||
cat == UnicodeCategory.InitialQuotePunctuation ||
cat == UnicodeCategory.FinalQuotePunctuation ||
cat == UnicodeCategory.OtherPunctuation;
}
static bool IsChineseChar(char c)
{
int cp = c;
return (cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0x3400 && cp <= 0x4DBF) ||
(cp >= 0x20000 && cp <= 0x2A6DF) || (cp >= 0x2A700 && cp <= 0x2B73F) ||
(cp >= 0x2B740 && cp <= 0x2B81F) || (cp >= 0x2B820 && cp <= 0x2CEAF) ||
(cp >= 0xF900 && cp <= 0xFAFF) || (cp >= 0x2F800 && cp <= 0x2FA1F);
}
static void Normalize(float[] vec)
{
double sum = 0;
for (int i = 0; i < vec.Length; i++) sum += vec[i] * vec[i];
var norm = (float)Math.Sqrt(sum);
if (norm > 0)
for (int i = 0; i < vec.Length; i++) vec[i] /= norm;
}
/// <summary>Cosine Similarity zwischen zwei normalisierten Vektoren (= Dot Product).</summary>
public static float CosineSimilarity(float[] a, float[] b)
{
if (a.Length != b.Length) return 0;
float dot = 0;
for (int i = 0; i < a.Length; i++) dot += a[i] * b[i];
return dot;
}
/// <summary>Serialisiert float[] zu byte[] für DB-Speicherung.</summary>
public static byte[] ToBytes(float[] vec)
{
var bytes = new byte[vec.Length * 4];
Buffer.BlockCopy(vec, 0, bytes, 0, bytes.Length);
return bytes;
}
/// <summary>Deserialisiert byte[] aus DB zu float[].</summary>
public static float[] FromBytes(byte[] bytes)
{
var vec = new float[bytes.Length / 4];
Buffer.BlockCopy(bytes, 0, vec, 0, bytes.Length);
return vec;
}
public void Dispose()
{
if (!_disposed)
{
_session?.Dispose();
_disposed = true;
}
}
}
+47 -3
View File
@@ -33,7 +33,7 @@ public class ToolService
private static readonly HashSet<string> ReadOnlyToolNames = new HashSet<string>
{
"read_file", "list_files", "glob", "grep", "execute", "todo_write",
"memory_read", "memory_write", "memory_list",
"memory_read", "memory_write", "memory_list", "memory_search",
"remote_execute", AskUserChoiceToolName
};
@@ -54,7 +54,7 @@ public class ToolService
return filtered.ToArray();
}
public object[] GetToolDefinitions(bool includeFileTools = false, bool includeMemory = false, bool includeUserChoice = true, bool includeRemote = false)
public object[] GetToolDefinitions(bool includeFileTools = false, bool includeMemory = false, bool includeUserChoice = true, bool includeRemote = false, bool includeMemorySearch = false)
{
var allTools = new List<object>();
if (includeUserChoice)
@@ -63,6 +63,8 @@ public class ToolService
allTools.AddRange(GetFileTools());
if (includeMemory)
allTools.AddRange(GetMemoryToolDefinitions());
if (includeMemorySearch)
allTools.Add(GetMemorySearchTool());
if (includeRemote)
allTools.Add(GetRemoteExecuteTool());
return allTools.ToArray();
@@ -264,6 +266,25 @@ public class ToolService
};
}
private object GetMemorySearchTool()
{
return new
{
name = "memory_search",
description = "Semantische Suche über alle vergangenen Chat-Nachrichten (inklusive archivierter Sessions). Findet relevante Informationen aus früheren Gesprächen anhand von Bedeutung, nicht exakten Wörtern. Nutze dies wenn du Kontext aus vergangenen Interaktionen brauchst.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["query"] = new { type = "string", description = "Suchanfrage – beschreibe wonach du suchst (z.B. 'Druckerproblem letzte Woche', 'Server-Migration Konfiguration')" },
["top_n"] = new { type = "integer", description = "Anzahl der Ergebnisse (Default: 10, Max: 30)" }
},
required = new[] { "query" }
}
};
}
private object GetRemoteExecuteTool()
{
return new
@@ -333,7 +354,7 @@ public class ToolService
};
}
public async Task<string> ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default, RemoteConnectionManager remoteMgr = null, string sessionId = null)
public async Task<string> ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default, RemoteConnectionManager remoteMgr = null, string sessionId = null, VectorSearchService vectorSearch = null)
{
switch (name)
{
@@ -617,6 +638,29 @@ public class ToolService
var files = memoryService.ListMemoryFiles(agentId, subPath);
return files.Count == 0 ? "(keine Memory-Dateien vorhanden)" : string.Join("\n", files);
}
case "memory_search":
{
if (vectorSearch == null)
return "Fehler: Semantische Suche nicht verfügbar";
var query = input.GetProperty("query").GetString() ?? "";
var topN = input.TryGetProperty("top_n", out var tn) ? tn.GetInt32() : 10;
if (topN > 30) topN = 30;
if (topN < 1) topN = 1;
var results = await vectorSearch.SearchAsync(query, agentId, topN);
if (results.Count == 0)
return "Keine relevanten Ergebnisse gefunden.";
var sb = new StringBuilder();
sb.AppendLine($"Gefunden: {results.Count} relevante Einträge\n");
for (int i = 0; i < results.Count; i++)
{
var r = results[i];
var date = DateTime.TryParse(r.CreatedAt, out var dt) ? dt.ToString("yyyy-MM-dd HH:mm") : r.CreatedAt;
sb.AppendLine($"[{i + 1}] Score: {r.Score:F2} | {r.Role} | {date} | Session: {r.SessionId}");
sb.AppendLine(r.TextPreview);
sb.AppendLine();
}
return sb.ToString().TrimEnd();
}
case "remote_execute":
{
if (remoteMgr == null || string.IsNullOrEmpty(sessionId))
+214
View File
@@ -0,0 +1,214 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using Microsoft.Data.Sqlite;
namespace Gott.Services;
/// <summary>
/// Verwaltet Message-Embeddings in SQLite und bietet semantische Suche.
/// </summary>
public class VectorSearchService
{
readonly string _connectionString;
readonly EmbeddingService _embedding;
readonly SemaphoreSlim _embeddingLock = new(1, 1);
public VectorSearchService(string dbPath, EmbeddingService embedding)
{
_connectionString = $"Data Source={dbPath}";
_embedding = embedding;
InitializeSchema();
}
SqliteConnection CreateConnection()
{
var conn = new SqliteConnection(_connectionString);
conn.Open();
return conn;
}
void InitializeSchema()
{
using var conn = CreateConnection();
conn.Execute(@"
CREATE TABLE IF NOT EXISTS message_embeddings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL UNIQUE,
chain_id TEXT NOT NULL,
session_id TEXT NOT NULL,
agent_id TEXT,
role TEXT NOT NULL,
text_preview TEXT,
vector BLOB NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_emb_session ON message_embeddings(session_id);
CREATE INDEX IF NOT EXISTS idx_emb_agent ON message_embeddings(agent_id);
CREATE INDEX IF NOT EXISTS idx_emb_message ON message_embeddings(message_id);
");
Console.WriteLine("[VectorSearch] Schema initialized.");
}
/// <summary>
/// Generiert und speichert ein Embedding für eine Message.
/// Thread-safe durch SemaphoreSlim (ONNX InferenceSession ist nicht thread-safe).
/// </summary>
public async Task EmbedMessageAsync(long messageId, string chainId, string sessionId,
string agentId, string role, string text)
{
if (!_embedding.IsAvailable || string.IsNullOrWhiteSpace(text)) return;
if (text.Length < 10) return; // Zu kurze Texte sind nicht sinnvoll
// Prüfe ob schon embedded
using var conn = CreateConnection();
var exists = await conn.ExecuteScalarAsync<int>(
"SELECT COUNT(*) FROM message_embeddings WHERE message_id = @messageId",
new { messageId });
if (exists > 0) return;
// Embedding generieren (thread-safe)
float[] vector;
await _embeddingLock.WaitAsync();
try
{
vector = _embedding.GenerateEmbedding(text);
}
finally
{
_embeddingLock.Release();
}
if (vector.Length == 0) return;
var preview = text.Length > 200 ? text.Substring(0, 200) : text;
await conn.ExecuteAsync(@"
INSERT OR IGNORE INTO message_embeddings
(message_id, chain_id, session_id, agent_id, role, text_preview, vector, created_at)
VALUES
(@messageId, @chainId, @sessionId, @agentId, @role, @preview, @vector, @createdAt)",
new
{
messageId,
chainId,
sessionId,
agentId = agentId ?? "",
role,
preview,
vector = EmbeddingService.ToBytes(vector),
createdAt = DateTime.UtcNow.ToString("o")
});
}
/// <summary>
/// Semantische Suche: Findet die N ähnlichsten Messages zu einer Query.
/// Durchsucht alle Sessions eines Agents (oder alle wenn agentId null).
/// </summary>
public async Task<List<SearchResult>> SearchAsync(string query, string agentId = null,
int topN = 10, float minScore = 0.3f)
{
if (!_embedding.IsAvailable) return new List<SearchResult>();
// Query embedden
float[] queryVec;
await _embeddingLock.WaitAsync();
try
{
queryVec = _embedding.GenerateEmbedding(query);
}
finally
{
_embeddingLock.Release();
}
if (queryVec.Length == 0) return new List<SearchResult>();
// Alle Embeddings laden und Cosine Similarity berechnen
using var conn = CreateConnection();
string sql;
object param;
if (!string.IsNullOrEmpty(agentId))
{
sql = "SELECT message_id, chain_id, session_id, agent_id, role, text_preview, vector, created_at FROM message_embeddings WHERE agent_id = @agentId";
param = new { agentId };
}
else
{
sql = "SELECT message_id, chain_id, session_id, agent_id, role, text_preview, vector, created_at FROM message_embeddings";
param = new { };
}
var rows = await conn.QueryAsync<EmbeddingRow>(sql, param);
var results = new List<SearchResult>();
foreach (var row in rows)
{
var vec = EmbeddingService.FromBytes(row.Vector);
var score = EmbeddingService.CosineSimilarity(queryVec, vec);
if (score >= minScore)
{
results.Add(new SearchResult
{
MessageId = row.MessageId,
ChainId = row.ChainId,
SessionId = row.SessionId,
AgentId = row.AgentId,
Role = row.Role,
TextPreview = row.TextPreview,
Score = score,
CreatedAt = row.CreatedAt
});
}
}
return results.OrderByDescending(r => r.Score).Take(topN).ToList();
}
/// <summary>
/// Gibt die Anzahl der gespeicherten Embeddings zurück.
/// </summary>
public int GetEmbeddingCount()
{
using var conn = CreateConnection();
return conn.ExecuteScalar<int>("SELECT COUNT(*) FROM message_embeddings");
}
/// <summary>
/// Gibt die IDs aller bereits eingebetteten Messages zurück.
/// </summary>
public async Task<HashSet<long>> GetEmbeddedMessageIdsAsync()
{
using var conn = CreateConnection();
var ids = await conn.QueryAsync<long>("SELECT message_id FROM message_embeddings");
return new HashSet<long>(ids);
}
public class EmbeddingRow
{
public long MessageId { get; set; }
public string ChainId { get; set; }
public string SessionId { get; set; }
public string AgentId { get; set; }
public string Role { get; set; }
public string TextPreview { get; set; }
public byte[] Vector { get; set; }
public string CreatedAt { get; set; }
}
public class SearchResult
{
public long MessageId { get; set; }
public string ChainId { get; set; }
public string SessionId { get; set; }
public string AgentId { get; set; }
public string Role { get; set; }
public string TextPreview { get; set; }
public float Score { get; set; }
public string CreatedAt { get; set; }
}
}