Skip to content

Commit 297c380

Browse files
committed
Skip auto-binary when sign-bit codes are degenerate
Embeddings with all-non-negative components (TF-IDF-style, ReLU final layers) pack to identical sign-bit codes, so once a corpus crossed the auto-binary threshold, phase-1 Hamming ranking was pure ties and approximate search picked candidates near-randomly: recall@1 measured 1/5 on a 10k corpus, and no rerankFactor or probe setting could repair it. Auto-binary now also requires the codes to spread (expected Hamming distance of at least dimension/16, estimated from per-bit frequencies over a strided sample); degenerate corpora keep the exact scan, which is correct and no slower. Explicit binary/clusters options are honored unchanged.
1 parent 93a1888 commit 297c380

5 files changed

Lines changed: 104 additions & 3 deletions

File tree

src/constants.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,17 @@ export const defaultClusterProbeCap = 48
4949
// the corpus is at least this large. Below the threshold, exact full scan
5050
// is fast enough that the rerank path's overhead isn't worth the column.
5151
export const defaultAutoBinaryThreshold = 10000
52+
53+
// Auto-binary also requires the sign-bit codes to discriminate: when the
54+
// expected Hamming distance between two random vectors falls below
55+
// dimension * defaultBinaryMinSpread, phase-1 binary ranking is dominated
56+
// by ties (embeddings with all-non-negative components share one code) and
57+
// approximate search degenerates to a near-random candidate pick that no
58+
// rerankFactor can repair. Exact scan is correct and no slower in that
59+
// regime. 1/16 sits far below healthy embeddings (mixed-sign embeddings
60+
// measure ~0.3-0.5 of dimension) and far above degenerate ones (~0).
61+
export const defaultBinaryMinSpread = 1 / 16
62+
63+
// Rows sampled (evenly strided) when estimating the sign-bit spread. Per-bit
64+
// one-frequencies converge fast, so a few thousand rows suffice at any N.
65+
export const binarySpreadSample = 4096

