use std::fs::File;
use std::io::{BufRead, BufReader, BufWriter, Read, Write};
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let [dir, want, queries, shards @ ..] = &args[..] else {
eprintln!("usage: msmarco <out directory> <vectors> <queries.jsonl> <passages.npy>...");
std::process::exit(2);
};
if shards.is_empty() {
eprintln!("usage: msmarco <out directory> <vectors> <queries.jsonl> <passages.npy>...");
std::process::exit(2);
}
let want: usize = want
.parse()
.expect("the second argument is how many vectors to take");
let set = prefix(dir);
std::fs::create_dir_all(dir).expect("could not make the output directory");
let dim = passages(dir, set, want, shards);
let n = query(dir, set, dim, queries);
println!("{n} queries at {dim} dimensions");
println!("now: cargo run --release -p yo-vector --example truth -- {dir}");
}
fn passages(dir: &str, set: &str, want: usize, shards: &[String]) -> usize {
let out = format!("{dir}/{set}_base.fvecs");
let mut file = BufWriter::with_capacity(1 << 20, File::create(&out).expect("could not write"));
let mut dim = 0usize;
let mut wrote = 0usize;
let mut norms = Norms::new();
for path in shards {
if wrote == want {
break;
}
let f = File::open(path).unwrap_or_else(|e| {
eprintln!("{path}: {e}");
std::process::exit(1);
});
let mut r = BufReader::with_capacity(1 << 22, f);
let (half, rows, cols) = npy(&mut r, path);
if dim == 0 {
dim = cols;
}
assert_eq!(cols, dim, "{path} is {cols} dimensional, not {dim}");
println!(
"{path}: {rows} by {cols}, {}",
if half { "f16" } else { "f32" }
);
let take = rows.min(want - wrote);
let mut raw = vec![0u8; cols * if half { 2 } else { 4 }];
let mut v = vec![0f32; cols];
for _ in 0..take {
r.read_exact(&mut raw).expect("short shard");
if half {
for (x, b) in v.iter_mut().zip(raw.as_chunks::<2>().0) {
*x = f16(u16::from_le_bytes(*b));
}
} else {
for (x, b) in v.iter_mut().zip(raw.as_chunks::<4>().0) {
*x = f32::from_le_bytes(*b);
}
}
norms.add(&v);
write_vec(&mut file, &v);
wrote += 1;
}
}
file.flush().expect("flush");
assert!(wrote > 0, "no vectors were written");
println!("wrote {out}, {wrote} vectors at {dim} dimensions");
norms.report();
dim
}
fn query(dir: &str, set: &str, dim: usize, path: &str) -> usize {
let out = format!("{dir}/{set}_query.fvecs");
let f = File::open(path).unwrap_or_else(|e| {
eprintln!("{path}: {e}");
eprintln!("this wants the plain jsonl, so gunzip queries.jsonl.gz first");
std::process::exit(1);
});
let mut r = BufReader::with_capacity(1 << 20, f);
let mut file = BufWriter::with_capacity(1 << 20, File::create(&out).expect("could not write"));
let mut line = String::new();
let mut n = 0usize;
let mut norms = Norms::new();
loop {
line.clear();
if r.read_line(&mut line).expect("could not read") == 0 {
break;
}
if line.trim().is_empty() {
continue;
}
let v = embedding(&line).unwrap_or_else(|| {
eprintln!("{path} line {} has no emb array", n + 1);
std::process::exit(1);
});
assert_eq!(v.len(), dim, "query {n} is {} dimensional", v.len());
norms.add(&v);
write_vec(&mut file, &v);
n += 1;
}
file.flush().expect("flush");
println!("wrote {out}, {n} queries");
norms.report();
n
}
fn embedding(line: &str) -> Option<Vec<f32>> {
let at = line.find("\"emb\"")?;
let open = line[at..].find('[')? + at + 1;
let close = line[open..].find(']')? + open;
let mut v = Vec::with_capacity(1024);
for part in line[open..close].split(',') {
v.push(part.trim().parse().ok()?);
}
Some(v)
}
fn write_vec(file: &mut BufWriter<File>, v: &[f32]) {
file.write_all(&(v.len() as i32).to_le_bytes())
.expect("write");
for x in v {
file.write_all(&x.to_le_bytes()).expect("write");
}
}
fn npy<R: Read>(r: &mut R, path: &str) -> (bool, usize, usize) {
let mut magic = [0u8; 8];
r.read_exact(&mut magic).expect("short file");
assert_eq!(&magic[..6], b"\x93NUMPY", "{path} is not a npy file");
let len = if magic[6] == 1 {
let mut n = [0u8; 2];
r.read_exact(&mut n).expect("short file");
u16::from_le_bytes(n) as usize
} else {
let mut n = [0u8; 4];
r.read_exact(&mut n).expect("short file");
u32::from_le_bytes(n) as usize
};
let mut head = vec![0u8; len];
r.read_exact(&mut head).expect("short file");
let head = String::from_utf8(head).expect("the npy header is not text");
let half = match field(&head, "'descr'") {
Some(d) if d.contains("<f2") => true,
Some(d) if d.contains("<f4") => false,
d => panic!("{path} holds {d:?}, and this reads <f2 and <f4"),
};
assert!(
field(&head, "'fortran_order'").is_some_and(|f| f.contains("False")),
"{path} is in column order, and this reads row order"
);
let shape = field(&head, "'shape'").expect("the npy header has no shape");
let mut dims = shape
.trim_matches(|c| c == '(' || c == ')')
.split(',')
.filter_map(|d| d.trim().parse::<usize>().ok());
let rows = dims.next().expect("the npy shape has no rows");
let cols = dims.next().expect("the npy shape is not two dimensional");
(half, rows, cols)
}
fn field<'a>(head: &'a str, key: &str) -> Option<&'a str> {
let at = head.find(key)? + key.len();
let rest = head[at..].trim_start().strip_prefix(':')?.trim_start();
let end = if rest.starts_with('(') {
rest.find(')')? + 1
} else {
rest.find(',').unwrap_or(rest.len())
};
Some(rest[..end].trim())
}
fn f16(bits: u16) -> f32 {
let sign = u32::from(bits & 0x8000) << 16;
let exp = u32::from(bits >> 10) & 0x1f;
let mant = u32::from(bits & 0x3ff);
let rest = match exp {
0 if mant == 0 => 0,
0 => {
let lz = mant.leading_zeros();
((134 - lz) << 23) | (((mant << (lz - 21)) & 0x3ff) << 13)
}
0x1f => 0x7f80_0000 | (mant << 13),
_ => ((exp + 112) << 23) | (mant << 13),
};
f32::from_bits(sign | rest)
}
struct Norms {
n: usize,
sum: f64,
low: f64,
high: f64,
}
impl Norms {
fn new() -> Norms {
Norms {
n: 0,
sum: 0.0,
low: f64::INFINITY,
high: 0.0,
}
}
fn add(&mut self, v: &[f32]) {
let len = v
.iter()
.map(|&x| f64::from(x) * f64::from(x))
.sum::<f64>()
.sqrt();
self.n += 1;
self.sum += len;
self.low = self.low.min(len);
self.high = self.high.max(len);
}
fn report(&self) {
if self.n == 0 {
return;
}
let mean = self.sum / self.n as f64;
println!(
" norms: mean {mean:.6}, from {:.6} to {:.6}",
self.low, self.high
);
if (self.high - self.low).abs() > 1e-3 {
println!(" these are not all the same length, so L2 and cosine will not agree");
}
}
}
fn prefix(dir: &str) -> &str {
dir.trim_end_matches('/')
.rsplit(['/', '\\'])
.next()
.unwrap_or(dir)
}