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
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: CI

on:
push:
branches: [ "main", "master" ]
pull_request:
branches: [ "main", "master" ]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.22"

- name: Verify gofmt
run: |
files=$(gofmt -l .)
if [ -n "$files" ]; then
echo "These files are not formatted:"
echo "$files"
exit 1
fi

- name: Go vet
run: go vet ./...

- name: Test with race detector and coverage
run: |
go test ./... -race -coverprofile=coverage.out -covermode=atomic

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: coverage.out
token: e2791bc0-4d3b-47a1-b04f-ed6f1f5bff17
flags: unittests
fail_ci_if_error: true
58 changes: 58 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
APP := goembedx
PKG := ./...
EXAMPLE := ./examples/basic.go
CLI := ./cmd/goembedx
COVER_FILE := coverage.out

.PHONY: all fmt lint test bench cover build example clean

all: fmt lint test

## ---------- Dev Commands ----------
fmt:
@echo "🧹 Formatting code..."
go fmt $(PKG)

lint:
@echo "🔍 Running basic lint (go vet)..."
go vet $(PKG)

test:
@echo "✅ Running tests with race detector..."
go test -race -cover -coverprofile=$(COVER_FILE) $(PKG)

bench:
@echo "🏎️ Benchmarking vector ops..."
go test -bench=. -benchmem ./vector

cover: test
@echo "📊 Coverage report at $(COVER_FILE)"
go tool cover -html=$(COVER_FILE)

## ---------- Build ----------
build:
@echo "🔧 Building CLI..."
go build -o bin/$(APP) $(CLI)

example:
@echo "▶️ Running example..."
go run $(EXAMPLE)

## ---------- Utilities ----------
clean:
@echo "🧽 Cleaning workspace..."
rm -rf bin/
rm -f $(COVER_FILE)

help:
@echo "Usage: make [target]"
@echo ""
@echo "Targets:"
@echo " fmt Format code"
@echo " lint Static analysis"
@echo " test Tests w/ race + coverage"
@echo " bench Run benchmarks"
@echo " cover Open coverage UI"
@echo " build Build CLI"
@echo " example Run example program"
@echo " clean Clean build artifacts"
60 changes: 57 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# goembedx 🧠⚡
# goembedx 🧠⚡
> Lightweight local embedding store for Go — pure Go, zero dependencies, blazing fast nearest-vector search.

