Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 46 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,18 +163,59 @@ roughly 100K vectors of 384 dimensions (benchmark: ~100ms on Apple M3 Pro).
Beyond that, implement `VectorStore` with pgvector, Qdrant, or a native
sqlite-vec extension backend.

## Embedder

The `Embedder` interface turns text into dense vectors for semantic retrieval:

```go
import (
"context"
"errors"

"github.com/mathomhaus/lore/pkg/lore/embed"
"github.com/mathomhaus/lore/pkg/lore/embed/bge"
)

func embedTexts(ctx context.Context, texts []string) ([][]float32, error) {
emb, err := bge.New()
if err != nil {
if errors.Is(err, embed.ErrUnsupported) {
// Platform has no ONNX Runtime; fall through to lexical-only retrieval.
return nil, err
}
return nil, err
}
defer emb.Close(ctx)

vecs, err := emb.Embed(ctx, texts)
if err != nil {
return nil, err
}
// Each vecs[i] is a float32 slice of length emb.Dimensions() (384 for BGE-small).
return vecs, nil
}
```

`bge.New` options:

- `bge.WithLogger(*slog.Logger)` for a structured logger covering init and runtime warnings.
- `bge.WithTracer(trace.Tracer)` for an OTel tracer; spans named `lore.embed.encode`.

The BGE reference implementation requires the ONNX Runtime shared library on the
host (e.g. `brew install onnxruntime` on macOS). Set `LORE_ONNXRUNTIME_LIB` to
override the default search path. When the library is absent, `bge.New` returns
`embed.ErrUnsupported` and callers should fall back to lexical retrieval.

Implement the `embed.Embedder` interface to swap in a remote embedding API or a
different model without changing any retrieval code.

## Attribution

