Introduction

I was experimenting with vector search in SQL Server 2025 using approximately 3.8 million Hacker News articles that that I used for my talk on SQL Server-based vector search feature at EightKB. I generated two sets of embeddings from the same articles: one stored as FP32 (32-bit floating point) and the other as FP16 (16-bit floating point). Both sets contained the same articles and used DiskANN vector indexes.I used a query as below to find articles similar to a selected example.

The query takes the embedding of one article and uses SQL Server’s vector index to find articles with similar embeddings. The distance value tells us how close the articles are: a smaller cosine distance means greater similarity.I then ran the equivalent query against the FP16 table, using an FP16 query vector. I expected FP16 to be faster, or at least require fewer logical reads. After all, an FP16 embedding uses roughly half the storage of an FP32 embedding.But that wasn’t always the case. Some FP16 searches were slower and performed more logical reads than the equivalent FP32 searches, although the size of embeddings were half the size of FP32.It made me wonder whether the difference had less to do with embedding size and more to do with how SQL Server builds and searches its DiskANN vector indexes.There are two possible explanations:

1. SQL Server’s FP32 and FP16 indexes report different starting nodes, based on the sys.vector_indexes DMV. Even if the graphs are nearly identical, beginning in different places could lead the search through different parts of the graph and require different amounts of work.

2. Although the graphs are similar, they are not identical. A few different connections could cause the search to discover different articles, follow different routes, and perform more or fewer comparisons.

I engaged in two experiments for this purpose.

Experiment A — Graph structure and ranking

Determine whether changing vector precision from FP32 to FP16 changes the reconstructed graph’s connections or the ranking of candidates.

Experiment B — Search traversal

Determine whether the small differences between the FP32 and FP16 reconstructed graphs cause the search to perform different amounts of work, and whether the different starting points contribute to those differences.

In this blog post, I’ll explore Experiment A. These experiments are only exploratory recreations of the DiskANN algorithm, based on what we know of its parameters, using SQL Server Graph tables. They are not a replica of the index.

As a first step, I tried to recreate DiskANN’s graph structure using SQL Server Graph tables. DiskANN algorithm has many parameters. Not all of them are exposed in the SQL Server implementation. You can view what is exposed in the build_parameters column using the query below.

My results for my FP32 and FP16 indexes are below.

name index distance_metric
build_parameters

IX_ArticlesEmbeddings_FP32_Embedding DiskANN COSINE  {“StartId”:”2041021″, “L”:”48″, “M”:”8″, “R”:”48″}

IX_ArticlesEmbeddings_FP16_Embedding DiskANN COSINE
{“StartId”:”5237″, “L”:”48″, “M”:”8″, “R”:”48″}

Microsoft has marked the build_parameters column as ‘for internal use only’.  We don’t know how these parameters are used. But based on the algorithm, we can reasonably assume L stands for Search List/Candidate List Size, and R stands for the maximum degree of the graph index. It is unclear what M means.

To construct the simulated graph, I used the parameters reported by SQL Server’s DiskANN indexes as a starting point.

I treated R = 48 as the maximum number of outgoing connections each node could have.
I used L = 48 as the search-list size when exploring the graph, but not as a limit on the number of connections.

For graph construction, I considered up to 192 candidate neighbors (4×R) for each node, then selected the best connections using cosine distance and a pruning factor of 1.2 to avoid keeping too many similar neighbors.
I also allowed reverse-edge proposals to help connect the graph.
StartID seemed to align with hn_id, the key defined in the embedding tables, so I used that.

Imagine we have five rows in our embeddings table. Each row represents a Hacker News article and contains a 768-dimensional vector describing its content.

embedding_id Article Example cosine distance from A
101 A: SQL Server indexes 0.00
102 B: Database performance 0.08
103 C: Query optimization 0.12
104 D: Machine learning 0.65
105 E: Cooking recipes 0.91

Illustrative graph built from those rows may look as below.

The graph-building process works as follows:

