Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/components/Points/PointVectors.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { memo } from 'react';
import PropTypes from 'prop-types';
import { Box, Button, Chip, Grid, Typography } from '@mui/material';
import { CopyTextButton } from '../Common/CopyTextButton';
import VectorFingerprint from './VectorFingerprint';
import { bigIntJSON } from '../../common/bigIntJSON';
import { useNavigate, useParams } from 'react-router-dom';
import { styled } from '@mui/material/styles';
Expand Down Expand Up @@ -67,8 +68,8 @@ const Vectors = memo(function Vectors({ point, onFindSimilar }) {
</>
)}
</Grid>
<Grid my={1} size={{ xs: 12, md: 4 }}>
<Typography variant="body2" color="text.secondary" display={'inline'} mr={1}>
<Grid my={1} size={{ xs: 12, md: 4 }} display={'flex'} alignItems={'center'} flexWrap={'wrap'} gap={1}>
<Typography variant="body2" color="text.secondary" display={'inline'}>
Length:
</Typography>
<Chip
Expand All @@ -87,6 +88,7 @@ const Vectors = memo(function Vectors({ point, onFindSimilar }) {
variant="outlined"
size="small"
/>
<VectorFingerprint vector={vectors[key]} />
</Grid>
<Grid
my={1}
Expand Down
87 changes: 87 additions & 0 deletions src/components/Points/VectorFingerprint.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React, { useEffect, useMemo, useRef } from 'react';
import PropTypes from 'prop-types';
import { alpha, useTheme } from '@mui/material/styles';
import Tooltip from '@mui/material/Tooltip';
import { computeVectorFingerprint } from '../../lib/vector-fingerprint';
import { blue, qdrantColor } from '../../theme/colors';

const CELL_COUNT = 32;
const WIDTH = 144;
const HEIGHT = 24;
const CELL_GAP = 1;

/**
* Compact canvas "fingerprint" of a vector: a waveform-like strip of bars
* around a center line, where each bar is a signed random projection of the
* whole vector. Similar vectors produce visually similar silhouettes.
* @param {Array|Object} vector - dense vector, multivector or sparse vector
* @return {JSX.Element|null}
* @constructor
*/
const VectorFingerprint = ({ vector }) => {
const theme = useTheme();
const canvasRef = useRef(null);
const isDark = theme.palette.mode === 'dark';

const fingerprint = useMemo(() => computeVectorFingerprint(vector, CELL_COUNT), [vector]);

useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !fingerprint) {
return;
}
const dpr = window.devicePixelRatio || 1;
canvas.width = WIDTH * dpr;
canvas.height = HEIGHT * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, WIDTH, HEIGHT);

const positiveColor = isDark ? qdrantColor[300] : qdrantColor[500];
const negativeColor = isDark ? blue[300] : blue[700];
const center = HEIGHT / 2;
const maxBar = center - 1;
const cellWidth = WIDTH / fingerprint.length;

ctx.fillStyle = alpha(theme.palette.text.primary, 0.15);
ctx.fillRect(0, center - 0.5, WIDTH, 1);

for (let i = 0; i < fingerprint.length; i++) {
const value = fingerprint[i];
// a 1px stub keeps near-zero bars visible, so the strip reads as a
// continuous waveform instead of scattered marks
const barHeight = Math.max(1, Math.abs(value) * maxBar);
ctx.fillStyle = value >= 0 ? positiveColor : negativeColor;
if (value >= 0) {
ctx.fillRect(i * cellWidth, center - barHeight, cellWidth - CELL_GAP, barHeight);
} else {
ctx.fillRect(i * cellWidth, center, cellWidth - CELL_GAP, barHeight);
}
}
}, [fingerprint, isDark, theme]);

if (!fingerprint) {
return null;
}

return (
<Tooltip title={'Vector fingerprint: similar vectors have similar fingerprints'} placement={'top'}>
<canvas
ref={canvasRef}
style={{
width: WIDTH,
height: HEIGHT,
verticalAlign: 'middle',
}}
role="img"
aria-label="Vector fingerprint"
/>
</Tooltip>
);
};

