Files
wehub-resource-sync a06f331eb8
install-script / powershell-syntax (push) Waiting to run
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
install-script / install (macos-14) (push) Waiting to run
install-script / install (ubuntu-latest) (push) Waiting to run
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

277 lines
8.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package search
import (
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"sync"
"github.com/coder/hnsw"
"github.com/zzet/gortex/internal/graph"
)
// vectorFrameMagic prefixes the framed VectorBackend.Save format: a
// 4-byte magic, a uint32 chunk-map JSON length, the chunk-map JSON,
// then the raw HNSW export. A blob lacking the magic is a legacy
// (pre-chunking) raw HNSW export and is loaded with an empty chunk
// map — so old snapshots keep working.
var vectorFrameMagic = [4]byte{'G', 'V', 'X', '1'}
// VectorDelegate is the subset of graph.VectorSearcher the
// VectorBackend shim consults when it's been told to delegate
// instead of holding an in-process HNSW. Exported (with a
// graph.VectorHit return) so the indexer can install a delegate
// without writing a translation layer — search already depends on
// graph for SymbolHit, so the type sharing is free.
type VectorDelegate interface {
SimilarTo(vec []float32, limit int) ([]graph.VectorHit, error)
}
// VectorBackend stores and searches embedding vectors using HNSW index.
//
// When delegate is set (via SetDelegate), the in-process HNSW is
// bypassed entirely: Add becomes a no-op (the indexer drives the
// delegate's bulk-upsert directly), Search forwards to the
// delegate's SimilarTo. The dims and chunkMap stay live so callers
// that need them (HybridBackend.dechunkVectorIDs) keep working
// against the same VectorBackend surface.
type VectorBackend struct {
graph *hnsw.Graph[string]
count int
dims int
// chunkMap maps a synthetic chunk vector ID ("<symbolID>#chunkK")
// to its parent symbol ID. It is non-empty only when AST
// sub-chunking split one or more symbols into multiple vectors.
// Search results are de-chunked through it so a symbol is never
// returned twice and chunk IDs never leak to callers.
chunkMap map[string]string
mu sync.RWMutex
// delegate is the optional engine-native vector searcher (today
// only graph.SymbolSearcher-implementing stores). Set means
// "don't build the in-process HNSW; route reads through here".
// The wrapped delegateCount tracks Add-call deltas so Count()
// reports a non-zero figure once the indexer has finished its
// bulk upsert — HybridBackend gates the vector channel on
// Count() > 0.
delegate VectorDelegate
delegateCount int
}
// NewVector creates a vector search backend for the given embedding dimensions.
func NewVector(dims int) *VectorBackend {
g := hnsw.NewGraph[string]()
g.Distance = hnsw.CosineDistance
return &VectorBackend{
graph: g,
dims: dims,
}
}
// SetChunkMap installs the chunk-vector → parent-symbol mapping. Called
// by the indexer after a chunked vector build. A nil or empty map
// means no symbol was sub-chunked.
func (v *VectorBackend) SetChunkMap(m map[string]string) {
v.mu.Lock()
defer v.mu.Unlock()
v.chunkMap = m
}
// ResolveChunk maps a vector ID to the symbol ID a caller should see.
// For a chunk vector it returns the parent symbol ID; for a plain
// symbol vector it returns the ID unchanged. The bool reports whether
// the ID was a chunk.
func (v *VectorBackend) ResolveChunk(id string) (string, bool) {
v.mu.RLock()
defer v.mu.RUnlock()
if parent, ok := v.chunkMap[id]; ok {
return parent, true
}
return id, false
}
// HasChunks reports whether any vector in the index is a chunk.
func (v *VectorBackend) HasChunks() bool {
v.mu.RLock()
defer v.mu.RUnlock()
return len(v.chunkMap) > 0
}
// Add indexes a symbol with its embedding vector.
func (v *VectorBackend) Add(id string, vector []float32) {
v.mu.Lock()
defer v.mu.Unlock()
if v.delegate != nil {
// Delegated mode: the indexer pushes vectors to the
// engine-native HNSW via the graph.VectorSearcher
// interface directly. Add here is a no-op so the
// in-process hnsw.Graph never allocates memory for what
// the delegate already owns; count tracks deltas so
// Count()'s "is the index populated" gate fires.
v.delegateCount++
return
}
v.graph.Add(hnsw.Node[string]{
Key: id,
Value: hnsw.Vector(vector),
})
v.count++
}
// SetDelegate routes Search / Count through an engine-native vector
// searcher (the disk store's graph.VectorSearcher). After
// the call:
// - Add is a no-op (the indexer talks to the delegate directly via
// graph.VectorSearcher.BulkUpsertEmbeddings / UpsertEmbedding),
// - Search forwards to delegate.SimilarTo,
// - Count reflects the delegate-delta count (not the in-process
// graph), so HybridBackend.searchChannels's `v.Count() > 0` gate
// fires once the indexer has populated the backend.
func (v *VectorBackend) SetDelegate(d VectorDelegate) {
v.mu.Lock()
defer v.mu.Unlock()
v.delegate = d
}
// Search returns the k nearest neighbors to the query vector.
func (v *VectorBackend) Search(query []float32, k int) []string {
v.mu.RLock()
d := v.delegate
v.mu.RUnlock()
if d != nil {
hits, err := d.SimilarTo(query, k)
if err != nil || len(hits) == 0 {
return nil
}
ids := make([]string, len(hits))
for i, h := range hits {
ids[i] = h.NodeID
}
return ids
}
v.mu.RLock()
defer v.mu.RUnlock()
if v.count == 0 {
return nil
}
results := v.graph.Search(hnsw.Vector(query), k)
ids := make([]string, len(results))
for i, r := range results {
ids[i] = r.Key
}
return ids
}
// Count returns the number of indexed vectors.
func (v *VectorBackend) Count() int {
v.mu.RLock()
defer v.mu.RUnlock()
if v.delegate != nil {
return v.delegateCount
}
return v.count
}
// Dims returns the embedding dimensionality.
func (v *VectorBackend) Dims() int { return v.dims }
// SizeBytes estimates HNSW's heap footprint. The raw vector storage
// (dims × 4 B) is a small fraction of the total — each node also
// carries layer neighbor lists, priority-queue scratch, and the
// string-keyed maps that drive graph navigation. Calibrated against
// heap profiles on a 68k-vector index (50 dims, default M=16): live
// was ~408 MiB, i.e. ~6 KiB per vector overall. Using dims×4 + 5900 B
// keeps the formula honest as dims change (MiniLM at 384 would push
// the dims×4 term to 1.5 KiB per vector, overhead stays roughly flat).
func (v *VectorBackend) SizeBytes() uint64 {
v.mu.RLock()
defer v.mu.RUnlock()
if v.count == 0 {
return 0
}
const hnswOverhead = 5900 // neighbor lists + map headers + priority-queue slack
perVector := uint64(v.dims)*4 + hnswOverhead
return uint64(v.count) * perVector
}
// Save writes the vector index to a writer in the framed format:
// magic + chunk-map JSON + raw HNSW export. The chunk map is persisted
// so query-time de-chunking still works after a daemon restart.
func (v *VectorBackend) Save(w io.Writer) error {
v.mu.RLock()
defer v.mu.RUnlock()
mapJSON := []byte("{}")
if len(v.chunkMap) > 0 {
b, err := json.Marshal(v.chunkMap)
if err != nil {
return fmt.Errorf("marshal chunk map: %w", err)
}
mapJSON = b
}
if _, err := w.Write(vectorFrameMagic[:]); err != nil {
return fmt.Errorf("write vector frame magic: %w", err)
}
var lenBuf [4]byte
binary.LittleEndian.PutUint32(lenBuf[:], uint32(len(mapJSON)))
if _, err := w.Write(lenBuf[:]); err != nil {
return fmt.Errorf("write chunk map length: %w", err)
}
if _, err := w.Write(mapJSON); err != nil {
return fmt.Errorf("write chunk map: %w", err)
}
if err := v.graph.Export(w); err != nil {
return fmt.Errorf("export vector index: %w", err)
}
return nil
}
// LoadFrom restores the vector index from a reader. It accepts both
// the framed format (magic + chunk map + HNSW) and the legacy raw
// HNSW export written before AST sub-chunking shipped — a legacy blob
// has no magic and loads with an empty chunk map.
func (v *VectorBackend) LoadFrom(r io.Reader) error {
v.mu.Lock()
defer v.mu.Unlock()
// Buffer the whole blob so a missing magic can be replayed into the
// HNSW importer. Vector index blobs are small relative to the graph.
all, err := io.ReadAll(r)
if err != nil {
return fmt.Errorf("read vector index: %w", err)
}
hnswBytes := all
v.chunkMap = nil
if len(all) >= 8 && bytes.Equal(all[:4], vectorFrameMagic[:]) {
mapLen := binary.LittleEndian.Uint32(all[4:8])
if int(mapLen)+8 > len(all) {
return fmt.Errorf("vector index frame: chunk map length %d exceeds blob", mapLen)
}
mapJSON := all[8 : 8+mapLen]
hnswBytes = all[8+mapLen:]
if mapLen > 0 {
m := make(map[string]string)
if err := json.Unmarshal(mapJSON, &m); err != nil {
return fmt.Errorf("unmarshal chunk map: %w", err)
}
if len(m) > 0 {
v.chunkMap = m
}
}
}
if err := v.graph.Import(bytes.NewReader(hnswBytes)); err != nil {
return fmt.Errorf("import vector index: %w", err)
}
return nil
}
// SetCount sets the node count (used after loading from persistence).
func (v *VectorBackend) SetCount(n int) {
v.mu.Lock()
defer v.mu.Unlock()
v.count = n
}