1.       Create nodes: Each embedding row becomes a potential graph node, identified by its embedding_id.

2.       Find candidates: For article A, our simulation considers up to 192 candidate articles and calculates their cosine distances.

3.       Select connections: It uses a pruning rule with alpha = 1.2 to favor useful connections rather than simply keeping many nearly identical neighbors.

4.       Apply R = 48: Article A can have at most 48 outgoing connections. In this small example, it might retain B and C because they are closely related, plus another connection that helps reach a different part of the graph.

5.       Store edges: Each selected connection becomes a row in VectorSearchEdgeFP32 or VectorSearchEdgeFP16, such as 101 ? 102.

The original vector tables are as below:

The graph table equivalents are as below:

USE [HackerNews]

GO

SET ANSI_NULLS ON

GO

SET QUOTED_IDENTIFIER ON

GO

CREATE TABLE [dbo].[VectorSearchNode](

[GraphVectorID] [int] IDENTITY(1,1) NOT NULL,

[hn_id] [bigint] NOT NULL,

[FP32EmbeddingID] [int] NOT NULL,

[FP16EmbeddingID] [int] NOT NULL,

[Embedding32] [vector](768, float32) NOT NULL,

[Embedding16] [vector](768, float16) NOT NULL,

[IsQuery] [bit] NOT NULL,

[InFP32Result] [bit] NOT NULL,

[FP32Rank] [int] NULL,

[FP32ResultDistance] [float] NULL,

[InFP16Result] [bit] NOT NULL,

[FP16Rank] [int] NULL,

[FP16ResultDistance] [float] NULL,

CONSTRAINT [PK_VectorSearchNode] PRIMARY KEY CLUSTERED

(

[GraphVectorID] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY],

CONSTRAINT [UQ_VectorSearchNode_hn_id] UNIQUE NONCLUSTERED

(

[hn_id] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]

)

AS NODE ON [PRIMARY]

GO

CREATE TABLE [dbo].[VectorSearchEdgeFP32](

[FromEmbeddingID] [int] NOT NULL,

[ToEmbeddingID] [int] NOT NULL,

[ToHnId] [bigint] NOT NULL,

[NeighborRank] [int] NOT NULL,

[SourceDistance] [float] NOT NULL,

CONSTRAINT [PK_VectorSearchEdgeFP32] PRIMARY KEY CLUSTERED

(

[FromEmbeddingID] ASC,

[ToEmbeddingID] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]

) ON [PRIMARY]

GO

CREATE TABLE [dbo].[VectorSearchEdgeFP16](

[FromEmbeddingID] [int] NOT NULL,

[ToEmbeddingID] [int] NOT NULL,

[ToHnId] [bigint] NOT NULL,

[NeighborRank] [int] NOT NULL,

[SourceDistance] [float] NOT NULL,

CONSTRAINT [PK_VectorSearchEdgeFP16] PRIMARY KEY CLUSTERED

(

[FromEmbeddingID] ASC,

[ToEmbeddingID] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]

) ON [PRIMARY]

GO

The stored procedure used to construct the graphs and populate it from the original embedding tables is as below – the procedure is the same for FP16 and FP32, only points to the appropriate tables.

USE [HackerNews]

GO

ET ANSI_NULLS ON

GO

SET QUOTED_IDENTIFIER ON

GO

CREATE   PROCEDURE [dbo].[BuildRobustNeighborsFP16_ExpA]

(

@FromEmbeddingID    int,

@CandidatePool      int = 192,

@R                  int = 48,

@Alpha              float = 1.2

)

AS

BEGIN

SET NOCOUNT ON;

SET XACT_ABORT ON;

IF @CandidatePool > 256

THROW 55101, 'CandidatePool must be <= 256.', 1;

IF @R > @CandidatePool

THROW 55102, 'R cannot exceed CandidatePool.', 1;

DECLARE @ExistingBuiltAt datetime2(7);

SELECT

@ExistingBuiltAt = BuiltAt