VectorFingerprint.propTypes = {
vector: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
};

export default VectorFingerprint;
107 changes: 107 additions & 0 deletions src/lib/tests/vector-fingerprint.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { computeVectorFingerprint } from '../vector-fingerprint';

const distance = (a, b) => {
let sum = 0;
for (let i = 0; i < a.length; i++) {
sum += (a[i] - b[i]) ** 2;
}
return Math.sqrt(sum);
};

const matchingSigns = (a, b) => {
let matches = 0;
for (let i = 0; i < a.length; i++) {
if (Math.sign(a[i]) === Math.sign(b[i])) {
matches++;
}
}
return matches / a.length;
};

// deterministic pseudo-random generator for test vectors
const mulberry32 = (seed) => () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};

const randomVector = (dim, seed) => {
const rand = mulberry32(seed);
return Array.from({ length: dim }, () => rand() * 2 - 1);
};

describe('computeVectorFingerprint', () => {
it('is deterministic', () => {
const vector = randomVector(512, 1);
const a = computeVectorFingerprint(vector, 32);
const b = computeVectorFingerprint(vector, 32);
expect(Array.from(a)).toEqual(Array.from(b));
});

it('keeps values in [-1, 1] and is scale-invariant', () => {
const vector = randomVector(3000, 2);
const fingerprint = computeVectorFingerprint(vector, 32);
expect(fingerprint.length).toBe(32);
for (const value of fingerprint) {
expect(Math.abs(value)).toBeLessThanOrEqual(1);
}
const scaled = computeVectorFingerprint(
vector.map((v) => v * 100),
32
);
for (let i = 0; i < fingerprint.length; i++) {
expect(scaled[i]).toBeCloseTo(fingerprint[i], 5);
}
});

it('produces closer fingerprints for closer vectors', () => {
const base = randomVector(512, 3);
const near = base.map((value, i) => value + 0.1 * (i % 2 ? 1 : -1));
const far = randomVector(512, 4);

const fpBase = computeVectorFingerprint(base, 32);
const fpNear = computeVectorFingerprint(near, 32);
const fpFar = computeVectorFingerprint(far, 32);

expect(distance(fpBase, fpNear)).toBeLessThan(distance(fpBase, fpFar));
});

it('keeps most cell signs stable between similar vectors', () => {
const base = randomVector(1024, 5);
const rand = mulberry32(6);
// small perturbation: cosine similarity stays high
const near = base.map((value) => value + (rand() * 2 - 1) * 0.15);

const fpBase = computeVectorFingerprint(base, 32);
const fpNear = computeVectorFingerprint(near, 32);
expect(matchingSigns(fpBase, fpNear)).toBeGreaterThan(0.8);
});

it('supports sparse vectors', () => {
const sparse = { indices: [1, 100, 100000, 4294967295], values: [0.5, -1, 2, 0.1] };
const fingerprint = computeVectorFingerprint(sparse, 32);
expect(fingerprint.length).toBe(32);
expect(fingerprint.some((value) => value !== 0)).toBe(true);
});

it('supports multivectors', () => {
const multivector = [randomVector(128, 7), randomVector(128, 8)];
const fingerprint = computeVectorFingerprint(multivector, 32);
expect(fingerprint.length).toBe(32);
expect(fingerprint.some((value) => value !== 0)).toBe(true);
});

it('clamps bucket count to the vector dimension and to 32', () => {
expect(computeVectorFingerprint([1, -2, 3], 48).length).toBe(3);
expect(computeVectorFingerprint(randomVector(512, 9), 64).length).toBe(32);
});

it('returns null for unsupported shapes', () => {
expect(computeVectorFingerprint(null)).toBeNull();
expect(computeVectorFingerprint([])).toBeNull();
expect(computeVectorFingerprint({ text: 'inference object' })).toBeNull();
expect(computeVectorFingerprint({ indices: [], values: [] })).toBeNull();
});
});
117 changes: 117 additions & 0 deletions src/lib/vector-fingerprint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Deterministic 32-bit integer hash (murmur3 finalizer).
* Its bits are used as pseudo-random projection signs, so each dimension
* contributes a stable +/-1 to every fingerprint cell.
* @param {number} i - input integer
* @return {number} - unsigned 32-bit hash
*/
const hashInt = (i) => {
let h = i | 0;
h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
return (h ^ (h >>> 16)) >>> 0;
};

