mirror of
https://github.com/hanzoai/graph.git
synced 2026-10-30 18:35:37 +00:00
Derived from Dgraph (github.com/dgraph-io/dgraph) at commit 64804bd2, Apache-2.0. See NOTICE. WHAT THIS IS. The graph engine as a Go library: posting lists, the DQL parser, the type and tokenizer system, the index and schema layers, and the wire types. 39,170 lines of the upstream's 166,337. THE STORE is github.com/luxfi/zapdb, a Badger fork that kept the package name, the subpackages (options, pb, y) and the API — Open, OpenManaged, DB, Options — so the 74 import sites are aliased rather than rewritten. THE CODEC is ZAP. zapdb serializes with ZAP, not protobuf: its pb package builds the ZAP types by default and the protobuf ones only under `-tags grpc`, a path that does not compile (table/builder.go calls pb.Marshal, which that tag removes). One call site in posting needed converting; every other type here is Hanzo's own protobuf and is untouched. NO TRANSPORT. An embedded engine dials nothing, so nothing here imports google.golang.org/grpc. Removed: 20 transport functions in x, the generated service stubs for Raft, Zero and Worker (1,657 lines), and the JWT helper — identity belongs to IAM. The core called none of them. NOT KEPT: worker, conn, raftwal, the cluster commands, the GraphQL layer and the test harnesses. `query`, the DQL execution engine, went with them because it depends on worker; parsing DQL is available here, executing it is not. The history of the upstream project lives at github.com/dgraph-io/dgraph. This repository begins at the derivation because three quarters of that history describes code this fork does not carry.
657 lines
19 KiB
Go
657 lines
19 KiB
Go
/*
|
||
* SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc.
|
||
* SPDX-License-Identifier: Apache-2.0
|
||
*/
|
||
|
||
package hnsw
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
c "github.com/hanzoai/graph/tok/constraints"
|
||
"github.com/hanzoai/graph/tok/index"
|
||
opt "github.com/hanzoai/graph/tok/options"
|
||
"github.com/golang/glog"
|
||
"github.com/pkg/errors"
|
||
)
|
||
|
||
type persistentHNSW[T c.Float] struct {
|
||
maxLevels int
|
||
efConstruction int
|
||
efSearch int
|
||
pred string
|
||
vecEntryKey string
|
||
vecKey string
|
||
vecDead string
|
||
simType SimilarityType[T]
|
||
floatBits int
|
||
// nodeAllEdges[65443][1][3] indicates the 3rd neighbor in the first
|
||
// layer for UUID 65443. The result will be a neighboring UUID.
|
||
nodeAllEdges map[uint64][][]uint64
|
||
deadNodes map[uint64]struct{}
|
||
}
|
||
|
||
func GetPersistantOptions[T c.Float](o opt.Options) string {
|
||
sb := strings.Builder{}
|
||
|
||
if val, ok, _ := opt.GetOpt(o, ExponentOpt, 3); ok {
|
||
sb.WriteString(fmt.Sprintf(`"%s":"%d",`, ExponentOpt, val))
|
||
}
|
||
if val, ok, _ := opt.GetOpt(o, MaxLevelsOpt, 3); ok {
|
||
sb.WriteString(fmt.Sprintf(`"%s":"%d",`, MaxLevelsOpt, val))
|
||
}
|
||
if val, ok, _ := opt.GetOpt(o, EfConstructionOpt, 3); ok {
|
||
sb.WriteString(fmt.Sprintf(`"%s":"%d",`, EfConstructionOpt, val))
|
||
}
|
||
if val, ok, _ := opt.GetOpt(o, EfSearchOpt, 3); ok {
|
||
sb.WriteString(fmt.Sprintf(`"%s":"%d",`, EfSearchOpt, val))
|
||
}
|
||
|
||
if simType, foundSimType := opt.GetInterfaceOpt(o, MetricOpt); foundSimType {
|
||
sim, ok := simType.(SimilarityType[T])
|
||
if !ok {
|
||
glog.Errorf("cannot cast %T to SimilarityType", simType)
|
||
}
|
||
sb.WriteString(fmt.Sprintf(`"%s":"%s",`, MetricOpt, sim.indexType))
|
||
}
|
||
|
||
final := sb.String()
|
||
if len(final) > 0 {
|
||
// Remove last , and cover with brackets
|
||
return "(" + final[:len(final)-1] + ")"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (ph *persistentHNSW[T]) applyOptions(o opt.Options) error {
|
||
if o.Specifies(ExponentOpt) {
|
||
// Adjust defaults based on exponent.
|
||
exponent, _, _ := opt.GetOpt(o, ExponentOpt, 3)
|
||
|
||
if !o.Specifies(MaxLevelsOpt) {
|
||
o.SetOpt(MaxLevelsOpt, exponent)
|
||
}
|
||
|
||
if !o.Specifies(EfConstructionOpt) {
|
||
o.SetOpt(EfConstructionOpt, 50*exponent)
|
||
}
|
||
|
||
if !o.Specifies(EfSearchOpt) {
|
||
o.SetOpt(EfSearchOpt, 30*exponent)
|
||
}
|
||
}
|
||
|
||
var err error
|
||
ph.maxLevels, _, err = opt.GetOpt(o, MaxLevelsOpt, 3)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
ph.efConstruction, _, err = opt.GetOpt(o, EfConstructionOpt, 150)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
ph.efSearch, _, err = opt.GetOpt(o, EfSearchOpt, 90)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
simType, foundSimType := opt.GetInterfaceOpt(o, MetricOpt)
|
||
if foundSimType {
|
||
okSimType, ok := simType.(SimilarityType[T])
|
||
if !ok {
|
||
return fmt.Errorf("cannot cast %T to SimilarityType", simType)
|
||
}
|
||
ph.simType = okSimType
|
||
} else {
|
||
ph.simType = SimilarityType[T]{indexType: Euclidean, distanceScore: euclideanDistanceSq[T],
|
||
insortHeap: insortPersistentHeapAscending[T], isBetterScore: isBetterScoreForDistance[T],
|
||
isSimilarityMetric: false}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (ph *persistentHNSW[T]) emptyFinalResultWithError(e error) (
|
||
*index.SearchPathResult, error) {
|
||
return index.NewSearchPathResult(), e
|
||
}
|
||
|
||
func (ph *persistentHNSW[T]) emptySearchResultWithError(e error) (*searchLayerResult[T], error) {
|
||
return newLayerResult[T](0), e
|
||
}
|
||
|
||
// fillNeighborEdges(uuid, c, edges) will "fill" edges with the neighbors for
|
||
// all levels associated with given uuid and CacheType.
|
||
// It returns true when we were able to find the node (either in cache or
|
||
// in persistent store) and false otherwise.
|
||
// (Of course, it may also return an error if a problem was encountered).
|
||
func (ph *persistentHNSW[T]) fillNeighborEdges(uuid uint64, c index.CacheType, edges *[][]uint64) (bool, error) {
|
||
var ok bool
|
||
*edges, ok = ph.nodeAllEdges[uuid]
|
||
if ok {
|
||
return true, nil
|
||
}
|
||
|
||
ok, err := populateEdgeDataFromKeyWithCacheType(ph.vecKey, uuid, c, edges)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if !ok {
|
||
return false, nil
|
||
}
|
||
|
||
// add this to in mem storage of uid -> edges
|
||
ph.nodeAllEdges[uuid] = *edges
|
||
return true, nil
|
||
}
|
||
|
||
// searchPersistentLayer searches a layer of the HNSW graph for the nearest
|
||
// neighbors of the query vector and returns the traversal path and the nearest
|
||
// neighbors
|
||
func (ph *persistentHNSW[T]) searchPersistentLayer(
|
||
c index.CacheType,
|
||
level int,
|
||
entry uint64,
|
||
startVec, query []T,
|
||
entryIsFilteredOut bool,
|
||
expectedNeighbors int,
|
||
filter index.SearchFilter[T]) (*searchLayerResult[T], error) {
|
||
r := newLayerResult[T](level)
|
||
|
||
bestDist, err := ph.simType.distanceScore(startVec, query, ph.floatBits)
|
||
r.markFirstDistanceComputation()
|
||
if err != nil {
|
||
return ph.emptySearchResultWithError(err)
|
||
}
|
||
best := persistentHeapElement[T]{
|
||
value: bestDist,
|
||
index: entry,
|
||
filteredOut: entryIsFilteredOut,
|
||
}
|
||
|
||
r.setFirstPathNode(best)
|
||
// Use the appropriate heap type based on metric: min-heap for distance metrics
|
||
// (lower is better), max-heap for similarity metrics (higher is better).
|
||
candidateHeap := buildCandidateHeap([]persistentHeapElement[T]{best}, ph.simType.isSimilarityMetric)
|
||
|
||
var allLayerEdges [][]uint64
|
||
|
||
//create set using map to append to on future visited nodes
|
||
for candidateHeap.Len() != 0 {
|
||
currCandidate := candidateHeap.Pop()
|
||
if r.numNeighbors() >= expectedNeighbors &&
|
||
ph.simType.isBetterScore(r.lastNeighborScore(), currCandidate.value) {
|
||
// Standard HNSW termination: once the current best candidate
|
||
// cannot improve the ef-sized neighbor set (and we already have
|
||
// at least expectedNeighbors), we stop exploring this layer.
|
||
// Recall is governed by ef; callers may raise ef (per‑query
|
||
// override supported) to explore further.
|
||
break
|
||
}
|
||
|
||
found, err := ph.fillNeighborEdges(currCandidate.index, c, &allLayerEdges)
|
||
if err != nil {
|
||
return ph.emptySearchResultWithError(err)
|
||
}
|
||
if !found {
|
||
continue
|
||
}
|
||
var eVec []T
|
||
improved := false
|
||
for _, currUid := range allLayerEdges[level] {
|
||
if r.indexVisited(currUid) {
|
||
continue
|
||
}
|
||
// iterate over candidate's neighbors distances to get
|
||
// best ones
|
||
_ = ph.getVecFromUid(currUid, c, &eVec)
|
||
// intentionally ignoring error -- we catch it
|
||
// indirectly via eVec == nil check.
|
||
if len(eVec) == 0 {
|
||
continue
|
||
}
|
||
currDist, err := ph.simType.distanceScore(eVec, query, ph.floatBits)
|
||
if err != nil {
|
||
return ph.emptySearchResultWithError(err)
|
||
}
|
||
filteredOut := !filter(query, eVec, currUid)
|
||
currElement := initPersistentHeapElement(
|
||
currDist, currUid, filteredOut)
|
||
r.addToVisited(*currElement)
|
||
r.incrementDistanceComputations()
|
||
|
||
// If we have not yet found k candidates, we can consider
|
||
// any candidate. Otherwise, only consider those that
|
||
// are better than our current k nearest neighbors.
|
||
// Note that the "numNeighbors" function is a bit tricky:
|
||
// If we previously added to the heap M elements that should
|
||
// be filtered out, we ignore M elements in the numNeighbors
|
||
// check! In this way, we can make sure to allow in up to
|
||
// expectedNeighbors "unfiltered" elements.
|
||
if r.numNeighbors() < expectedNeighbors || ph.simType.isBetterScore(currDist, r.lastNeighborScore()) {
|
||
if candidateHeap.Len() > expectedNeighbors {
|
||
candidateHeap.PopLast()
|
||
}
|
||
candidateHeap.Push(*currElement)
|
||
r.addPathNode(*currElement, ph.simType, expectedNeighbors)
|
||
improved = true
|
||
}
|
||
}
|
||
|
||
if !improved && r.numNeighbors() >= expectedNeighbors {
|
||
break
|
||
}
|
||
}
|
||
return r, nil
|
||
}
|
||
|
||
// Search searches the HNSW graph for the nearest neighbors of the query vector
|
||
// and returns the traversal path and the nearest neighbors
|
||
func (ph *persistentHNSW[T]) Search(ctx context.Context, c index.CacheType, query []T,
|
||
maxResults int, filter index.SearchFilter[T]) (nnUids []uint64, err error) {
|
||
r, err := ph.SearchWithPath(ctx, c, query, maxResults, filter)
|
||
return r.Neighbors, err
|
||
}
|
||
|
||
// SearchWithOptions applies optional per-call controls (ef override and distance threshold).
|
||
// When EfOverride > 0, it is applied at upper layers and the bottom layer uses
|
||
// candidateK = max(maxResults, EfOverride). Results return the best maxResults.
|
||
// When DistanceThreshold is set, results exceeding the threshold (in the metric domain)
|
||
// are filtered out before limiting to maxResults.
|
||
func (ph *persistentHNSW[T]) SearchWithOptions(
|
||
ctx context.Context,
|
||
c index.CacheType,
|
||
query []T,
|
||
maxResults int,
|
||
opts index.VectorIndexOptions[T],
|
||
) ([]uint64, error) {
|
||
if opts.Filter == nil {
|
||
opts.Filter = index.AcceptAll[T]
|
||
}
|
||
if maxResults < 0 {
|
||
maxResults = 0
|
||
}
|
||
r := index.NewSearchPathResult()
|
||
start := time.Now().UnixMilli()
|
||
|
||
// 0-profile_vector_entry
|
||
var startVec []T
|
||
entry, err := ph.PickStartNode(ctx, c, &startVec)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Upper layers use efUpper (override if provided)
|
||
efUpper := ph.efSearch
|
||
if opts.EfOverride > 0 {
|
||
efUpper = opts.EfOverride
|
||
}
|
||
|
||
for level := range ph.maxLevels - 1 {
|
||
if isEqual(startVec, query) {
|
||
break
|
||
}
|
||
filterOut := !opts.Filter(query, startVec, entry)
|
||
layerResult, err := ph.searchPersistentLayer(
|
||
c, level, entry, startVec, query, filterOut, efUpper, opts.Filter)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
layerResult.updateFinalMetrics(r)
|
||
entry = layerResult.bestNeighbor().index
|
||
layerResult.updateFinalPath(r)
|
||
if err = ph.getVecFromUid(entry, c, &startVec); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
// Bottom layer: candidate size = max(k, efUpper)
|
||
filterOut := !opts.Filter(query, startVec, entry)
|
||
candidateK := maxResults
|
||
if efUpper > candidateK {
|
||
candidateK = efUpper
|
||
}
|
||
layerResult, err := ph.searchPersistentLayer(
|
||
c, ph.maxLevels-1, entry, startVec, query, filterOut, candidateK, opts.Filter)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
layerResult.updateFinalMetrics(r)
|
||
layerResult.updateFinalPath(r)
|
||
|
||
// Build final neighbor list with optional threshold, limited to maxResults.
|
||
res := make([]uint64, 0, maxResults)
|
||
for _, n := range layerResult.neighbors {
|
||
if maxResults == 0 {
|
||
break
|
||
}
|
||
if n.filteredOut {
|
||
continue
|
||
}
|
||
if opts.DistanceThreshold != nil {
|
||
th := *opts.DistanceThreshold
|
||
switch ph.simType.indexType {
|
||
case Euclidean:
|
||
// n.value stores the metric-domain distance (not squared).
|
||
if float64(n.value) > th {
|
||
continue
|
||
}
|
||
case Cosine:
|
||
// n.value is cosine similarity in [-1,1]; cosine distance d = 1 - sim must be <= th.
|
||
if float64(1.0)-float64(n.value) > th {
|
||
continue
|
||
}
|
||
default:
|
||
// Dot product or others: ignore threshold for now.
|
||
}
|
||
}
|
||
res = append(res, n.index)
|
||
if len(res) >= maxResults {
|
||
break
|
||
}
|
||
}
|
||
|
||
r.Metrics[searchTime] = uint64(time.Now().UnixMilli() - start)
|
||
return res, nil
|
||
}
|
||
|
||
// SearchWithUidAndOptions is analogous to SearchWithUid but applies per‑call options.
|
||
func (ph *persistentHNSW[T]) SearchWithUidAndOptions(
|
||
_ context.Context,
|
||
c index.CacheType,
|
||
queryUid uint64,
|
||
maxResults int,
|
||
opts index.VectorIndexOptions[T],
|
||
) ([]uint64, error) {
|
||
if opts.Filter == nil {
|
||
opts.Filter = index.AcceptAll[T]
|
||
}
|
||
if maxResults < 0 {
|
||
maxResults = 0
|
||
}
|
||
var queryVec []T
|
||
if err := ph.getVecFromUid(queryUid, c, &queryVec); err != nil {
|
||
if errors.Is(err, errFetchingPostingList) {
|
||
return []uint64{}, nil
|
||
}
|
||
return []uint64{}, err
|
||
}
|
||
if len(queryVec) == 0 {
|
||
return []uint64{}, nil
|
||
}
|
||
filterOut := !opts.Filter(queryVec, queryVec, queryUid)
|
||
candidateK := maxResults
|
||
if opts.EfOverride > candidateK {
|
||
candidateK = opts.EfOverride
|
||
}
|
||
lr, err := ph.searchPersistentLayer(
|
||
c, ph.maxLevels-1, queryUid, queryVec, queryVec, filterOut, candidateK, opts.Filter)
|
||
if err != nil {
|
||
return []uint64{}, err
|
||
}
|
||
res := make([]uint64, 0, maxResults)
|
||
for _, n := range lr.neighbors {
|
||
if maxResults == 0 {
|
||
break
|
||
}
|
||
if n.filteredOut {
|
||
continue
|
||
}
|
||
if opts.DistanceThreshold != nil {
|
||
th := *opts.DistanceThreshold
|
||
switch ph.simType.indexType {
|
||
case Euclidean:
|
||
if float64(n.value) > th {
|
||
continue
|
||
}
|
||
case Cosine:
|
||
if float64(1.0)-float64(n.value) > th {
|
||
continue
|
||
}
|
||
default:
|
||
}
|
||
}
|
||
res = append(res, n.index)
|
||
if len(res) >= maxResults {
|
||
break
|
||
}
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// SearchWithUid searches the HNSW graph for the nearest neighbors of the query UID
|
||
// and returns the traversal path and the nearest neighbors
|
||
func (ph *persistentHNSW[T]) SearchWithUid(_ context.Context, c index.CacheType, queryUid uint64,
|
||
maxResults int, filter index.SearchFilter[T]) (nnUids []uint64, err error) {
|
||
var queryVec []T
|
||
err = ph.getVecFromUid(queryUid, c, &queryVec)
|
||
if err != nil {
|
||
if errors.Is(err, errFetchingPostingList) {
|
||
// No vector. return empty result
|
||
return []uint64{}, nil
|
||
}
|
||
return []uint64{}, err
|
||
}
|
||
|
||
if len(queryVec) == 0 {
|
||
// No vector. return empty result
|
||
return []uint64{}, nil
|
||
}
|
||
|
||
shouldFilterOutQueryVec := !filter(queryVec, queryVec, queryUid)
|
||
|
||
// How normal search works is by continuously searching higher layers
|
||
// for the best entry node to the last layer. Since we already know the
|
||
// best entry node (it already exists in the lowest level), we
|
||
// can just search the last layer and return the results.
|
||
r, err := ph.searchPersistentLayer(
|
||
c, ph.maxLevels-1, queryUid, queryVec, queryVec,
|
||
shouldFilterOutQueryVec, maxResults, filter)
|
||
for _, n := range r.neighbors {
|
||
nnUids = append(nnUids, n.index)
|
||
}
|
||
return nnUids, err
|
||
}
|
||
|
||
// There will be times when the entry node has been deleted. In that case, we want to make a new node
|
||
// the first vector.
|
||
func (ph *persistentHNSW[T]) calculateNewEntryVec(
|
||
_ context.Context,
|
||
c index.CacheType,
|
||
startVec *[]T) (uint64, error) {
|
||
|
||
itr, err := c.Find([]byte(ph.pred), func(value []byte) bool {
|
||
index.BytesAsFloatArray(value, startVec, ph.floatBits)
|
||
return len(*startVec) != 0
|
||
})
|
||
|
||
if err != nil {
|
||
return 0, errors.Wrapf(err, EmptyHNSWTreeError)
|
||
}
|
||
if itr == 0 {
|
||
return itr, errors.New(EmptyHNSWTreeError)
|
||
}
|
||
|
||
return itr, nil
|
||
}
|
||
|
||
func (ph *persistentHNSW[T]) PickStartNode(
|
||
ctx context.Context,
|
||
c index.CacheType,
|
||
startVec *[]T) (uint64, error) {
|
||
|
||
data, err := getDataFromKeyWithCacheType(ph.vecEntryKey, 1, c)
|
||
if err != nil {
|
||
if errors.Is(err, errFetchingPostingList) {
|
||
// The index might be empty
|
||
return ph.calculateNewEntryVec(ctx, c, startVec)
|
||
}
|
||
return 0, err
|
||
}
|
||
|
||
entry := BytesToUint64(data)
|
||
if err = ph.getVecFromUid(entry, c, startVec); err != nil && !errors.Is(err, errNilVector) {
|
||
return 0, err
|
||
}
|
||
|
||
if len(*startVec) == 0 {
|
||
return ph.calculateNewEntryVec(ctx, c, startVec)
|
||
}
|
||
return entry, err
|
||
}
|
||
|
||
// SearchWithPath allows persistentHNSW to implement index.OptionalIndexSupport.
|
||
// See index.OptionalIndexSupport.SearchWithPath for more info.
|
||
func (ph *persistentHNSW[T]) SearchWithPath(
|
||
ctx context.Context,
|
||
c index.CacheType,
|
||
query []T,
|
||
maxResults int,
|
||
filter index.SearchFilter[T]) (r *index.SearchPathResult, err error) {
|
||
start := time.Now().UnixMilli()
|
||
r = index.NewSearchPathResult()
|
||
|
||
// 0-profile_vector_entry
|
||
var startVec []T
|
||
entry, err := ph.PickStartNode(ctx, c, &startVec)
|
||
if err != nil {
|
||
return ph.emptyFinalResultWithError(err)
|
||
}
|
||
|
||
// Calculates best entry for last level (maxLevels-1) by searching each
|
||
// layer and using new best entry.
|
||
for level := range ph.maxLevels - 1 {
|
||
if isEqual(startVec, query) {
|
||
break
|
||
}
|
||
filterOut := !filter(query, startVec, entry)
|
||
layerResult, err := ph.searchPersistentLayer(
|
||
c, level, entry, startVec, query, filterOut, ph.efSearch, filter)
|
||
if err != nil {
|
||
return ph.emptyFinalResultWithError(err)
|
||
}
|
||
layerResult.updateFinalMetrics(r)
|
||
entry = layerResult.bestNeighbor().index
|
||
|
||
layerResult.updateFinalPath(r)
|
||
err = ph.getVecFromUid(entry, c, &startVec)
|
||
if err != nil {
|
||
return ph.emptyFinalResultWithError(err)
|
||
}
|
||
}
|
||
filterOut := !filter(query, startVec, entry)
|
||
layerResult, err := ph.searchPersistentLayer(
|
||
c, ph.maxLevels-1, entry, startVec, query, filterOut, maxResults, filter)
|
||
if err != nil {
|
||
return ph.emptyFinalResultWithError(err)
|
||
}
|
||
layerResult.updateFinalMetrics(r)
|
||
layerResult.updateFinalPath(r)
|
||
layerResult.addFinalNeighbors(r)
|
||
t := time.Now().UnixMilli()
|
||
elapsed := t - start
|
||
r.Metrics[searchTime] = uint64(elapsed)
|
||
return r, nil
|
||
}
|
||
|
||
// InsertToPersistentStorage inserts a node into the HNSW graph and returns the
|
||
// traversal path and the edges created
|
||
func (ph *persistentHNSW[T]) Insert(ctx context.Context, c index.CacheType,
|
||
inUuid uint64, inVec []T) ([]*index.KeyValue, error) {
|
||
tc, ok := c.(*TxnCache)
|
||
if !ok {
|
||
return []*index.KeyValue{}, nil
|
||
}
|
||
_, edges, err := ph.insertHelper(ctx, tc, inUuid, inVec)
|
||
return edges, err
|
||
}
|
||
|
||
// InsertToPersistentStorage inserts a node into the HNSW graph and returns the
|
||
// traversal path and the edges created
|
||
func (ph *persistentHNSW[T]) insertHelper(ctx context.Context, tc *TxnCache,
|
||
inUuid uint64, inVec []T) ([]persistentHeapElement[T], []*index.KeyValue, error) {
|
||
|
||
// return all the new edges created at all HNSW levels
|
||
var startVec []T
|
||
entry, edges, err := ph.createEntryAndStartNodes(ctx, tc, inUuid, &startVec)
|
||
if err != nil || len(edges) > 0 {
|
||
return []persistentHeapElement[T]{}, edges, err
|
||
}
|
||
|
||
if entry == inUuid {
|
||
// something interesting is you physically cannot add duplicate nodes,
|
||
// it'll just overwrite w the same info
|
||
// only situation where you can add duplicate nodes is if your
|
||
// mutation adds the same node as entry
|
||
return []persistentHeapElement[T]{}, []*index.KeyValue{}, nil
|
||
}
|
||
|
||
// startVecs: vectors used to calc where to start up until inLevel,
|
||
// nns: nearest neighbors to return,
|
||
// visited: all visited nodes
|
||
// var nns []persistentHeapElement[T]
|
||
visited := []persistentHeapElement[T]{}
|
||
inLevel := getInsertLayer(ph.maxLevels) // calculate layer to insert node at (randomized every time)
|
||
for level := range inLevel {
|
||
// perform insertion for layers [level, max_level) only, when level < inLevel just find better start
|
||
err := ph.getVecFromUid(entry, tc, &startVec)
|
||
if err != nil {
|
||
return []persistentHeapElement[T]{}, []*index.KeyValue{}, err
|
||
}
|
||
layerResult, err := ph.searchPersistentLayer(tc, level, entry, startVec,
|
||
inVec, false, ph.efSearch, index.AcceptAll[T])
|
||
if err != nil {
|
||
return []persistentHeapElement[T]{}, []*index.KeyValue{}, err
|
||
}
|
||
entry = layerResult.bestNeighbor().index
|
||
}
|
||
|
||
emptyEdges := make([][]uint64, ph.maxLevels)
|
||
_, err = ph.addNeighbors(ctx, tc, inUuid, emptyEdges)
|
||
if err != nil {
|
||
return []persistentHeapElement[T]{}, []*index.KeyValue{}, err
|
||
}
|
||
|
||
var outboundEdgesAllLayers = make([][]uint64, ph.maxLevels)
|
||
var inboundEdgesAllLayersMap = make(map[uint64][][]uint64)
|
||
for level := inLevel; level < ph.maxLevels; level++ {
|
||
err := ph.getVecFromUid(entry, tc, &startVec)
|
||
if err != nil {
|
||
return []persistentHeapElement[T]{}, []*index.KeyValue{}, err
|
||
}
|
||
layerResult, err := ph.searchPersistentLayer(tc, level, entry, startVec,
|
||
inVec, false, ph.efConstruction, index.AcceptAll[T])
|
||
if err != nil {
|
||
return []persistentHeapElement[T]{}, []*index.KeyValue{}, err
|
||
}
|
||
|
||
entry = layerResult.bestNeighbor().index
|
||
|
||
nns := layerResult.neighbors
|
||
for i := range nns {
|
||
if inboundEdgesAllLayersMap[nns[i].index] == nil {
|
||
inboundEdgesAllLayersMap[nns[i].index] = make([][]uint64, ph.maxLevels)
|
||
}
|
||
inboundEdgesAllLayersMap[nns[i].index][level] =
|
||
append(inboundEdgesAllLayersMap[nns[i].index][level], inUuid)
|
||
outboundEdgesAllLayers[level] =
|
||
append(outboundEdgesAllLayers[level], nns[i].index)
|
||
}
|
||
}
|
||
edge, err := ph.addNeighbors(ctx, tc, inUuid, outboundEdgesAllLayers)
|
||
if err != nil {
|
||
return []persistentHeapElement[T]{}, []*index.KeyValue{}, err
|
||
}
|
||
edges = append(edges, edge)
|
||
for nnUid, inboundEdges := range inboundEdgesAllLayersMap {
|
||
edge, err := ph.addNeighbors(ctx, tc, nnUid, inboundEdges)
|
||
if err != nil {
|
||
return []persistentHeapElement[T]{}, []*index.KeyValue{}, err
|
||
}
|
||
edges = append(edges, edge)
|
||
}
|
||
|
||
return visited, edges, nil
|
||
}
|