FROM dbo.VectorAdjacencyBuildState_ExpA

WHERE Precision = 'FP16'

AND FromEmbeddingID = @FromEmbeddingID

AND CandidatePool = @CandidatePool

AND R = @R

AND Alpha = @Alpha;

IF @ExistingBuiltAt IS NOT NULL

AND NOT EXISTS

(

SELECT 1

FROM dbo.VectorReverseProposalFP16_ExpA

WHERE OwnerEmbeddingID = @FromEmbeddingID

AND CreatedAt > @ExistingBuiltAt

)

BEGIN

RETURN;

END;

DELETE FROM dbo.VectorSearchEdgeFP16_ExpA

WHERE FromEmbeddingID = @FromEmbeddingID;

DELETE FROM dbo.VectorAdjacencyBuildState_ExpA

WHERE Precision = 'FP16'

AND FromEmbeddingID = @FromEmbeddingID;

DECLARE @SourceVector vector(768,float16);

SELECT

@SourceVector = embedding

FROM dbo.ArticlesEmbeddings_FP16

WHERE embedding_id = @FromEmbeddingID;

IF @SourceVector IS NULL

THROW 55103, 'FP16 source embedding not found.', 1;

CREATE TABLE #Pool

(

CandidateEmbeddingID int NOT NULL PRIMARY KEY,

CandidateHnId        bigint NOT NULL,

CandidateVector      vector(768,float16) NOT NULL,

DistanceFromSource   float NOT NULL,

IsActive             bit NOT NULL

);

INSERT #Pool

(

CandidateEmbeddingID,

CandidateHnId,

CandidateVector,

DistanceFromSource,

IsActive

)

SELECT TOP (@CandidatePool)

E.embedding_id,

E.hn_id,

E.embedding,

VS.distance,

1

FROM VECTOR_SEARCH

(

TABLE       = dbo.ArticlesEmbeddings_FP16 AS E,

COLUMN      = embedding,

SIMILAR_TO  = @SourceVector,

METRIC      = 'cosine',

TOP_N       = 257

) AS VS

WHERE E.embedding_id <> @FromEmbeddingID

ORDER BY

VS.distance,

E.embedding_id;

INSERT #Pool

(

CandidateEmbeddingID,

CandidateHnId,

CandidateVector,

DistanceFromSource,

IsActive

)

SELECT

E.embedding_id,

E.hn_id,

E.embedding,

VECTOR_DISTANCE

(

'cosine',

@SourceVector,

E.embedding

),

1

FROM dbo.VectorReverseProposalFP16_ExpA AS RP

INNER JOIN dbo.ArticlesEmbeddings_FP16 AS E

ON E.embedding_id = RP.CandidateEmbeddingID

LEFT JOIN #Pool AS P

ON P.CandidateEmbeddingID = E.embedding_id

WHERE RP.OwnerEmbeddingID = @FromEmbeddingID

AND E.embedding_id <> @FromEmbeddingID

AND P.CandidateEmbeddingID IS NULL;

DECLARE @SelectedCount int = 0;

DECLARE @CurrentRank int = 0;

DECLARE @SelectedEmbeddingID int;

DECLARE @SelectedHnId bigint;

DECLARE @SelectedDistance float;

DECLARE @SelectedVector vector(768,float16);

WHILE @SelectedCount < @R

BEGIN

SET @SelectedEmbeddingID = NULL;

SELECT TOP (1)

@SelectedEmbeddingID =

P.CandidateEmbeddingID,

@SelectedHnId =

P.CandidateHnId,

@SelectedVector =

P.CandidateVector,

@SelectedDistance =

P.DistanceFromSource

FROM #Pool AS P

WHERE P.IsActive = 1

ORDER BY

P.DistanceFromSource,

P.CandidateEmbeddingID;

IF @SelectedEmbeddingID IS NULL

BREAK;

SET @SelectedCount += 1;

SET @CurrentRank += 1;

INSERT dbo.VectorSearchEdgeFP16_ExpA

