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:
viettranx committed 2026-03-29 12:15:21 +07:00
1 parent 97f4942943
commit 2e869f4ece
22 files changed
+1693 -57

No files matched your search

+47 -13
View File
@@ -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)
}
}