// one projection sign per hash bit
const MAX_BUCKETS = 32;

// A random +/-1 projection of a unit vector is roughly a standard normal,
// so +/-2.5 sigma covers nearly the whole range after normalization.
const DISPLAY_SIGMA = 2.5;

const accumulateDense = (vector, buckets, offset) => {
for (let i = 0; i < vector.length; i++) {
const value = vector[i];
const bits = hashInt(i + offset);
for (let j = 0; j < buckets.length; j++) {
buckets[j] += (bits >>> j) & 1 ? value : -value;
}
}
};

const accumulateSparse = (indices, values, buckets) => {
for (let i = 0; i < indices.length; i++) {
const value = values[i];
const bits = hashInt(Number(indices[i]));
for (let j = 0; j < buckets.length; j++) {
buckets[j] += (bits >>> j) & 1 ? value : -value;
}
}
};

const squaredNorm = (values) => {
let sum = 0;
for (let i = 0; i < values.length; i++) {
sum += values[i] * values[i];
}
return sum;
};

/**
* Reduce a vector of any supported shape (dense, multivector, sparse)
* to a fixed-size signed fingerprint with values in [-1, 1].
*
* Each cell is a SimHash-style projection of the whole vector onto a
* deterministic random +/-1 direction, normalized by the vector's L2 norm.
* Cell signs stay stable between similar vectors (for cosine similarity s
* a fraction 1 - acos(s)/pi of signs agree), so close vectors produce
* visually close fingerprints while unrelated ones agree only by chance.
*
* @param {Array<number>|Array<Array<number>>|{indices: Array<number>, values: Array<number>}} vector
* @param {number} numBuckets - number of fingerprint cells (capped at 32)
* @return {Float32Array|null} - fingerprint values in [-1, 1], or null if the shape is not supported
*/
export const computeVectorFingerprint = (vector, numBuckets = MAX_BUCKETS) => {
if (!vector) {
return null;
}

let size = Math.max(1, Math.min(numBuckets, MAX_BUCKETS));
let norm2 = 0;
if (Array.isArray(vector)) {
const dim = Array.isArray(vector[0]) ? vector[0].length : vector.length;
size = Math.min(size, dim);
}
const buckets = new Float32Array(size);

if (Array.isArray(vector)) {
if (vector.length === 0) {
return null;
}
if (Array.isArray(vector[0])) {
// multivector: project each row with a row-specific sign pattern,
// so rows do not cancel each other out
for (let row = 0; row < vector.length; row++) {
if (Array.isArray(vector[row])) {
accumulateDense(vector[row], buckets, Math.imul(row, 0x9e3779b9));
norm2 += squaredNorm(vector[row]);
}
}
} else if (typeof vector[0] === 'number') {
accumulateDense(vector, buckets, 0);
norm2 = squaredNorm(vector);
} else {
return null;
}
} else if (Array.isArray(vector.indices) && Array.isArray(vector.values)) {
if (vector.indices.length === 0) {
return null;
}
accumulateSparse(vector.indices, vector.values, buckets);
norm2 = squaredNorm(vector.values);
} else {
return null;
}

if (norm2 === 0) {
return buckets;
}

const scale = 1 / (Math.sqrt(norm2) * DISPLAY_SIGMA);
for (let j = 0; j < buckets.length; j++) {
const value = buckets[j] * scale;
buckets[j] = Math.max(-1, Math.min(1, value));
}

return buckets;
};