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:
1 parent
baf53b7dac
commit
add111ffe1
9 files changed
+727
-15
No files matched your search
@@ -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; }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user