src/utils.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { binarySpreadSample } from './constants.js'
2+
13
/**
24
* @import { DistanceMetric, HypVectorMetadata } from './types.js'
35
* @import { FileMetaData, KeyValue } from 'hyparquet'
@@ -114,6 +116,38 @@ export function packBinary(v, dim = v.length) {
114116
return bytes
115117
}
116118

119+
/**
120+
* Expected Hamming distance between two random rows of packed binary codes,
121+
* from per-bit one-frequencies: sum over bits of 2 * p * (1 - p). Identical
122+
* codes measure 0; independent fair bits measure dimension / 2. Rows are
123+
* sampled with an even stride when there are more than sampleCap, which is
124+
* plenty to estimate per-bit frequencies at any corpus size.
125+
*
126+
* @param {Uint8Array[]} codes packed binary codes (see packBinary)
127+
* @param {number} dimension vector dimension
128+
* @param {number} [sampleCap] max rows to sample
129+
* @returns {number} expected Hamming distance in bits
130+
*/
131+
export function expectedHamming(codes, dimension, sampleCap = binarySpreadSample) {
132+
if (!codes.length) return 0
133+
const stride = Math.max(1, Math.ceil(codes.length / sampleCap))
134+
const ones = new Uint32Array(dimension)
135+
let sampled = 0
136+
for (let row = 0; row < codes.length; row += stride) {
137+
const code = codes[row]
138+
for (let i = 0; i < dimension; i += 1) {
139+
if (code[i >> 3] >> (i & 7) & 1) ones[i] += 1
140+
}
141+
sampled += 1
142+
}
143+
let expected = 0
144+
for (let i = 0; i < dimension; i += 1) {
145+
const p = ones[i] / sampled
146+
expected += 2 * p * (1 - p)
147+
}
148+
return expected
149+
}
150+
117151
/**
118152
* Unpack a Uint8Array of raw little-endian float32 bytes into a Float32Array.
119153
* Always returns a fresh aligned copy, since the source buffer may be

src/writeVectors.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ import { binaryKMeans, reorderClustersByHamming } from './cluster.js'
33
import {
44
defaultAutoBinaryThreshold,
55
defaultBinaryColumn,
6+
defaultBinaryMinSpread,
67
defaultBinaryPageSize,
78
defaultClusterIterations,
89
defaultIdColumn,
910
defaultRowGroupSize,
1011
defaultVectorColumn,
1112
hypVectorVersion,
1213
} from './constants.js'
13-
import { encodeBase64, l2Normalize, packBinary, packFloat32 } from './utils.js'
14+
import { encodeBase64, expectedHamming, l2Normalize, packBinary, packFloat32 } from './utils.js'
1415

1516
/**
1617
* @import { VectorRecord, WriteVectorsOptions } from './types.js'
@@ -99,8 +100,14 @@ export async function writeVectors({
99100

100101
// Resolve auto defaults now that we know N. Auto-clusters only fires
101102
// when the caller also let `binary` auto; explicit `binary: true` means
102-
// "add the column, don't reshuffle rows".
103-
if (autoBinary) binary = ids.length >= defaultAutoBinaryThreshold
103+
// "add the column, don't reshuffle rows". Degenerate sign-bit codes
104+
// (e.g. embeddings with all-non-negative components) are all ties, which
105+
// would make phase-1 ranking near-random, so auto-binary also requires
106+
// the codes to spread; without the column, search stays an exact scan.
107+
if (autoBinary) {
108+
binary = ids.length >= defaultAutoBinaryThreshold &&
109+
expectedHamming(packedBin, dimension) >= dimension * defaultBinaryMinSpread
110+
}
104111
binary = binary === true
105112
const clusterCount = clusters ?? (autoBinary && binary ? Math.max(1, Math.round(Math.sqrt(ids.length) / 2)) : 0)
106113
// Clustering operates on the binary codes, so it implies the binary column

test/utils.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import {
33
cosineSimilarity,
44
dotProduct,
55
euclideanDistance,
6+
expectedHamming,
67
l2Normalize,
8+
packBinary,
79
} from '../src/utils.js'
810

911
describe('dotProduct', () => {
@@ -51,3 +53,24 @@ describe('l2Normalize', () => {
5153
expect(Array.from(n)).toEqual([0, 0, 0])
5254
})
5355
})
56+
57+
describe('expectedHamming', () => {
58+
it('measures 0 for identical codes and dim/2 for fair bits', () => {
59+
const dimension = 16
60+
const same = Array.from({ length: 100 }, () => packBinary(new Float32Array(dimension).fill(1)))
61+
expect(expectedHamming(same, dimension)).toBe(0)
62+
// half all-positive, half all-negative: every bit is a fair coin
63+
const fair = Array.from({ length: 100 }, (_, i) =>
64+
packBinary(new Float32Array(dimension).fill(i % 2 ? 1 : -1)))
65+
expect(expectedHamming(fair, dimension)).toBeCloseTo(dimension / 2)
66+
})
67+
68+
it('samples with an even stride on large inputs', () => {
69+
const dimension = 8
70+
// sign pattern with no period, so any sampling stride sees both signs
71+
const fair = Array.from({ length: 20000 }, (_, i) =>
72+
packBinary(new Float32Array(dimension).fill(Math.sin(i * 12.9898) > 0 ? 1 : -1)))
73+
expect(expectedHamming(fair, dimension, 1000)).toBeCloseTo(dimension / 2, 0)
74+
expect(expectedHamming([], dimension)).toBe(0)
75+
})
76+
})

test/writeVectors.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,29 @@ describe('writeVectors', () => {
104104
expect(find('hypvector.clusters')).toBe('50')
105105
})
106106

107+
it('skips auto-binary at large N when the sign-bit codes are degenerate', async () => {
108+
const dimension = 32
109+
const writer = fileWriter(TEST_FILE)
110+
// all-non-negative components share one sign-bit code, so binary
111+
// phase-1 ranking would be pure ties: keep the exact scan instead
112+
const vectors = makeVectors(10000, dimension).map(({ id, vector }) => ({
113+
id, vector: vector.map(Math.abs),
114+
}))
115+
await writeVectors({ writer, dimension, vectors })
116+
const file = await asyncBufferFromFile(TEST_FILE)
117+
const meta = await parquetMetadataAsync(file)
118+
/**
119+
* @param {string} key
120+
* @returns {string | undefined}
121+
*/
122+
function find(key) {
123+
return meta.key_value_metadata?.find(e => e.key === key)?.value
124+
}
125+
expect(find('hypvector.binary')).toBe('false')
126+
expect(find('hypvector.clusters')).toBe('0')
127+
expect(meta.schema.some(e => e.name === 'vector_bin')).toBe(false)
128+
})
129+
107130
it('forces the binary column when clusters are requested below the auto-binary threshold', async () => {
108131
const dimension = 32 // binaryBytes must be a multiple of 4 for k-means
109132
const writer = fileWriter(TEST_FILE)

0 commit comments

Comments
 (0)