diff --git a/src/components/Points/PointVectors.jsx b/src/components/Points/PointVectors.jsx index 1df3d37d1..2a17c3d20 100644 --- a/src/components/Points/PointVectors.jsx +++ b/src/components/Points/PointVectors.jsx @@ -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'; @@ -67,8 +68,8 @@ const Vectors = memo(function Vectors({ point, onFindSimilar }) { )} - - + + Length: + { + 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 ( + + + + ); +}; + +VectorFingerprint.propTypes = { + vector: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), +}; + +export default VectorFingerprint; diff --git a/src/lib/tests/vector-fingerprint.test.js b/src/lib/tests/vector-fingerprint.test.js new file mode 100644 index 000000000..daf58468c --- /dev/null +++ b/src/lib/tests/vector-fingerprint.test.js @@ -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(); + }); +}); diff --git a/src/lib/vector-fingerprint.js b/src/lib/vector-fingerprint.js new file mode 100644 index 000000000..7cbfc25b5 --- /dev/null +++ b/src/lib/vector-fingerprint.js @@ -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|Array>|{indices: Array, values: Array}} 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; +};