Files
jbergner a6bc71fb3a
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
Init
2026-08-26 18:34:41 +02:00

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
}