mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 16:13:53 +00:00
feat(kg): add tsvector FTS, entity deduplication, and embedding fixes
- Replace ILIKE search with PostgreSQL tsvector/GIN full-text search for KG entities (migration 000031) - Add entity deduplication system with dual-threshold strategy: auto-merge at 0.98+ similarity with Jaro-Winkler name match, flag 0.90-0.98 as candidates for manual review - Add ScanDuplicates for bulk on-demand duplicate detection - Add MergeEntities with advisory lock and tenant-scoped relation re-pointing (delete-then-update to avoid ON CONFLICT on UPDATE) - Wire dedup inline after KG extraction pipeline - Fix BackfillKGEmbeddings: was failing silently due to context.Background() missing tenant_id; now runs cross-tenant - Fix BackfillKGEmbeddings: break on error → continue with failed ID tracking and max consecutive error cap - Add EmbedEntity helper; UpsertEntity now generates embeddings in background goroutine - Add HTTP endpoints: POST /kg/dedup/scan, GET /kg/dedup, POST /kg/merge, POST /kg/dedup/dismiss - Add web UI: Dedup dialog with Scan All button, side-by-side entity comparison, merge/dismiss actions - Add Jaro-Winkler similarity algorithm with 34 unit tests - Update IngestExtraction to return upserted entity IDs - Bump RequiredSchemaVersion to 31
This commit is contained in:
1 parent
97f4942943
commit
2e869f4ece
22 files changed
+1693
-57
No files matched your search
@@ -2,7 +2,6 @@ package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -10,27 +9,29 @@ import (
|
||||
|
||||
// BackfillKGEmbeddings generates embeddings for all KG entities that don't have one yet.
|
||||
// Processes in batches of 50. Returns total number of entities updated.
|
||||
// On batch-level embedding failure, skips the batch and continues (up to 3 consecutive failures).
|
||||
func (s *PGKnowledgeGraphStore) BackfillKGEmbeddings(ctx context.Context) (int, error) {
|
||||
if s.embProvider == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
const batchSize = 50
|
||||
const maxConsecutiveErrors = 3
|
||||
total := 0
|
||||
consecutiveErrors := 0
|
||||
|
||||
// $1=batchSize (LIMIT), tenant at $2 if not cross-tenant
|
||||
tc, tcArgs, _, tcErr := scopeClause(ctx, 2)
|
||||
if tcErr != nil {
|
||||
return 0, tcErr
|
||||
}
|
||||
batchQ := fmt.Sprintf(`SELECT id, name, description FROM kg_entities
|
||||
WHERE embedding IS NULL%s
|
||||
// Backfill is a cross-tenant admin operation — no tenant scoping.
|
||||
// context.Background() is typically passed here (no tenant in context).
|
||||
batchQ := `SELECT id, name, description FROM kg_entities
|
||||
WHERE embedding IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1`, tc)
|
||||
batchBaseArgs := tcArgs // tenant uuid(s) prepended after $1
|
||||
LIMIT $1`
|
||||
|
||||
// Track failed entity IDs to avoid re-fetching them
|
||||
failedIDs := make(map[uuid.UUID]bool)
|
||||
|
||||
for {
|
||||
queryArgs := append([]any{batchSize}, batchBaseArgs...)
|
||||
queryArgs := []any{batchSize}
|
||||
rows, err := s.db.QueryContext(ctx, batchQ, queryArgs...)
|
||||
if err != nil {
|
||||
return total, err
|
||||
@@ -47,6 +48,9 @@ func (s *PGKnowledgeGraphStore) BackfillKGEmbeddings(ctx context.Context) (int,
|
||||
if err := rows.Scan(&id, &name, &desc); err != nil {
|
||||
continue
|
||||
}
|
||||
if failedIDs[id] {
|
||||
continue // skip previously failed entities
|
||||
}
|
||||
pending = append(pending, entityRow{id: id, text: name + " " + desc})
|
||||
}
|
||||
rows.Close()
|
||||
@@ -63,9 +67,19 @@ func (s *PGKnowledgeGraphStore) BackfillKGEmbeddings(ctx context.Context) (int,
|
||||
}
|
||||
embeddings, err := s.embProvider.Embed(ctx, texts)
|
||||
if err != nil {
|
||||
slog.Warn("kg entity embedding batch failed", "error", err)
|
||||
break
|
||||
slog.Warn("kg entity embedding batch failed, skipping batch", "error", err, "batch_size", len(pending))
|
||||
// Mark these entities as failed so we don't re-fetch them
|
||||
for _, p := range pending {
|
||||
failedIDs[p.id] = true
|
||||
}
|
||||
consecutiveErrors++
|
||||
if consecutiveErrors >= maxConsecutiveErrors {
|
||||
slog.Warn("kg backfill: too many consecutive errors, stopping", "errors", consecutiveErrors)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
consecutiveErrors = 0 // reset on success
|
||||
|
||||
for i, emb := range embeddings {
|
||||
if len(emb) == 0 {
|
||||
@@ -92,3 +106,23 @@ func (s *PGKnowledgeGraphStore) BackfillKGEmbeddings(ctx context.Context) (int,
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// EmbedEntity generates and stores an embedding for a single entity.
|
||||
// Called by UpsertEntity to ensure entities created via HTTP API also get embeddings.
|
||||
func (s *PGKnowledgeGraphStore) EmbedEntity(ctx context.Context, entityID, name, description string) {
|
||||
if s.embProvider == nil {
|
||||
return
|
||||
}
|
||||
text := name + " " + description
|
||||
embeddings, err := s.embProvider.Embed(ctx, []string{text})
|
||||
if err != nil || len(embeddings) == 0 || len(embeddings[0]) == 0 {
|
||||
return // best-effort, don't fail the upsert
|
||||
}
|
||||
vecStr := vectorToString(embeddings[0])
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE kg_entities SET embedding = $1::vector WHERE id = $2`,
|
||||
vecStr, mustParseUUID(entityID),
|
||||
); err != nil {
|
||||
slog.Warn("kg entity embedding failed", "entity_id", entityID, "error", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user