Hybrid-Suche (Vektor + FTS5-Keyword), Recency-Boost, Query-Erweiterung (letzte 2-3 User-Messages), Chunk-Overlapping, FullText-Speicherung, konfigurierbares Embedding-Modell/MaxSeqLen, deduplizierter AutoContext- Code (Streaming + CLI shared), Admin-Diagnose-Seite mit Re-Embed-Funktion. Threshold von 0.45 auf 0.35 gesenkt (Hybrid-Suche ist präziser). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
199 lines
7.1 KiB
C#
199 lines
7.1 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using Gott.Extensions;
|
|
using Gott.Services;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
|
|
namespace Gott.Endpoints;
|
|
|
|
public static class EmbeddingDebugEndpoint
|
|
{
|
|
public static void MapEmbeddingDebugEndpoints(this WebApplication app)
|
|
{
|
|
app.MapGet("/api/admin/embedding-debug", HandleDebugSearch);
|
|
app.MapGet("/api/admin/embedding-debug/stats", HandleStats);
|
|
app.MapPost("/api/admin/embedding-debug/re-embed", HandleReEmbed);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Diagnose-Suche: Zeigt Top-N mit Scores, inkl. Ergebnisse unter Schwellenwert.
|
|
/// Query-Params: q (query), agentId, topN (default 20), chainId (zum Simulieren des Filters)
|
|
/// </summary>
|
|
private static async Task HandleDebugSearch(HttpContext ctx,
|
|
VectorSearchService vectorSearch, EmbeddingService embeddingService)
|
|
{
|
|
if (!ctx.IsAdmin())
|
|
{
|
|
ctx.Response.StatusCode = 403;
|
|
await ctx.Response.WriteAsJsonAsync(new { error = "Admin access required" });
|
|
return;
|
|
}
|
|
|
|
if (!embeddingService.IsAvailable)
|
|
{
|
|
await ctx.Response.WriteAsJsonAsync(new { error = "Embedding model not available" });
|
|
return;
|
|
}
|
|
|
|
var query = ctx.Request.Query["q"].ToString();
|
|
var agentId = ctx.Request.Query["agentId"].ToString();
|
|
var chainId = ctx.Request.Query["chainId"].ToString();
|
|
var topN = 20;
|
|
if (int.TryParse(ctx.Request.Query["topN"].ToString(), out var parsedTopN) && parsedTopN > 0)
|
|
topN = Math.Min(parsedTopN, 100);
|
|
|
|
if (string.IsNullOrWhiteSpace(query))
|
|
{
|
|
await ctx.Response.WriteAsJsonAsync(new { error = "Query parameter 'q' is required" });
|
|
return;
|
|
}
|
|
|
|
// Suche mit SEHR niedrigem Schwellenwert, damit wir alles sehen
|
|
var allResults = await vectorSearch.SearchAsync(query,
|
|
string.IsNullOrEmpty(agentId) ? null : agentId,
|
|
topN: topN,
|
|
minScore: 0.1f);
|
|
|
|
// Memory-Dateien auch durchsuchen
|
|
var memoryResults = new List<VectorSearchService.MemorySearchResult>();
|
|
if (!string.IsNullOrEmpty(agentId))
|
|
{
|
|
memoryResults = await vectorSearch.SearchMemoryFilesAsync(query, agentId, topN: 10, minScore: 0.1f);
|
|
}
|
|
|
|
// Ergebnisse annotieren: Was würde der AutoContext-Filter machen?
|
|
// Neuer Threshold ist 0.35 durch Hybrid-Suche
|
|
var annotatedMessages = allResults.Select(r => new
|
|
{
|
|
r.MessageId,
|
|
r.ChainId,
|
|
r.SessionId,
|
|
r.AgentId,
|
|
r.Role,
|
|
r.TextPreview,
|
|
FullText = r.FullText ?? r.TextPreview,
|
|
Score = Math.Round(r.Score, 4),
|
|
r.CreatedAt,
|
|
WouldPassThreshold = r.Score >= 0.35f,
|
|
WouldBeFilteredByChain = !string.IsNullOrEmpty(chainId) && r.ChainId == chainId,
|
|
WouldBeFilteredByLength = (r.TextPreview?.Length ?? 0) < 20,
|
|
WouldAppearInAutoContext = r.Score >= 0.35f
|
|
&& (string.IsNullOrEmpty(chainId) || r.ChainId != chainId)
|
|
&& (r.TextPreview?.Length ?? 0) >= 20
|
|
}).ToList();
|
|
|
|
var annotatedMemory = memoryResults.Select(r => new
|
|
{
|
|
r.AgentId,
|
|
r.FilePath,
|
|
r.ChunkIndex,
|
|
r.TextPreview,
|
|
FullText = r.FullText ?? r.TextPreview,
|
|
Score = Math.Round(r.Score, 4),
|
|
r.UpdatedAt,
|
|
WouldPassThreshold = r.Score >= 0.35f
|
|
}).ToList();
|
|
|
|
// Zusammenfassung
|
|
var summary = new
|
|
{
|
|
Query = query,
|
|
AgentId = agentId,
|
|
ChainIdFilter = chainId,
|
|
TotalMessageResults = annotatedMessages.Count,
|
|
AboveThreshold035 = annotatedMessages.Count(r => r.WouldPassThreshold),
|
|
BelowThreshold = annotatedMessages.Count(r => !r.WouldPassThreshold),
|
|
WouldAppearInAutoContext = annotatedMessages.Count(r => r.WouldAppearInAutoContext),
|
|
FilteredByChain = annotatedMessages.Count(r => r.WouldBeFilteredByChain),
|
|
FilteredByLength = annotatedMessages.Count(r => r.WouldBeFilteredByLength),
|
|
TotalMemoryResults = annotatedMemory.Count,
|
|
MemoryAboveThreshold = annotatedMemory.Count(r => r.WouldPassThreshold)
|
|
};
|
|
|
|
await ctx.Response.WriteAsJsonAsync(new
|
|
{
|
|
summary,
|
|
messages = annotatedMessages,
|
|
memory = annotatedMemory
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Statistiken: Anzahl Embeddings pro Agent, Gesamtzahl, etc.
|
|
/// </summary>
|
|
private static async Task HandleStats(HttpContext ctx,
|
|
VectorSearchService vectorSearch, DatabaseService db)
|
|
{
|
|
if (!ctx.IsAdmin())
|
|
{
|
|
ctx.Response.StatusCode = 403;
|
|
await ctx.Response.WriteAsJsonAsync(new { error = "Admin access required" });
|
|
return;
|
|
}
|
|
|
|
using var conn = db.GetConnection();
|
|
|
|
var totalMessages = await Dapper.SqlMapper.ExecuteScalarAsync<int>(conn,
|
|
"SELECT COUNT(*) FROM message_embeddings");
|
|
var totalMemory = await Dapper.SqlMapper.ExecuteScalarAsync<int>(conn,
|
|
"SELECT COUNT(*) FROM memory_file_embeddings");
|
|
|
|
var perAgentDynamic = await Dapper.SqlMapper.QueryAsync(conn,
|
|
@"SELECT me.agent_id,
|
|
a.name,
|
|
COUNT(*) AS message_count,
|
|
(SELECT COUNT(*) FROM memory_file_embeddings mfe WHERE mfe.agent_id = me.agent_id) AS memory_chunks
|
|
FROM message_embeddings me
|
|
LEFT JOIN agents a ON a.id = me.agent_id
|
|
GROUP BY me.agent_id
|
|
ORDER BY COUNT(*) DESC");
|
|
|
|
var perAgent = perAgentDynamic.Select(r => new
|
|
{
|
|
AgentId = (string)r.agent_id,
|
|
AgentName = (string)r.name,
|
|
MessageCount = (long)r.message_count,
|
|
MemoryChunks = (long)r.memory_chunks
|
|
}).ToList();
|
|
|
|
await ctx.Response.WriteAsJsonAsync(new
|
|
{
|
|
totalMessages,
|
|
totalMemory,
|
|
perAgent
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Triggert Re-Embedding aller Messages und Memory-Dateien.
|
|
/// POST /api/admin/embedding-debug/re-embed
|
|
/// </summary>
|
|
private static async Task HandleReEmbed(HttpContext ctx,
|
|
EmbeddingBackgroundService embeddingBg, EmbeddingService embeddingService)
|
|
{
|
|
if (!ctx.IsAdmin())
|
|
{
|
|
ctx.Response.StatusCode = 403;
|
|
await ctx.Response.WriteAsJsonAsync(new { error = "Admin access required" });
|
|
return;
|
|
}
|
|
|
|
if (!embeddingService.IsAvailable)
|
|
{
|
|
await ctx.Response.WriteAsJsonAsync(new { error = "Embedding model not available" });
|
|
return;
|
|
}
|
|
|
|
embeddingBg.TriggerReEmbed();
|
|
await ctx.Response.WriteAsJsonAsync(new
|
|
{
|
|
status = "Re-embedding triggered",
|
|
model = embeddingService.ModelName,
|
|
info = "Background service will re-embed all messages and memory files. Check stats for progress."
|
|
});
|
|
}
|
|
}
|