235 lines
6.5 KiB
Go
Executable File
235 lines
6.5 KiB
Go
Executable File
// ask.go – Sucht relevante Chunks in Qdrant und beantwortet Fragen mit einem LLM
|
||
package brain
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"log/slog"
|
||
"strings"
|
||
"time"
|
||
|
||
pb "github.com/qdrant/go-client/qdrant"
|
||
openai "github.com/sashabaranov/go-openai"
|
||
"google.golang.org/grpc/metadata"
|
||
|
||
"my-brain-importer/internal/config"
|
||
)
|
||
|
||
// KnowledgeChunk repräsentiert ein Suchergebnis aus Qdrant.
|
||
type KnowledgeChunk struct {
|
||
Text string
|
||
Score float32
|
||
Source string
|
||
}
|
||
|
||
// AskQuery sucht relevante Chunks und generiert eine LLM-Antwort.
|
||
// Gibt die Antwort als String und die verwendeten Quellen zurück.
|
||
func AskQuery(question string) (string, []KnowledgeChunk, error) {
|
||
ctx := context.Background()
|
||
ctx = metadata.AppendToOutgoingContext(ctx, "api-key", config.Cfg.Qdrant.APIKey)
|
||
|
||
embClient := config.NewEmbeddingClient()
|
||
chatClient := config.NewChatClient()
|
||
|
||
chunks := searchKnowledge(ctx, embClient, question)
|
||
if len(chunks) == 0 {
|
||
return "", nil, nil
|
||
}
|
||
|
||
contextText := buildContext(chunks)
|
||
|
||
systemPrompt := `Du bist ein hilfreicher persönlicher Assistent.
|
||
Beantworte Fragen primär anhand der bereitgestellten Informationen aus der Wissensdatenbank.
|
||
Ergänze fehlende Details mit deinem eigenen Wissen, kennzeichne dies aber klar mit "Aus meinem Wissen:".
|
||
|
||
WICHTIGE REGELN:
|
||
- Nutze die bereitgestellten Informationen als Hauptquelle
|
||
- Ergänze mit eigenem Wissen wenn sinnvoll, kennzeichne es deutlich
|
||
- Antworte auf Deutsch
|
||
- Sei präzise und direkt`
|
||
|
||
userPrompt := fmt.Sprintf(`Hier sind die relevanten Informationen aus meiner Wissensdatenbank:
|
||
|
||
%s
|
||
|
||
Basierend auf diesen Informationen, beantworte bitte folgende Frage:
|
||
%s`, contextText, question)
|
||
|
||
slog.Debug("[LLM] AskQuery Prompt",
|
||
"model", config.Cfg.Chat.Model,
|
||
"system", systemPrompt,
|
||
"user", userPrompt,
|
||
)
|
||
|
||
start := time.Now()
|
||
stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
|
||
Model: config.Cfg.Chat.Model,
|
||
Messages: []openai.ChatCompletionMessage{
|
||
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
|
||
{Role: openai.ChatMessageRoleUser, Content: userPrompt},
|
||
},
|
||
Temperature: 0.7,
|
||
MaxTokens: 500,
|
||
})
|
||
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.Debug("[LLM] AskQuery Antwort",
|
||
"dauer", time.Since(start).Round(time.Millisecond),
|
||
"zeichen", answer.Len(),
|
||
"antwort", answer.String(),
|
||
)
|
||
|
||
return answer.String(), chunks, nil
|
||
}
|
||
|
||
// Ask sucht relevante Chunks und gibt Antwort + Quellen auf stdout aus.
|
||
func Ask(question string) {
|
||
fmt.Printf("🤔 Frage: \"%s\"\n\n", question)
|
||
fmt.Println("🔍 Durchsuche lokale Wissensdatenbank...")
|
||
|
||
answer, chunks, err := AskQuery(question)
|
||
if err != nil {
|
||
log.Fatalf("❌ %v", err)
|
||
}
|
||
|
||
if len(chunks) == 0 {
|
||
fmt.Println("\n❌ Keine relevanten Informationen in der Datenbank gefunden.")
|
||
fmt.Println(" Füge mehr Daten mit './bin/ingest' hinzu.")
|
||
return
|
||
}
|
||
|
||
fmt.Printf("✅ %d relevante Informationen gefunden\n\n", len(chunks))
|
||
fmt.Println("🧠 Generiere Antwort mit lokalem Modell...")
|
||
fmt.Println(strings.Repeat("═", 80))
|
||
fmt.Print("\n💬 Antwort:\n\n")
|
||
fmt.Print(answer)
|
||
fmt.Print("\n\n")
|
||
fmt.Println(strings.Repeat("═", 80))
|
||
fmt.Print("\n📚 Verwendete Quellen:\n")
|
||
for i, chunk := range chunks {
|
||
preview := chunk.Text
|
||
if len(preview) > 80 {
|
||
preview = preview[:80] + "..."
|
||
}
|
||
fmt.Printf(" [%d] %.1f%% - %s\n %s\n", i+1, chunk.Score*100, chunk.Source, preview)
|
||
}
|
||
}
|
||
|
||
func searchKnowledge(ctx context.Context, embClient *openai.Client, query string) []KnowledgeChunk {
|
||
embResp, err := embClient.CreateEmbeddings(ctx, openai.EmbeddingRequest{
|
||
Input: []string{query},
|
||
Model: openai.EmbeddingModel(config.Cfg.Embedding.Model),
|
||
})
|
||
if err != nil {
|
||
log.Printf("❌ Embedding Fehler: %v", err)
|
||
return nil
|
||
}
|
||
|
||
conn := config.NewQdrantConn()
|
||
defer conn.Close()
|
||
|
||
searchResult, err := pb.NewPointsClient(conn).Search(ctx, &pb.SearchPoints{
|
||
CollectionName: config.Cfg.Qdrant.Collection,
|
||
Vector: embResp.Data[0].Embedding,
|
||
Limit: config.Cfg.TopK,
|
||
WithPayload: &pb.WithPayloadSelector{
|
||
SelectorOptions: &pb.WithPayloadSelector_Enable{Enable: true},
|
||
},
|
||
ScoreThreshold: floatPtr(config.Cfg.ScoreThreshold),
|
||
})
|
||
if err != nil {
|
||
log.Printf("❌ Suche fehlgeschlagen: %v", err)
|
||
return nil
|
||
}
|
||
|
||
var chunks []KnowledgeChunk
|
||
seen := make(map[string]bool)
|
||
for _, hit := range searchResult.Result {
|
||
text := hit.Payload["text"].GetStringValue()
|
||
if text == "" || seen[text] {
|
||
continue
|
||
}
|
||
seen[text] = true
|
||
chunks = append(chunks, KnowledgeChunk{
|
||
Text: text,
|
||
Score: hit.Score,
|
||
Source: hit.Payload["source"].GetStringValue(),
|
||
})
|
||
}
|
||
return chunks
|
||
}
|
||
|
||
func buildContext(chunks []KnowledgeChunk) string {
|
||
var b strings.Builder
|
||
for i, chunk := range chunks {
|
||
fmt.Fprintf(&b, "--- Information %d (Relevanz: %.1f%%) ---\n", i+1, chunk.Score*100)
|
||
b.WriteString(chunk.Text)
|
||
b.WriteString("\n\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// ChatDirect stellt eine Frage direkt an das LLM ohne Datenbankkontext.
|
||
func ChatDirect(question string) (string, error) {
|
||
ctx := context.Background()
|
||
chatClient := config.NewChatClient()
|
||
|
||
systemPrompt := `Du bist ein hilfreicher persönlicher Assistent. Antworte auf Deutsch, präzise und direkt.`
|
||
|
||
slog.Debug("[LLM] ChatDirect Prompt",
|
||
"model", config.Cfg.Chat.Model,
|
||
"user", question,
|
||
)
|
||
|
||
start := time.Now()
|
||
stream, err := chatClient.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
|
||
Model: config.Cfg.Chat.Model,
|
||
Messages: []openai.ChatCompletionMessage{
|
||
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
|
||
{Role: openai.ChatMessageRoleUser, Content: question},
|
||
},
|
||
Temperature: 0.7,
|
||
MaxTokens: 500,
|
||
})
|
||
if err != nil {
|
||
return "", 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.Debug("[LLM] ChatDirect Antwort",
|
||
"dauer", time.Since(start).Round(time.Millisecond),
|
||
"zeichen", answer.Len(),
|
||
"antwort", answer.String(),
|
||
)
|
||
|
||
return answer.String(), nil
|
||
}
|
||
|
||
func floatPtr(f float32) *float32 { return &f }
|