项目文件夹

文件
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
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
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

277 行
8.7 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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
}