release-tag / Resolve release metadata (push) Successful in 30s
release-tag / Build knowledge (push) Failing after 4m51s
release-tag / Build control (push) Failing after 5m0s
release-tag / Build agent (push) Failing after 5m0s
release-tag / Build agent-data-init (push) Failing after 5m5s
release-tag / Build neuroforge-worker (push) Failing after 5m7s
release-tag / Build neuroforge (push) Failing after 5m9s
884 lines
22 KiB
Go
884 lines
22 KiB
Go
package vector
|
|
|
|
import (
|
|
"bufio"
|
|
"container/heap"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// PQConfig configures the disk-backed IVF-PQ index. Vectors are assumed to be
|
|
// cosine-search vectors and are normalized before training/encoding.
|
|
type PQConfig struct {
|
|
Partitions int `json:"partitions"`
|
|
ProbePartitions int `json:"probe_partitions"`
|
|
Subquantizers int `json:"subquantizers"`
|
|
Centroids int `json:"centroids"`
|
|
TrainingSamples int `json:"training_samples"`
|
|
KMeansIters int `json:"kmeans_iters"`
|
|
BuildWorkers int `json:"build_workers"`
|
|
}
|
|
|
|
type PQBuildStats struct {
|
|
Dimension int `json:"dimension"`
|
|
Items int `json:"items"`
|
|
Partitions int `json:"partitions"`
|
|
Subquantizers int `json:"subquantizers"`
|
|
Centroids int `json:"centroids"`
|
|
TrainingSamples int `json:"training_samples"`
|
|
Bytes int64 `json:"bytes"`
|
|
Duration time.Duration `json:"duration"`
|
|
}
|
|
|
|
type PQHit struct {
|
|
ID string `json:"id"`
|
|
Similarity float64 `json:"similarity"`
|
|
}
|
|
|
|
type pqManifest struct {
|
|
Version int `json:"version"`
|
|
Dimension int `json:"dimension"`
|
|
// Count is the ordinal/ID-table size. Indexed is the number of records
|
|
// actually present in partition files. They may differ when a vector is
|
|
// deleted while a lock-free rebuild is in progress.
|
|
Count int `json:"count"`
|
|
Indexed int `json:"indexed,omitempty"`
|
|
Config PQConfig `json:"config"`
|
|
Coarse [][]float32 `json:"coarse"`
|
|
Codebooks [][][]float32 `json:"codebooks"`
|
|
PartitionCount []int `json:"partition_count"`
|
|
}
|
|
|
|
type PQIndex struct {
|
|
dir string
|
|
manifest pqManifest
|
|
files []*os.File
|
|
sizes []int64
|
|
idFile *os.File
|
|
idOffsets []uint64
|
|
}
|
|
|
|
func defaultPQConfig(cfg PQConfig, dim int) PQConfig {
|
|
if cfg.Partitions < 2 {
|
|
cfg.Partitions = 128
|
|
}
|
|
if cfg.ProbePartitions < 1 {
|
|
cfg.ProbePartitions = 48
|
|
}
|
|
if cfg.ProbePartitions > cfg.Partitions {
|
|
cfg.ProbePartitions = cfg.Partitions
|
|
}
|
|
if cfg.Subquantizers < 1 {
|
|
cfg.Subquantizers = 16
|
|
}
|
|
if cfg.Subquantizers > dim {
|
|
cfg.Subquantizers = dim
|
|
}
|
|
if cfg.Centroids < 2 {
|
|
cfg.Centroids = 128
|
|
}
|
|
if cfg.Centroids > 256 {
|
|
cfg.Centroids = 256
|
|
}
|
|
if cfg.TrainingSamples < cfg.Centroids*4 {
|
|
cfg.TrainingSamples = 8192
|
|
}
|
|
if cfg.KMeansIters < 1 {
|
|
cfg.KMeansIters = 6
|
|
}
|
|
if cfg.BuildWorkers < 1 {
|
|
cfg.BuildWorkers = runtime.GOMAXPROCS(0)
|
|
}
|
|
if cfg.BuildWorkers > 64 {
|
|
cfg.BuildWorkers = 64
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func partitionPath(dir string, p int) string {
|
|
return filepath.Join(dir, fmt.Sprintf("part-%04d.pq", p))
|
|
}
|
|
|
|
func writeJSONAtomic(path string, v any) error {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|
|
|
|
func l2norm(v []float32) {
|
|
var s float64
|
|
for _, x := range v {
|
|
s += float64(x * x)
|
|
}
|
|
if s == 0 {
|
|
return
|
|
}
|
|
inv := float32(1 / math.Sqrt(s))
|
|
for i := range v {
|
|
v[i] *= inv
|
|
}
|
|
}
|
|
|
|
func sqDist(a, b []float32) float32 {
|
|
var s float32
|
|
for i := range a {
|
|
d := a[i] - b[i]
|
|
s += d * d
|
|
}
|
|
return s
|
|
}
|
|
|
|
func nearest(v []float32, centers [][]float32) int {
|
|
best := 0
|
|
bestD := float32(math.MaxFloat32)
|
|
for i, c := range centers {
|
|
d := sqDist(v, c)
|
|
if d < bestD {
|
|
bestD, best = d, i
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// deterministicKMeans intentionally avoids random initialization. A stable,
|
|
// farthest-point seed makes rebuilds reproducible and sufficiently diverse for
|
|
// the IVF/PQ coarse codebooks used here.
|
|
func deterministicKMeans(samples [][]float32, k, iters int) [][]float32 {
|
|
if len(samples) == 0 {
|
|
return nil
|
|
}
|
|
if k > len(samples) {
|
|
k = len(samples)
|
|
}
|
|
dim := len(samples[0])
|
|
centers := make([][]float32, 0, k)
|
|
centers = append(centers, append([]float32(nil), samples[0]...))
|
|
minDist := make([]float32, len(samples))
|
|
for i := range minDist {
|
|
minDist[i] = float32(math.MaxFloat32)
|
|
}
|
|
for len(centers) < k {
|
|
last := centers[len(centers)-1]
|
|
far, farD := 0, float32(-1)
|
|
for i, s := range samples {
|
|
d := sqDist(s, last)
|
|
if d < minDist[i] {
|
|
minDist[i] = d
|
|
}
|
|
if minDist[i] > farD {
|
|
farD, far = minDist[i], i
|
|
}
|
|
}
|
|
centers = append(centers, append([]float32(nil), samples[far]...))
|
|
}
|
|
assign := make([]int, len(samples))
|
|
for iter := 0; iter < iters; iter++ {
|
|
sums := make([][]float64, k)
|
|
counts := make([]int, k)
|
|
for i := range sums {
|
|
sums[i] = make([]float64, dim)
|
|
}
|
|
changed := false
|
|
for i, s := range samples {
|
|
a := nearest(s, centers)
|
|
if iter == 0 || assign[i] != a {
|
|
changed = true
|
|
assign[i] = a
|
|
}
|
|
counts[a]++
|
|
for d, x := range s {
|
|
sums[a][d] += float64(x)
|
|
}
|
|
}
|
|
for c := 0; c < k; c++ {
|
|
if counts[c] == 0 {
|
|
continue
|
|
}
|
|
inv := 1 / float64(counts[c])
|
|
for d := 0; d < dim; d++ {
|
|
centers[c][d] = float32(sums[c][d] * inv)
|
|
}
|
|
}
|
|
if !changed {
|
|
break
|
|
}
|
|
}
|
|
return centers
|
|
}
|
|
|
|
func subBounds(dim, m, sub int) (int, int) {
|
|
base, rem := dim/m, dim%m
|
|
start := sub*base + minIntPQ(sub, rem)
|
|
n := base
|
|
if sub < rem {
|
|
n++
|
|
}
|
|
return start, start + n
|
|
}
|
|
func minIntPQ(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func residual(v, coarse []float32) []float32 {
|
|
out := make([]float32, len(v))
|
|
for i := range v {
|
|
out[i] = v[i] - coarse[i]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func trainPQ(samples [][]float32, coarse [][]float32, m, ks, iters int) [][][]float32 {
|
|
residuals := make([][]float32, len(samples))
|
|
for i, v := range samples {
|
|
c := nearest(v, coarse)
|
|
residuals[i] = residual(v, coarse[c])
|
|
}
|
|
books := make([][][]float32, m)
|
|
dim := len(samples[0])
|
|
workers := minIntPQ(m, maxIntPQ(1, runtime.GOMAXPROCS(0)))
|
|
jobs := make(chan int, m)
|
|
var wg sync.WaitGroup
|
|
for w := 0; w < workers; w++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for sub := range jobs {
|
|
a, b := subBounds(dim, m, sub)
|
|
subv := make([][]float32, len(residuals))
|
|
for i, r := range residuals {
|
|
subv[i] = append([]float32(nil), r[a:b]...)
|
|
}
|
|
books[sub] = deterministicKMeans(subv, ks, iters)
|
|
}
|
|
}()
|
|
}
|
|
for sub := 0; sub < m; sub++ {
|
|
jobs <- sub
|
|
}
|
|
close(jobs)
|
|
wg.Wait()
|
|
return books
|
|
}
|
|
|
|
func maxIntPQ(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func encodePQInto(v, coarse []float32, books [][][]float32, out []byte) {
|
|
m := len(books)
|
|
for sub, book := range books {
|
|
a, b := subBounds(len(v), m, sub)
|
|
best := 0
|
|
bestD := float32(math.MaxFloat32)
|
|
for ci, cent := range book {
|
|
var d float32
|
|
for j := a; j < b; j++ {
|
|
r := v[j] - coarse[j]
|
|
x := r - cent[j-a]
|
|
d += x * x
|
|
}
|
|
if d < bestD {
|
|
bestD, best = d, ci
|
|
}
|
|
}
|
|
out[sub] = byte(best)
|
|
}
|
|
}
|
|
|
|
type pqRecord struct {
|
|
ordinal uint32
|
|
codeLen uint16
|
|
codes [256]byte
|
|
}
|
|
|
|
// PQVectorIterator streams source vectors to the builder. The callback vector
|
|
// is copied before asynchronous encoding, so callers may reuse their storage
|
|
// after yield returns.
|
|
type PQVectorIterator func(yield func(id string, vector []float32) error) error
|
|
|
|
func trainPQModel(dim int, cfg PQConfig, ids []string, getVector func(string) ([]float32, bool)) (PQConfig, [][]float32, [][][]float32, int, error) {
|
|
cfg = defaultPQConfig(cfg, dim)
|
|
if len(ids) == 0 || getVector == nil {
|
|
return cfg, nil, nil, 0, errors.New("no vectors for PQ training")
|
|
}
|
|
want := cfg.TrainingSamples
|
|
if want > len(ids) {
|
|
want = len(ids)
|
|
}
|
|
samples := make([][]float32, 0, want)
|
|
step := float64(len(ids)) / float64(want)
|
|
for i := 0; i < want; i++ {
|
|
pos := int(float64(i) * step)
|
|
if pos >= len(ids) {
|
|
pos = len(ids) - 1
|
|
}
|
|
v, ok := getVector(ids[pos])
|
|
if !ok || len(v) != dim {
|
|
continue
|
|
}
|
|
cp := append([]float32(nil), v...)
|
|
l2norm(cp)
|
|
samples = append(samples, cp)
|
|
}
|
|
if len(samples) < 2 {
|
|
return cfg, nil, nil, len(samples), errors.New("not enough valid vectors for PQ training")
|
|
}
|
|
if cfg.Partitions > len(samples) {
|
|
cfg.Partitions = len(samples)
|
|
}
|
|
if cfg.Centroids > len(samples) {
|
|
cfg.Centroids = len(samples)
|
|
}
|
|
if cfg.ProbePartitions > cfg.Partitions {
|
|
cfg.ProbePartitions = cfg.Partitions
|
|
}
|
|
coarse := deterministicKMeans(samples, cfg.Partitions, cfg.KMeansIters)
|
|
books := trainPQ(samples, coarse, cfg.Subquantizers, cfg.Centroids, cfg.KMeansIters)
|
|
return cfg, coarse, books, len(samples), nil
|
|
}
|
|
|
|
// BuildPQIndex builds a complete IVF-PQ index from an ID list. It remains the
|
|
// convenient in-memory/random-access API used by tests and small stores.
|
|
func BuildPQIndex(dir string, dim int, cfg PQConfig, ids []string, getVector func(string) ([]float32, bool)) (PQBuildStats, error) {
|
|
return BuildPQIndexStream(dir, dim, cfg, ids, getVector, func(yield func(string, []float32) error) error {
|
|
for _, id := range ids {
|
|
v, ok := getVector(id)
|
|
if !ok || len(v) != dim {
|
|
continue
|
|
}
|
|
if err := yield(id, v); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// BuildPQIndexStream trains from a bounded sample but performs the full encode
|
|
// pass through a sequential iterator. This is the production path for cold
|
|
// segment stores: it avoids one random segment lookup/open per memory and keeps
|
|
// build memory bounded independently of the number of vectors.
|
|
func BuildPQIndexStream(dir string, dim int, cfg PQConfig, sampleIDs []string, getSampleVector func(string) ([]float32, bool), iterate PQVectorIterator) (PQBuildStats, error) {
|
|
start := time.Now()
|
|
if dim < 2 {
|
|
return PQBuildStats{}, errors.New("PQ dimension must be >= 2")
|
|
}
|
|
if iterate == nil {
|
|
return PQBuildStats{}, errors.New("PQ vector iterator required")
|
|
}
|
|
cfg, coarse, books, trainingCount, err := trainPQModel(dim, cfg, sampleIDs, getSampleVector)
|
|
if err != nil {
|
|
return PQBuildStats{}, err
|
|
}
|
|
if err := os.RemoveAll(dir); err != nil {
|
|
return PQBuildStats{}, err
|
|
}
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return PQBuildStats{}, err
|
|
}
|
|
success := false
|
|
defer func() {
|
|
if !success {
|
|
_ = os.RemoveAll(dir)
|
|
}
|
|
}()
|
|
|
|
type writer struct {
|
|
f *os.File
|
|
bw *bufio.Writer
|
|
ch chan pqRecord
|
|
count int
|
|
err error
|
|
}
|
|
writers := make([]*writer, cfg.Partitions)
|
|
for part := 0; part < cfg.Partitions; part++ {
|
|
f, err := os.OpenFile(partitionPath(dir, part), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
for i := 0; i < part; i++ {
|
|
_ = writers[i].f.Close()
|
|
}
|
|
return PQBuildStats{}, err
|
|
}
|
|
writers[part] = &writer{f: f, bw: bufio.NewWriterSize(f, 1<<20), ch: make(chan pqRecord, 256)}
|
|
}
|
|
var writerWG sync.WaitGroup
|
|
for _, w := range writers {
|
|
writerWG.Add(1)
|
|
go func(w *writer) {
|
|
defer writerWG.Done()
|
|
var ord [4]byte
|
|
for rec := range w.ch {
|
|
if w.err != nil {
|
|
continue
|
|
}
|
|
binary.LittleEndian.PutUint32(ord[:], rec.ordinal)
|
|
if _, err := w.bw.Write(ord[:]); err != nil {
|
|
w.err = err
|
|
continue
|
|
}
|
|
if _, err := w.bw.Write(rec.codes[:rec.codeLen]); err != nil {
|
|
w.err = err
|
|
continue
|
|
}
|
|
w.count++
|
|
}
|
|
if err := w.bw.Flush(); w.err == nil && err != nil {
|
|
w.err = err
|
|
}
|
|
if err := w.f.Sync(); w.err == nil && err != nil {
|
|
w.err = err
|
|
}
|
|
if err := w.f.Close(); w.err == nil && err != nil {
|
|
w.err = err
|
|
}
|
|
}(w)
|
|
}
|
|
|
|
idf, err := os.OpenFile(filepath.Join(dir, "ids.bin"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
for _, w := range writers {
|
|
close(w.ch)
|
|
}
|
|
writerWG.Wait()
|
|
return PQBuildStats{}, err
|
|
}
|
|
off, err := os.OpenFile(filepath.Join(dir, "id-offsets.bin"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
_ = idf.Close()
|
|
for _, w := range writers {
|
|
close(w.ch)
|
|
}
|
|
writerWG.Wait()
|
|
return PQBuildStats{}, err
|
|
}
|
|
idbw := bufio.NewWriterSize(idf, 1<<20)
|
|
offbw := bufio.NewWriterSize(off, 1<<20)
|
|
var idPos uint64
|
|
var ordinal uint32
|
|
var ob [8]byte
|
|
var lb [2]byte
|
|
|
|
type buildJob struct {
|
|
ordinal uint32
|
|
vector []float32
|
|
}
|
|
jobs := make(chan buildJob, cfg.BuildWorkers*2)
|
|
var vectorPool sync.Pool
|
|
vectorPool.New = func() any { return make([]float32, dim) }
|
|
var encWG sync.WaitGroup
|
|
for wi := 0; wi < cfg.BuildWorkers; wi++ {
|
|
encWG.Add(1)
|
|
go func() {
|
|
defer encWG.Done()
|
|
for job := range jobs {
|
|
v := job.vector
|
|
l2norm(v)
|
|
part := nearest(v, coarse)
|
|
var rec pqRecord
|
|
rec.ordinal = job.ordinal
|
|
rec.codeLen = uint16(len(books))
|
|
encodePQInto(v, coarse[part], books, rec.codes[:rec.codeLen])
|
|
writers[part].ch <- rec
|
|
vectorPool.Put(v)
|
|
}
|
|
}()
|
|
}
|
|
|
|
iterErr := iterate(func(id string, v []float32) error {
|
|
if id == "" || len(v) != dim {
|
|
return nil
|
|
}
|
|
if len(id) > math.MaxUint16 {
|
|
return errors.New("memory id too long for PQ index")
|
|
}
|
|
if ordinal == math.MaxUint32 {
|
|
return errors.New("PQ index supports at most uint32 ordinals per dimension")
|
|
}
|
|
binary.LittleEndian.PutUint64(ob[:], idPos)
|
|
if _, err := offbw.Write(ob[:]); err != nil {
|
|
return err
|
|
}
|
|
binary.LittleEndian.PutUint16(lb[:], uint16(len(id)))
|
|
if _, err := idbw.Write(lb[:]); err != nil {
|
|
return err
|
|
}
|
|
if _, err := idbw.WriteString(id); err != nil {
|
|
return err
|
|
}
|
|
idPos += uint64(2 + len(id))
|
|
cp := vectorPool.Get().([]float32)
|
|
if cap(cp) < dim {
|
|
cp = make([]float32, dim)
|
|
} else {
|
|
cp = cp[:dim]
|
|
}
|
|
copy(cp, v)
|
|
jobs <- buildJob{ordinal: ordinal, vector: cp}
|
|
ordinal++
|
|
return nil
|
|
})
|
|
close(jobs)
|
|
encWG.Wait()
|
|
for _, w := range writers {
|
|
close(w.ch)
|
|
}
|
|
writerWG.Wait()
|
|
|
|
closeIDs := func() error {
|
|
if err := idbw.Flush(); err != nil {
|
|
return err
|
|
}
|
|
if err := offbw.Flush(); err != nil {
|
|
return err
|
|
}
|
|
if err := idf.Sync(); err != nil {
|
|
return err
|
|
}
|
|
if err := off.Sync(); err != nil {
|
|
return err
|
|
}
|
|
if err := idf.Close(); err != nil {
|
|
return err
|
|
}
|
|
return off.Close()
|
|
}
|
|
if err := closeIDs(); err != nil {
|
|
return PQBuildStats{}, err
|
|
}
|
|
if iterErr != nil {
|
|
return PQBuildStats{}, iterErr
|
|
}
|
|
|
|
counts := make([]int, len(writers))
|
|
total := 0
|
|
for i, w := range writers {
|
|
if w.err != nil {
|
|
return PQBuildStats{}, w.err
|
|
}
|
|
counts[i] = w.count
|
|
total += w.count
|
|
}
|
|
if total == 0 || total != int(ordinal) {
|
|
return PQBuildStats{}, fmt.Errorf("PQ encode count mismatch: encoded=%d ids=%d", total, ordinal)
|
|
}
|
|
man := pqManifest{Version: 2, Dimension: dim, Count: total, Indexed: total, Config: cfg, Coarse: coarse, Codebooks: books, PartitionCount: counts}
|
|
if err := writeJSONAtomic(filepath.Join(dir, "manifest.json"), &man); err != nil {
|
|
return PQBuildStats{}, err
|
|
}
|
|
var bytes int64
|
|
_ = filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
|
|
if err == nil && info != nil && !info.IsDir() {
|
|
bytes += info.Size()
|
|
}
|
|
return nil
|
|
})
|
|
success = true
|
|
return PQBuildStats{Dimension: dim, Items: total, Partitions: cfg.Partitions, Subquantizers: cfg.Subquantizers, Centroids: cfg.Centroids, TrainingSamples: trainingCount, Bytes: bytes, Duration: time.Since(start)}, nil
|
|
}
|
|
|
|
func OpenPQIndex(dir string) (*PQIndex, error) {
|
|
b, err := os.ReadFile(filepath.Join(dir, "manifest.json"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var man pqManifest
|
|
if err := json.Unmarshal(b, &man); err != nil {
|
|
return nil, err
|
|
}
|
|
if man.Version != 2 || man.Dimension < 2 || len(man.Coarse) == 0 || len(man.Codebooks) == 0 {
|
|
return nil, errors.New("invalid PQ manifest")
|
|
}
|
|
if man.Indexed == 0 {
|
|
// Backward-compatible with early v0.6 development indexes.
|
|
man.Indexed = man.Count
|
|
}
|
|
if man.Indexed < 0 || man.Indexed > man.Count {
|
|
return nil, errors.New("invalid PQ indexed count")
|
|
}
|
|
idx := &PQIndex{dir: dir, manifest: man, files: make([]*os.File, len(man.Coarse)), sizes: make([]int64, len(man.Coarse))}
|
|
idf, err := os.Open(filepath.Join(dir, "ids.bin"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
idx.idFile = idf
|
|
offb, err := os.ReadFile(filepath.Join(dir, "id-offsets.bin"))
|
|
if err != nil {
|
|
idx.Close()
|
|
return nil, err
|
|
}
|
|
if len(offb) != man.Count*8 {
|
|
idx.Close()
|
|
return nil, errors.New("invalid PQ id offset table")
|
|
}
|
|
idx.idOffsets = make([]uint64, man.Count)
|
|
for i := range idx.idOffsets {
|
|
idx.idOffsets[i] = binary.LittleEndian.Uint64(offb[i*8 : i*8+8])
|
|
}
|
|
for p := range idx.files {
|
|
f, err := os.Open(partitionPath(dir, p))
|
|
if err != nil {
|
|
idx.Close()
|
|
return nil, err
|
|
}
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
f.Close()
|
|
idx.Close()
|
|
return nil, err
|
|
}
|
|
idx.files[p], idx.sizes[p] = f, st.Size()
|
|
}
|
|
return idx, nil
|
|
}
|
|
func (p *PQIndex) Close() error {
|
|
var first error
|
|
if p.idFile != nil {
|
|
if err := p.idFile.Close(); err != nil {
|
|
first = err
|
|
}
|
|
p.idFile = nil
|
|
}
|
|
for i, f := range p.files {
|
|
if f != nil {
|
|
if err := f.Close(); first == nil && err != nil {
|
|
first = err
|
|
}
|
|
p.files[i] = nil
|
|
}
|
|
}
|
|
return first
|
|
}
|
|
func (p *PQIndex) Len() int { return p.manifest.Indexed }
|
|
func (p *PQIndex) Dimension() int { return p.manifest.Dimension }
|
|
func (p *PQIndex) Config() PQConfig { return p.manifest.Config }
|
|
func (p *PQIndex) DiskBytes() int64 {
|
|
var n int64
|
|
for _, s := range p.sizes {
|
|
n += s
|
|
}
|
|
for _, name := range []string{"manifest.json", "ids.bin", "id-offsets.bin"} {
|
|
if st, err := os.Stat(filepath.Join(p.dir, name)); err == nil {
|
|
n += st.Size()
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func dotPQ(a, b []float32) float32 {
|
|
var s float32
|
|
for i := range a {
|
|
s += a[i] * b[i]
|
|
}
|
|
return s
|
|
}
|
|
|
|
type pqHeapItem struct {
|
|
ordinal uint32
|
|
sim float32
|
|
}
|
|
type pqMinHeap []pqHeapItem
|
|
|
|
func (h pqMinHeap) Len() int { return len(h) }
|
|
func (h pqMinHeap) Less(i, j int) bool { return h[i].sim < h[j].sim }
|
|
func (h pqMinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
|
|
func (h *pqMinHeap) Push(x any) { *h = append(*h, x.(pqHeapItem)) }
|
|
func (h *pqMinHeap) Pop() any { old := *h; n := len(old); x := old[n-1]; *h = old[:n-1]; return x }
|
|
func pushTopPQ(h *pqMinHeap, capN int, x pqHeapItem) {
|
|
if capN <= 0 {
|
|
return
|
|
}
|
|
if h.Len() < capN {
|
|
heap.Push(h, x)
|
|
return
|
|
}
|
|
if (*h)[0].sim < x.sim {
|
|
(*h)[0] = x
|
|
heap.Fix(h, 0)
|
|
}
|
|
}
|
|
|
|
func buildPQLookup(q []float32, books [][][]float32) [][]float32 {
|
|
m := len(books)
|
|
lookup := make([][]float32, m)
|
|
for sub, book := range books {
|
|
a, b := subBounds(len(q), m, sub)
|
|
row := make([]float32, len(book))
|
|
for c, cent := range book {
|
|
row[c] = dotPQ(q[a:b], cent)
|
|
}
|
|
lookup[sub] = row
|
|
}
|
|
return lookup
|
|
}
|
|
|
|
func (p *PQIndex) scanPartition(part int, q []float32, lookup [][]float32, keep int) ([]pqHeapItem, error) {
|
|
if part < 0 || part >= len(p.files) {
|
|
return nil, errors.New("invalid PQ partition")
|
|
}
|
|
base := dotPQ(q, p.manifest.Coarse[part])
|
|
m := len(p.manifest.Codebooks)
|
|
// A small sequential buffer is enough because partition files are compact
|
|
// fixed-width records and normally served by the OS page cache. Keeping this
|
|
// at 64 KiB prevents a single query from allocating ProbePartitions MiB.
|
|
r := bufio.NewReaderSize(io.NewSectionReader(p.files[part], 0, p.sizes[part]), 64<<10)
|
|
h := &pqMinHeap{}
|
|
heap.Init(h)
|
|
rec := make([]byte, 4+m)
|
|
for {
|
|
_, err := io.ReadFull(r, rec)
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if errors.Is(err, io.ErrUnexpectedEOF) {
|
|
return nil, errors.New("truncated PQ partition")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ord := binary.LittleEndian.Uint32(rec[:4])
|
|
if int(ord) >= p.manifest.Count {
|
|
return nil, errors.New("invalid PQ ordinal")
|
|
}
|
|
sim := base
|
|
valid := true
|
|
for sub, code := range rec[4:] {
|
|
ci := int(code)
|
|
if ci >= len(lookup[sub]) {
|
|
valid = false
|
|
break
|
|
}
|
|
sim += lookup[sub][ci]
|
|
}
|
|
if valid {
|
|
pushTopPQ(h, keep, pqHeapItem{ordinal: ord, sim: sim})
|
|
}
|
|
}
|
|
return *h, nil
|
|
}
|
|
|
|
func (p *PQIndex) resolveID(ord uint32) (string, bool) {
|
|
if int(ord) >= len(p.idOffsets) || p.idFile == nil {
|
|
return "", false
|
|
}
|
|
o := int64(p.idOffsets[ord])
|
|
var lb [2]byte
|
|
if _, err := p.idFile.ReadAt(lb[:], o); err != nil {
|
|
return "", false
|
|
}
|
|
n := int(binary.LittleEndian.Uint16(lb[:]))
|
|
if n <= 0 {
|
|
return "", false
|
|
}
|
|
b := make([]byte, n)
|
|
if _, err := p.idFile.ReadAt(b, o+2); err != nil {
|
|
return "", false
|
|
}
|
|
return string(b), true
|
|
}
|
|
|
|
func (p *PQIndex) Search(q []float32, k int) []PQHit {
|
|
if len(q) != p.manifest.Dimension || k <= 0 {
|
|
return nil
|
|
}
|
|
qn := append([]float32(nil), q...)
|
|
l2norm(qn)
|
|
type coarseHit struct {
|
|
p int
|
|
dist float32
|
|
}
|
|
coarse := make([]coarseHit, len(p.manifest.Coarse))
|
|
for i, c := range p.manifest.Coarse {
|
|
// Training assigns partitions by squared L2 distance. Use the same
|
|
// metric during probing; ranking only by dot(q,c) is wrong when centroid
|
|
// norms differ.
|
|
coarse[i] = coarseHit{i, sqDist(qn, c)}
|
|
}
|
|
sort.Slice(coarse, func(i, j int) bool { return coarse[i].dist < coarse[j].dist })
|
|
probes := p.manifest.Config.ProbePartitions
|
|
if probes < 1 {
|
|
probes = 1
|
|
}
|
|
if probes > len(coarse) {
|
|
probes = len(coarse)
|
|
}
|
|
perPart := k * 2
|
|
if perPart < 32 {
|
|
perPart = 32
|
|
}
|
|
lookup := buildPQLookup(qn, p.manifest.Codebooks)
|
|
|
|
// Bound concurrent partition scanners. The old one-goroutine-per-probe
|
|
// path multiplied buffers and temporary heaps by up to 48+ partitions per
|
|
// query and became expensive under concurrent recall traffic.
|
|
workers := probes
|
|
if workers > 8 {
|
|
workers = 8
|
|
}
|
|
type scanResult struct {
|
|
items []pqHeapItem
|
|
err error
|
|
}
|
|
jobs := make(chan int, probes)
|
|
results := make(chan scanResult, probes)
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < workers; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for part := range jobs {
|
|
items, err := p.scanPartition(part, qn, lookup, perPart)
|
|
results <- scanResult{items: items, err: err}
|
|
}
|
|
}()
|
|
}
|
|
for _, c := range coarse[:probes] {
|
|
jobs <- c.p
|
|
}
|
|
close(jobs)
|
|
wg.Wait()
|
|
close(results)
|
|
|
|
h := &pqMinHeap{}
|
|
heap.Init(h)
|
|
for res := range results {
|
|
if res.err != nil {
|
|
continue
|
|
}
|
|
for _, x := range res.items {
|
|
pushTopPQ(h, k, x)
|
|
}
|
|
}
|
|
out := make([]PQHit, h.Len())
|
|
for i := len(out) - 1; i >= 0; i-- {
|
|
x := heap.Pop(h).(pqHeapItem)
|
|
id, ok := p.resolveID(x.ordinal)
|
|
if !ok {
|
|
continue
|
|
}
|
|
out[i] = PQHit{ID: id, Similarity: float64(x.sim)}
|
|
}
|
|
return out
|
|
}
|