(

FromEmbeddingID,

ToEmbeddingID,

ToHnId,

NeighborRank,

SourceDistance

)

VALUES

(

@FromEmbeddingID,

@SelectedEmbeddingID,

@SelectedHnId,

@CurrentRank,

@SelectedDistance

);

UPDATE #Pool

SET IsActive = 0

WHERE CandidateEmbeddingID =

@SelectedEmbeddingID;

UPDATE P

SET IsActive = 0

FROM #Pool AS P

WHERE P.IsActive = 1

AND

(

@Alpha *

VECTOR_DISTANCE

(

'cosine',

@SelectedVector,

P.CandidateVector

)

)

<= P.DistanceFromSource;

END;

INSERT dbo.VectorReverseProposalFP16_ExpA

(

OwnerEmbeddingID,

CandidateEmbeddingID

)

SELECT

E.ToEmbeddingID,

E.FromEmbeddingID

FROM dbo.VectorSearchEdgeFP16_ExpA AS E

LEFT JOIN dbo.VectorReverseProposalFP16_ExpA AS Existing

ON Existing.OwnerEmbeddingID =

E.ToEmbeddingID

AND Existing.CandidateEmbeddingID =

E.FromEmbeddingID

WHERE E.FromEmbeddingID =

@FromEmbeddingID

AND Existing.OwnerEmbeddingID IS NULL;

INSERT dbo.VectorAdjacencyBuildState_ExpA

(

Precision,

FromEmbeddingID,

CandidatePool,

R,

Alpha,

BuiltAt

)

VALUES

(

'FP16',

@FromEmbeddingID,

@CandidatePool,

@R,

@Alpha,

SYSUTCDATETIME()

);

END;

GO

After constructing the graph, I use L as the search-list size when navigating it. It controls how many promising candidates the simulated search keeps track of, not how many edges each node has.

R controls graph connections, while L guides graph searching. The 192-candidate pool and pruning factor are assumptions.

Parameter Setting
Matched source articles 1,000
Candidate pool 192
Maximum outgoing edges (R) 48
Pruning parameter (alpha) 1.2
Build passes 2
Query article hn_id 18349232

The graph was built by calling dbo.BuildRobustNeighborsFP32_ExpA and dbo.BuildRobustNeighborsFP16_ExpA (code above) for the matched embedding IDs, using @CandidatePool = 192, @R = 48, and @Alpha = 1.2.

Tests performed

1. Graph size and overlap

The two graphs had almost the same number of edges: 45,855 for FP32 and 45,871 for FP16. Each graph had outgoing edges for all 1,000 sampled source articles. FP16 had only 16 more edges, about 0.035% more than FP32.

Metric FP32 FP16
Built source nodes 1,000 1,000
Directed edges 45,855 45,871
Distinct destinations 43,151 43,148
Average outgoing edges 45.86 45.87

SQL: graph size

