Neuer Discord-Command für tiefe Recherche in 3 Phasen: 1. Initiale Qdrant-Suche mit der Originalfrage 2. LLM generiert Folgefragen, sucht erneut (max 2 Iterationen) 3. Synthese aller gesammelten Chunks zu umfassender Antwort Nutzbar via /deepask oder @bot deepask. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
225 lines
6.4 KiB
Go
225 lines
6.4 KiB
Go
// deepask.go – Multi-Step Reasoning: iterative RAG-Suche mit Folgefragen
|
||
package brain
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
"time"
|
||
|
||
openai "github.com/sashabaranov/go-openai"
|
||
"google.golang.org/grpc/metadata"
|
||
|
||
"my-brain-importer/internal/agents"
|
||
"my-brain-importer/internal/config"
|
||
)
|
||
|
||
const maxDeepSteps = 3
|
||
|
||
// DeepAskQuery führt eine iterative RAG-Suche durch:
|
||
// 1. Initiale Suche → LLM generiert Folgefragen
|
||
// 2. Vertiefungssuchen mit Folgefragen (max 2 Iterationen)
|
||
// 3. Synthese: alle gesammelten Chunks → umfassende Antwort
|
||
func DeepAskQuery(question string, history []agents.HistoryMessage) (string, []KnowledgeChunk, error) {
|
||
start := time.Now()
|
||
|
||
ctx := context.Background()
|
||
ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey)
|
||
|
||
embClient := config.NewEmbeddingClient()
|
||
chatClient := config.NewChatClient()
|
||
|
||
// Deduplizierung über alle Schritte
|
||
seen := make(map[string]bool)
|
||
var allChunks []KnowledgeChunk
|
||
|
||
addChunks := func(chunks []KnowledgeChunk) int {
|
||
added := 0
|
||
for _, c := range chunks {
|
||
if !seen[c.Text] {
|
||
seen[c.Text] = true
|
||
allChunks = append(allChunks, c)
|
||
added++
|
||
}
|
||
}
|
||
return added
|
||
}
|
||
|
||
// Phase 1: Initiale Suche
|
||
slog.Info("[DeepAsk] Phase 1: Initiale Suche", "frage", question)
|
||
initialChunks := searchKnowledge(ctx, embClient, question)
|
||
addChunks(initialChunks)
|
||
|
||
if len(allChunks) == 0 {
|
||
return "", nil, nil
|
||
}
|
||
|
||
// Phase 2: Folgefragen generieren und suchen (max 2 Iterationen)
|
||
queries := []string{question}
|
||
for step := 1; step < maxDeepSteps; step++ {
|
||
followUps := generateFollowUpQueries(ctx, chatClient, question, allChunks)
|
||
if len(followUps) == 0 {
|
||
slog.Info("[DeepAsk] Keine Folgefragen generiert, überspringe", "schritt", step)
|
||
break
|
||
}
|
||
|
||
slog.Info("[DeepAsk] Phase 2: Vertiefung",
|
||
"schritt", step,
|
||
"folgefragen", len(followUps),
|
||
"fragen", followUps,
|
||
)
|
||
|
||
newFound := 0
|
||
for _, fq := range followUps {
|
||
chunks := searchKnowledge(ctx, embClient, fq)
|
||
newFound += addChunks(chunks)
|
||
queries = append(queries, fq)
|
||
}
|
||
|
||
if newFound == 0 {
|
||
slog.Info("[DeepAsk] Keine neuen Chunks gefunden, beende Vertiefung", "schritt", step)
|
||
break
|
||
}
|
||
|
||
slog.Info("[DeepAsk] Neue Chunks gefunden", "schritt", step, "neu", newFound, "gesamt", len(allChunks))
|
||
}
|
||
|
||
// Phase 3: Synthese — alle Chunks + Frage → umfassende Antwort
|
||
slog.Info("[DeepAsk] Phase 3: Synthese", "chunks", len(allChunks), "schritte", len(queries))
|
||
|
||
contextText := buildContext(allChunks)
|
||
coreMemory := LoadCoreMemory()
|
||
|
||
systemPrompt := `Du bist ein hilfreicher persönlicher Assistent mit Zugang zu einer umfangreichen Wissensdatenbank.
|
||
Dir wurden Informationen aus mehreren Suchdurchläufen bereitgestellt.
|
||
|
||
WICHTIGE REGELN:
|
||
- Nutze ALLE bereitgestellten Informationen für eine umfassende Antwort
|
||
- Verbinde Informationen aus verschiedenen Quellen zu einer kohärenten Antwort
|
||
- Ergänze mit eigenem Wissen wenn sinnvoll, kennzeichne es mit "Aus meinem Wissen:"
|
||
- Antworte auf Deutsch
|
||
- Sei gründlich aber strukturiert`
|
||
|
||
if coreMemory != "" {
|
||
systemPrompt += "\n\n## Fakten über den Nutzer:\n" + coreMemory
|
||
}
|
||
|
||
userPrompt := fmt.Sprintf(`Hier sind relevante Informationen aus mehreren Suchdurchläufen in meiner Wissensdatenbank:
|
||
|
||
%s
|
||
|
||
Basierend auf ALLEN diesen Informationen, beantworte bitte umfassend folgende Frage:
|
||
%s`, contextText, question)
|
||
|
||
msgs := []openai.ChatCompletionMessage{
|
||
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
|
||
}
|
||
for _, h := range history {
|
||
msgs = append(msgs, openai.ChatCompletionMessage{
|
||
Role: h.Role,
|
||
Content: h.Content,
|
||
})
|
||
}
|
||
msgs = append(msgs, openai.ChatCompletionMessage{
|
||
Role: openai.ChatMessageRoleUser,
|
||
Content: userPrompt,
|
||
})
|
||
|
||
stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
|
||
Model: config.Cfg.Chat.Model,
|
||
Messages: msgs,
|
||
Temperature: 0.7,
|
||
MaxTokens: 800,
|
||
})
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("LLM Fehler: %w", err)
|
||
}
|
||
defer stream.Close()
|
||
|
||
var answer strings.Builder
|
||
for {
|
||
response, err := stream.Recv()
|
||
if err != nil {
|
||
break
|
||
}
|
||
if len(response.Choices) > 0 {
|
||
answer.WriteString(response.Choices[0].Delta.Content)
|
||
}
|
||
}
|
||
|
||
slog.Info("[DeepAsk] Abgeschlossen",
|
||
"dauer", time.Since(start).Round(time.Millisecond),
|
||
"chunks_gesamt", len(allChunks),
|
||
"suchanfragen", len(queries),
|
||
"antwort_zeichen", answer.Len(),
|
||
)
|
||
|
||
return answer.String(), allChunks, nil
|
||
}
|
||
|
||
// generateFollowUpQueries lässt das LLM basierend auf bisherigen Ergebnissen Folgefragen generieren.
|
||
// Gibt 0-3 Folgefragen zurück.
|
||
func generateFollowUpQueries(ctx context.Context, chatClient *openai.Client, question string, chunks []KnowledgeChunk) []string {
|
||
contextText := buildContext(chunks)
|
||
|
||
prompt := fmt.Sprintf(`Originalfrage: %s
|
||
|
||
Bisherige Suchergebnisse:
|
||
%s
|
||
|
||
Generiere 1-3 Folgefragen, die helfen würden, die Originalfrage besser zu beantworten.
|
||
Jede Folgefrage muss auf einer eigenen Zeile stehen und mit "FOLGEFRAGE:" beginnen.
|
||
Wenn die bisherigen Ergebnisse die Frage bereits vollständig beantworten, schreibe: KEINE FOLGEFRAGEN`, question, contextText)
|
||
|
||
resp, err := chatClient.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
|
||
Model: config.Cfg.Chat.Model,
|
||
Messages: []openai.ChatCompletionMessage{
|
||
{Role: openai.ChatMessageRoleSystem, Content: "Du generierst präzise Suchfragen für eine Wissensdatenbank. Antworte NUR im vorgegebenen Format."},
|
||
{Role: openai.ChatMessageRoleUser, Content: prompt},
|
||
},
|
||
Temperature: 0.3,
|
||
MaxTokens: 300,
|
||
})
|
||
if err != nil {
|
||
slog.Warn("[DeepAsk] Folgefragen-Generierung fehlgeschlagen", "fehler", err)
|
||
return nil
|
||
}
|
||
if len(resp.Choices) == 0 {
|
||
return nil
|
||
}
|
||
|
||
return parseFollowUpQueries(resp.Choices[0].Message.Content)
|
||
}
|
||
|
||
// parseFollowUpQueries extrahiert Folgefragen aus der LLM-Antwort.
|
||
// Erwartet Zeilen im Format "FOLGEFRAGE: <frage>".
|
||
func parseFollowUpQueries(response string) []string {
|
||
// Reasoning-Modelle: Antwort nach </think>-Tag
|
||
if idx := strings.LastIndex(response, "</think>"); idx >= 0 {
|
||
response = response[idx+len("</think>"):]
|
||
}
|
||
|
||
if strings.Contains(strings.ToUpper(response), "KEINE FOLGEFRAGEN") {
|
||
return nil
|
||
}
|
||
|
||
var queries []string
|
||
for _, line := range strings.Split(response, "\n") {
|
||
line = strings.TrimSpace(line)
|
||
upper := strings.ToUpper(line)
|
||
if strings.HasPrefix(upper, "FOLGEFRAGE:") {
|
||
q := strings.TrimSpace(line[len("FOLGEFRAGE:"):])
|
||
if q != "" {
|
||
queries = append(queries, q)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Max 3 Folgefragen
|
||
if len(queries) > 3 {
|
||
queries = queries[:3]
|
||
}
|
||
return queries
|
||
}
|