[![Go Reference](https://pkg.go.dev/badge/github.com/ldaidone/goembedx.svg)](https://pkg.go.dev/github.com/ldaidone/goembedx)
[![Go Report Card](https://goreportcard.com/badge/github.com/ldaidone/goembedx)](https://goreportcard.com/report/github.com/ldaidone/goembedx)
![Stars](https://img.shields.io/github/stars/ldaidone/goembedx?style=social)
[![License](https://img.shields.io/badge/license-Apache_2.0-blue.svg)](LICENSE)
[![CI](https://github.com/ldaidone/goembedx/actions/workflows/ci.yml/badge.svg)](https://github.com/ldaidone/goembedx/actions/workflows/ci.yml)
[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](#)
[![Build](https://github.com/ldaidone/goembedx/actions/workflows/ci.yml/badge.svg)](https://github.com/ldaidone/goembedx/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/ldaidone/goembedx/branch/main/graph/badge.svg)](https://codecov.io/gh/ldaidone/goembedx)


> 💡 **goembedx** is a tiny vector database for embeddings — perfect for local LLM agents, RAG systems, and semantic search inside Go applications.

Expand All @@ -21,3 +22,56 @@

---

### 🚀 Quick Start

```go
import (
"fmt"

"github.com/ldaidone/goembedx"
)

func main() {
store := goembedx.New(384) // 384-dim example (MiniLM, etc.)

store.Add("doc1", []float32{ /* embedding */ })
store.Add("doc2", []float32{ /* embedding */ })

query := []float32{ /* embedding */ }
results := store.Search(query, 3)

for _, r := range results {
fmt.Println(r.ID, r.Score)
}
}
```

### 📦 Install

```bash
go get github.com/ldaidone/goembedx
```

### 🔭 Roadmap

- ✅ In-memory vector store
- ✅ Cosine similarity + Top-K
- 🧩 File-based persistence (.embedx)
- 🧠 Optional ANN index (HNSW lite)
- 🤖 Ollama & HF embedding helpers
- 🔌 goembedx serve — REST API mode

### 🧪 Testing

```bash
go test ./...
```

## License

Apache 2.0 License - see the [LICENSE](LICENSE) file for details.

## Support

If this saves you time or helps your AI project, consider starring ⭐
and consider [buying me a coffee](https://www.buymeacoffee.com/leodaido)! ☕️ — it keeps the ideas flowing!
115 changes: 115 additions & 0 deletions cmd/goembedx/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package main

import (
"bufio"
"flag"
"fmt"
"os"
"strconv"
"strings"

"github.com/ldaidone/goembedx"
)

var (
dim = flag.Int("dim", 3, "Dimension of vectors")
topK = flag.Int("k", 3, "Top-K results")
mode = flag.String("mode", "interactive", "Mode: add|query|interactive")
id = flag.String("id", "", "ID for add mode")
vecFlag = flag.String("vec", "", "Comma-separated vector")
)

func parseVec(s string, dimension int) ([]float32, error) {
parts := strings.Split(s, ",")
if len(parts) != dimension {
return nil, fmt.Errorf("expected %d elements, got %d", dimension, len(parts))
}
v := make([]float32, len(parts))
for i, p := range parts {
f, err := strconv.ParseFloat(strings.TrimSpace(p), 32)
if err != nil {
return nil, err
}
v[i] = float32(f)
}
return v, nil
}

func main() {
flag.Parse()
store := goembedx.MemoryStore(*dim)

switch *mode {
case "add":
v, err := parseVec(*vecFlag, *dim)
if err != nil {
fmt.Println("parse error:", err)
os.Exit(1)
}
if err := goembedx.AddVector(store, *id, v); err != nil {
fmt.Println("add failed:", err)
os.Exit(1)
}
fmt.Println("✅ Added:", *id)
return

case "query":
v, err := parseVec(*vecFlag, *dim)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
results, err := goembedx.SearchTopK(store, v, *topK)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("🔎 Results:")
for _, r := range results {
fmt.Printf(" %s => %.5f\n", r.ID, r.Score)
}
return
}

// interactive REPL mode (phase-1 lightweight)
fmt.Println("goembedx interactive mode")
fmt.Println("commands:")
fmt.Println(" add <id> 1,2,3")
fmt.Println(" query 1,2,3")
reader := bufio.NewScanner(os.Stdin)

for {
fmt.Print("> ")
if !reader.Scan() {
break
}
line := strings.TrimSpace(reader.Text())

if strings.HasPrefix(line, "add ") {
fields := strings.Fields(line)
id := fields[1]
v, err := parseVec(fields[2], *dim)
if err != nil {
fmt.Println("⚠️ parse:", err)
continue
}
_ = goembedx.AddVector(store, id, v)
fmt.Println("✅ ok")
continue
}

if strings.HasPrefix(line, "query ") {
fields := strings.Fields(line)
v, err := parseVec(fields[1], *dim)
if err != nil {
fmt.Println("⚠️ parse:", err)
continue
}
results, _ := goembedx.SearchTopK(store, v, *topK)
for _, r := range results {
fmt.Printf(" %s => %.5f\n", r.ID, r.Score)
}
continue
}
}
}
27 changes: 27 additions & 0 deletions examples/basic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package main

import (
"fmt"

"github.com/ldaidone/goembedx/search"
"github.com/ldaidone/goembedx/store"
"github.com/ldaidone/goembedx/vector"
)

func main() {
// small demo showing add + search
s := store.NewMemoryStore(3)
_ = s.Add("doc1", []float32{1, 0, 0})
_ = s.Add("doc2", []float32{0.9, 0.1, 0})
_ = s.Add("doc3", []float32{0, 1, 0})

query := []float32{1, 0, 0}
results := search.SearchBrute(s, query, 2)

fmt.Println("Top results:")
for i, r := range results {
fmt.Printf("%d) id=%s score=%.5f\n", i+1, r.ID, r.Score)
}
// show cosine computed directly
fmt.Println("Cosine(doc1,query) =", vector.Cosine([]float32{1, 0, 0}, query))
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/ldaidone/goembedx

go 1.25.3
40 changes: 40 additions & 0 deletions goembedx.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package goembedx

import (
"errors"

"github.com/ldaidone/goembedx/search"
"github.com/ldaidone/goembedx/store"
)

// Store is the top-level interface to the vector storage engine.
// For now we only expose memory, but keep interface future-proof when sqlite/bolt arrive.
type Store interface {
Add(id string, vec []float32) error
Len() int
Dim() int
Data() []store.Vector
}

// MemoryStore returns an in-memory vector store.
// dim = embedding dimensionality (e.g. 384, 512, 768, 1024)
func MemoryStore(dim int) Store {
return store.NewMemoryStore(dim)
}

// AddVector adds a vector to any store (helper for fluent API).
func AddVector(s Store, id string, vec []float32) error {
return s.Add(id, vec)
}

// SearchTopK performs brute-force cosine similarity search.
// k <= 0 means "return all".
func SearchTopK(s Store, query []float32, k int) ([]search.Result, error) {
if s == nil {
return nil, errors.New("nil store")
}
if len(query) != s.Dim() {
return nil, errors.New("query dimension mismatch")
}
return search.SearchBrute(s.(*store.MemoryStore), query, k), nil
}
Loading
Loading