I then compared neighbors using article hn_id, rather than embedding_id, because the identity values are not guaranteed to match between precision tables. The average Jaccard similarity was 0.9191 (91.91%).( Jaccard similarity is the number of shared neighbors divided by the number of distinct neighbors across both lists.

Neighborhood measure Result
Average shared neighbors 43.81
Average Jaccard 91.91%
Minimum Jaccard 41.18%
Maximum Jaccard 100%

Our earlier exploratory run reported 96.35% average Jaccard, but only for 143 common source articles. The revised 1,000-article matched test is the more useful comparison. The 4.44 percentage-point decrease should not be interpreted as an effect of sample size alone: the experiment population and build histories also changed.

SQL: neighborhood overlap

2. Starting-article neighborhoods

The two articles associated with the reported native StartId values were more similar than the overall average. For hn_id 20594, both reconstructed graphs selected the same 48 neighbors. For hn_id 11559412, they shared 47 of 48 neighbors. The builder’s assigned order was also almost identical.

Start article FP32 / FP16 neighbors Shared Jaccard Same rank
20594 48 / 48 48 100.00% 48
11559412 48 / 48 47 95.92% 45

For the 47 shared neighbors of article 11559412, average absolute rank movement was 0.04 and the largest movement was one position. The builder’s NeighborRank is a selection order, not a recorded native DiskANN traversal order.

SQL: compare starting neighbors and ranks

3. Distance differences at starting articles

For shared source-to-neighbor edges, FP16 and FP32 cosine distances differed by only a few millionths. These distances describe edges in our reconstructed graph; they are not distances from the search query.

Start article Avg absolute difference Max absolute difference Avg relative difference
20594 0.0000046343 0.0000136495 0.002764%
11559412 0.0000059630 0.0000152588 0.002975%

SQL: compare stored edge distances

SQL: compare stored edge distances

WITH N32 AS

(

SELECT src.hn_id AS SourceHnId, g.ToHnId AS NeighborHnId,

g.SourceDistance AS D32

FROM dbo.VectorSearchEdgeFP32_ExpA AS g

JOIN dbo.ArticlesEmbeddings_FP32 AS src

ON src.embedding_id=g.FromEmbeddingID

WHERE src.hn_id IN (20594,11559412)

),

N16 AS

(

SELECT src.hn_id AS SourceHnId, g.ToHnId AS NeighborHnId,

g.SourceDistance AS D16

FROM dbo.VectorSearchEdgeFP16_ExpA AS g

JOIN dbo.ArticlesEmbeddings_FP16 AS src

ON src.embedding_id=g.FromEmbeddingID

WHERE src.hn_id IN (20594,11559412)

)

SELECT a.SourceHnId, COUNT(*) AS SharedNeighbors,

CAST(AVG(ABS(a.D32-b.D16)) AS decimal(18,10)) AS AvgAbsoluteDistanceDifference,

CAST(MAX(ABS(a.D32-b.D16)) AS decimal(18,10)) AS MaxAbsoluteDistanceDifference,

CAST(AVG(CASE WHEN a.D32<>0

THEN 100.0*ABS(a.D32-b.D16)/ABS(a.D32) END)

AS decimal(18,6)) AS AvgRelativeDifferencePct

FROM N32 AS a JOIN N16 AS b

ON a.SourceHnId=b.SourceHnId AND a.NeighborHnId=b.NeighborHnId

GROUP BY a.SourceHnId ORDER BY a.SourceHnId;

4. Ranking by distance to the query

A search decides which candidate looks promising using its distance to the query, not its distance to the node that supplied the edge. I therefore used query article 18349232 to calculate query-relative cosine distances in both precisions.

At the two starting articles, every candidate kept the same query-relative rank: 48 of 48 around article 20594, and 49 of 49 in the union of the two neighbor lists around article 11559412. The average absolute query-distance differences were 0.0000094548 and 0.0000083179, respectively.

SQL: query ranking at both starting articles

DECLARE @Query32 vector(768,float32), @Query16 vector(768,float16);

SELECT @Query32=embedding FROM dbo.ArticlesEmbeddings_FP32 WHERE hn_id=18349232;

SELECT @Query16=embedding FROM dbo.ArticlesEmbeddings_FP16 WHERE hn_id=18349232;

WITH StartingNeighbors AS

(

SELECT src.hn_id AS StartHnId, g.ToHnId AS CandidateHnId

FROM dbo.VectorSearchEdgeFP32_ExpA AS g

JOIN dbo.ArticlesEmbeddings_FP32 AS src ON src.embedding_id=g.FromEmbeddingID

WHERE src.hn_id IN (20594,11559412)

UNION

SELECT src.hn_id, g.ToHnId

FROM dbo.VectorSearchEdgeFP16_ExpA AS g

JOIN dbo.ArticlesEmbeddings_FP16 AS src ON src.embedding_id=g.FromEmbeddingID

WHERE src.hn_id IN (20594,11559412)

),

Distances AS

(

SELECT n.StartHnId,n.CandidateHnId,

VECTOR_DISTANCE('cosine',@Query32,e32.embedding) AS D32,

VECTOR_DISTANCE('cosine',@Query16,e16.embedding) AS D16

FROM StartingNeighbors AS n

JOIN dbo.ArticlesEmbeddings_FP32 AS e32 ON e32.hn_id=n.CandidateHnId

JOIN dbo.ArticlesEmbeddings_FP16 AS e16 ON e16.hn_id=n.CandidateHnId

),

Ranked AS

(

SELECT *,

ROW_NUMBER() OVER (PARTITION BY StartHnId ORDER BY D32,CandidateHnId) AS Rank32,

ROW_NUMBER() OVER (PARTITION BY StartHnId ORDER BY D16,CandidateHnId) AS Rank16

FROM Distances

)

SELECT StartHnId,COUNT(*) AS ComparedCandidates,

SUM(CASE WHEN Rank32=Rank16 THEN 1 ELSE 0 END) AS SameQueryRankCount,

CAST(AVG(1.0*ABS(Rank32-Rank16)) AS decimal(10,2)) AS AvgQueryRankDifference,

MAX(ABS(Rank32-Rank16)) AS MaxQueryRankDifference,

CAST(AVG(ABS(D32-D16)) AS decimal(18,10)) AS AvgAbsoluteQueryDistanceDifference

FROM Ranked GROUP BY StartHnId ORDER BY StartHnId;

4. Ranking by distance to the query

A search decides which candidate looks promising using its distance to the query, not its distance to the node that supplied the edge. I therefore used query article 18349232 to calculate query-relative cosine distances in both precisions.

At the two starting articles, every candidate kept the same query-relative rank: 48 of 48 around article 20594, and 49 of 49 in the union of the two neighbor lists around article 11559412. The average absolute query-distance differences were 0.0000094548 and 0.0000083179, respectively.

SQL: query ranking at both starting articles

5. Finally, I repeated the ranking test across all 1,000 source neighborhoods, comparing only candidates present in both graphs. This isolates distance-order changes from differences in candidate membership.

Ranking measure across all 1,000 neighborhoods Result
Shared candidate occurrences 43,813
Same-rank candidate occurrences 43,648
Same-rank percentage 99.6234%
Nodes with identical ranking 920
Nodes with at least one rank difference 80
Largest rank displacement 2

Only 165 of 43,813 shared candidate occurrences changed position. These are occurrences within source neighborhoods, not necessarily 165 distinct articles.

SQL: query ranking across the sample

Conclusions

FP16 and FP32 produced almost the same graph density in this reconstruction. Most outgoing neighbors were shared, and the two starting articles had nearly identical local connections. For the chosen query, 99.6234% of shared candidate occurrences kept the same query-relative rank.

The practical finding is not huge, but can be useful: changing embedding precision did not substantially reorder shared candidates (in this test). It did change some edges. Those non-shared edges could still affect traversal, especially after several hops.

Scope and limitations

This experiment studies a reconstructed graph based on DiskANN parameters that are revealed. It is also not an exhaustive test – Only a sample of the original dataset was used and tests done on a couple of nodes, not all.

The two precisions use separate native indexes for candidate generation. Therefore, we cannot attribute observed edge differences to FP16 rounding alone. I did not reproduce undocumented native build behavior, and I did not implement the native index parameter M because it is undocumented and its purpose is unknown. The L = 48 value belongs to the planned traversal simulation, not the graph-construction procedure.

The rank-agreement percentage is conditional on neighbors being shared. It does not include candidates found only in one graph, nor does it show the exact order of any native search.

Next experiment

I will run a four-way controlled traversal: each reconstructed graph (FP32 and FP16) starting from each reported starting article (11559412 and 20594), using query 18349232 and the same simulated frontier limit L = 48. It is expected to find expanded nodes, candidates evaluated, and attempts to expand destinations that lack adjacency lists. This will help us understand starting-point effects from graph/precision effects within the reconstruction.

Share.
Leave A Reply