KnowledgeRefinery/daemon-go/internal/pipeline/embedder.go
oho 38a99476d6 Knowledge Refinery: local-first semantic search & 3D concept visualization
macOS app for corpus ingestion, semantic search, and concept universe
visualization powered by local LLMs via LM Studio.

Architecture:
- Go daemon (17MB single binary, zero dependencies)
  - chi router, pure-Go SQLite, tiktoken tokenizer
  - 6-stage pipeline: scan → extract → chunk → embed → annotate → conceptualize
  - Brute-force cosine vector search in memory
  - 89 tests across 8 packages
- SwiftUI app (macOS 15+)
  - Multi-workspace management with auto-start daemons
  - Live pipeline progress, search, concept browser
  - WebGPU 3D universe renderer with Canvas2D fallback
  - Custom crystal app icon
2026-02-13 18:09:46 +01:00

118 lines
2.7 KiB
Go

package pipeline
import (
"log/slog"
"github.com/oho/knowledge-refinery-daemon/internal/lmstudio"
"github.com/oho/knowledge-refinery-daemon/internal/storage"
)
// Embedder embeds chunks via LM Studio and stores vectors.
type Embedder struct {
lm *lmstudio.Client
vs *storage.VectorStore
db *storage.Database
batchSize int
model *string
dimDetected bool
}
func NewEmbedder(lm *lmstudio.Client, vs *storage.VectorStore, db *storage.Database, batchSize int) *Embedder {
return &Embedder{
lm: lm,
vs: vs,
db: db,
batchSize: batchSize,
}
}
// EmbedChunks embeds a list of chunks and stores them in the vector store.
func (e *Embedder) EmbedChunks(chunks []storage.Chunk) int {
if len(chunks) == 0 {
return 0
}
// Auto-detect model
if e.model == nil {
e.model = e.lm.GetEmbeddingModel()
if e.model == nil {
slog.Error("No embedding model available in LM Studio")
return 0
}
}
// Detect dimension from a test call
if !e.dimDetected {
vec, err := e.lm.EmbedSingle("hello world", e.model)
if err != nil {
slog.Error("Failed to detect embedding dimension", "error", err)
return 0
}
e.vs.SetDimension(len(vec))
e.dimDetected = true
slog.Info("Detected embedding dimension", "dim", len(vec), "model", *e.model)
}
embeddedCount := 0
for i := 0; i < len(chunks); i += e.batchSize {
end := i + e.batchSize
if end > len(chunks) {
end = len(chunks)
}
batch := chunks[i:end]
texts := make([]string, len(batch))
for j, c := range batch {
texts[j] = c.ChunkText
}
rawVecs, err := e.lm.Embed(texts, e.model)
if err != nil {
slog.Error("Embedding batch failed", "error", err)
continue
}
// Build vector records
records := make([]storage.VectorRecord, len(batch))
for j, c := range batch {
// Convert float64 to float32
vec := make([]float32, len(rawVecs[j]))
for k, v := range rawVecs[j] {
vec[k] = float32(v)
}
asset, _ := e.db.GetFileAsset(c.AssetID)
assetPath := ""
if asset != nil {
assetPath = asset.Path
}
records[j] = storage.VectorRecord{
ID: c.ID,
Vector: vec,
Text: c.ChunkText,
AssetID: c.AssetID,
AssetPath: assetPath,
EvidenceAnchor: c.EvidenceAnchor,
PipelineVersion: c.PipelineVersion,
AtomType: "text",
}
}
if err := e.vs.AddVectors(records); err != nil {
slog.Error("Failed to add vectors", "error", err)
continue
}
// Mark chunks as having embeddings
for _, c := range batch {
e.db.UpdateChunkEmbedding(c.ID, c.ID)
}
embeddedCount += len(batch)
slog.Info("Embedded batch", "batch", i/e.batchSize+1, "count", len(batch))
}
return embeddedCount
}