Lore extracts and generalizes the storage, embedding, and retrieval primitives
originally built inside [`mathomhaus/guild`](https://github.com/mathomhaus/guild).
Guild remains the opinionated agent-coordination platform that adds
quest, oath, and brief on top of these primitives.

## Spec

The architectural rationale and product positioning that informs this library
lives in the maintainer's notes at
`~/Library/CloudStorage/SynologyDrive-Obsidian/Personal/01 Projects/Agent Guild/Positioning/lore-product-mvp-2026-04-27.md`.

## License

Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/mathomhaus/lore
go 1.25.0

require (
github.com/shota3506/onnxruntime-purego v0.0.0-20260315223538-8db8bd7424b2
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
modernc.org/sqlite v1.50.0
Expand All @@ -11,6 +12,7 @@ require (
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
Expand All @@ -25,6 +27,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/shota3506/onnxruntime-purego v0.0.0-20260315223538-8db8bd7424b2 h1:jW34+ipjoob+BfuLfALS6FBJh84KJcmzFowmxsImA/o=
github.com/shota3506/onnxruntime-purego v0.0.0-20260315223538-8db8bd7424b2/go.mod h1:jsOuI9QymOruQ9UdLrCbMPFykhI13d5FcuV60R1wJ6Q=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
Expand Down
14 changes: 14 additions & 0 deletions pkg/lore/embed/bge/assets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package bge

import _ "embed"

// modelBytes is the int8-quantized BAAI/bge-small-en-v1.5 ONNX model,
// embedded at build time. The model file is ~34 MB.
//
//go:embed model/model.onnx
var modelBytes []byte

// vocabBytes is the WordPiece vocabulary for bge-small-en-v1.5.
//
//go:embed model/vocab.txt
var vocabBytes []byte
75 changes: 75 additions & 0 deletions pkg/lore/embed/bge/bge.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Package bge provides an in-process embedder backed by an int8-quantized
// BAAI/bge-small-en-v1.5 model. The model and vocabulary files are bundled
// via go:embed so no network access, no external binaries, and no cgo are
// required at runtime.
//
// Platform support mirrors guild's embed package: unix (darwin + linux,
// amd64 + arm64). On other platforms New returns ErrUnsupported so callers
// can fall through to lexical-only retrieval.
//
// Usage:
//
// emb, err := bge.New()
// if err != nil {
// // handle ErrUnsupported or init failure
// }
// defer emb.Close(ctx)
//
// vecs, err := emb.Embed(ctx, []string{"hello world"})
package bge

import (
"log/slog"

"go.opentelemetry.io/otel/trace"

"github.com/mathomhaus/lore/pkg/lore/embed"
)

// Option configures a BGE embedder at construction time.
type Option func(*config)

// WithLogger sets the structured logger used for initialization messages
// and runtime warnings. If not provided, slog.Default() is used.
func WithLogger(l *slog.Logger) Option {
return func(c *config) {
c.logger = l
}
}

// WithTracer sets the OpenTelemetry tracer used to instrument Embed calls.
// If not provided, the no-op tracer is used.
func WithTracer(t trace.Tracer) Option {
return func(c *config) {
c.tracer = t
}
}

type config struct {
logger *slog.Logger
tracer trace.Tracer
}

// New returns an Embedder backed by the bundled int8-quantized BGE-small
// model. The model is loaded from go:embed assets at first call; no network
// access is performed. Returns ErrUnsupported on platforms where the ONNX
// runtime is not available.
//
// Callers must call Close when done to release the loaded model.
func New(opts ...Option) (embed.Embedder, error) {
cfg := &config{}
for _, o := range opts {
o(cfg)
}
if cfg.logger == nil {
cfg.logger = slog.Default()
}
if cfg.tracer == nil {
cfg.tracer = trace.NewNoopTracerProvider().Tracer("lore.embed.bge")
}
return newEmbedder(cfg)
}

// newEmbedder is the platform-dispatched constructor. Implemented in
// bge_unix.go on supported platforms; the stub in bge_unsupported.go
// returns ErrUnsupported everywhere else.
136 changes: 136 additions & 0 deletions pkg/lore/embed/bge/bge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package bge_test

import (
"context"
"errors"
"testing"

"github.com/mathomhaus/lore/pkg/lore/embed"
"github.com/mathomhaus/lore/pkg/lore/embed/bge"
)

// newOrSkip constructs a BGE embedder; skips the test if the platform is
// unsupported or the ONNX runtime library is not installed.
func newOrSkip(t *testing.T) embed.Embedder {
t.Helper()
emb, err := bge.New()
if err != nil {
if errors.Is(err, embed.ErrUnsupported) {
t.Skipf("skipping: %v", err)
}
t.Fatalf("bge.New: %v", err)
}
t.Cleanup(func() {
if err := emb.Close(context.Background()); err != nil {
t.Errorf("Close: %v", err)
}
})
return emb
}

// TestEmbed_Dimensions verifies that every output vector has length Dimensions().
func TestEmbed_Dimensions(t *testing.T) {
emb := newOrSkip(t)
ctx := context.Background()

texts := []string{"hello world", "retrieval augmented generation", "agent memory"}
vecs, err := emb.Embed(ctx, texts)
if err != nil {
t.Fatalf("Embed: %v", err)
}
if len(vecs) != len(texts) {
t.Fatalf("got %d vectors, want %d", len(vecs), len(texts))
}
for i, v := range vecs {
if len(v) != emb.Dimensions() {
t.Errorf("vecs[%d] has length %d, want %d", i, len(v), emb.Dimensions())
}
}
}

// TestEmbed_Determinism verifies that the same input produces identical
// output on two consecutive calls.
func TestEmbed_Determinism(t *testing.T) {
emb := newOrSkip(t)
ctx := context.Background()
text := "deterministic embedding test"

v1, err := emb.Embed(ctx, []string{text})
if err != nil {
t.Fatalf("first Embed: %v", err)
}
v2, err := emb.Embed(ctx, []string{text})
if err != nil {
t.Fatalf("second Embed: %v", err)
}

if len(v1[0]) != len(v2[0]) {
t.Fatalf("vector length mismatch: %d vs %d", len(v1[0]), len(v2[0]))
}
for i := range v1[0] {
if v1[0][i] != v2[0][i] {
t.Errorf("v1[0][%d]=%v != v2[0][%d]=%v", i, v1[0][i], i, v2[0][i])
}
}
}

// TestEmbed_BatchSizes verifies that batches of size 1, 5, and 100 all succeed.
func TestEmbed_BatchSizes(t *testing.T) {
emb := newOrSkip(t)
ctx := context.Background()

for _, n := range []int{1, 5, 100} {
texts := make([]string, n)
for i := range texts {
texts[i] = "batch test item"
}
vecs, err := emb.Embed(ctx, texts)
if err != nil {
t.Errorf("Embed(batch=%d): %v", n, err)
continue
}
if len(vecs) != n {
t.Errorf("Embed(batch=%d): got %d vectors, want %d", n, len(vecs), n)
}
}
}

// TestEmbed_EmptyInput verifies that an empty texts slice returns ErrInvalidArgument.
func TestEmbed_EmptyInput(t *testing.T) {
emb := newOrSkip(t)
_, err := emb.Embed(context.Background(), []string{})
if !errors.Is(err, embed.ErrInvalidArgument) {
t.Errorf("expected ErrInvalidArgument, got %v", err)
}
}

// TestEmbed_EmptyString verifies that a slice containing an empty string returns ErrInvalidArgument.
func TestEmbed_EmptyString(t *testing.T) {
emb := newOrSkip(t)
_, err := emb.Embed(context.Background(), []string{"valid", "", "also valid"})
if !errors.Is(err, embed.ErrInvalidArgument) {
t.Errorf("expected ErrInvalidArgument, got %v", err)
}
}

// TestClose_Idempotent verifies that calling Close multiple times does not error.
func TestClose_Idempotent(t *testing.T) {
emb := newOrSkip(t)
ctx := context.Background()

if err := emb.Close(ctx); err != nil {
t.Fatalf("first Close: %v", err)
}
if err := emb.Close(ctx); err != nil {
t.Errorf("second Close: %v", err)
}
}

// TestEmbed_DimensionsConst verifies that Dimensions() returns the package-
// level Dim constant.
func TestEmbed_DimensionsConst(t *testing.T) {
emb := newOrSkip(t)
if emb.Dimensions() != embed.Dim {
t.Errorf("Dimensions()=%d, want %d", emb.Dimensions(), embed.Dim)
}
}
Loading
Loading