* fix(graph): handle qualified C++ symbols Signed-off-by: trvon <me@trevon.dev> * refactor(vector)!: remove dead transition APIs Split Simeon vector and retrieval linkage so consumers inherit only the runtime surface they use. BREAKING CHANGE: remove unused async/model/config helpers and the undefined vector-backend factory from public headers. Signed-off-by: trvon <me@trevon.dev> * fix(vector): harden PQ snapshot lifecycle Validate and atomically install persisted generations, revalidate after races, and fall back to exact search when a snapshot is stale. Add the exact-scan reference lane and distinguish observed work counters from unavailable backend metrics. Signed-off-by: trvon <me@trevon.dev> * feat(topology): expose route coordinates Persist chart distortion and local-dimension evidence, retain pre-scalarization route axes, and emit detailed trace rows without treating unavailable vector work as zero. Signed-off-by: trvon <me@trevon.dev> * feat(xplan): add vector decision gates Track binary and corpus identity, preserve missing-work semantics, and add repeated PQ routing, crossover, and rerank plans with quality and cost summaries. Signed-off-by: trvon <me@trevon.dev> * feat(topology): formalize coordinate routing Separate chart geometry from route-risk admission and prove candidate-stage recall and strict-focus boundaries. Record the measured atlas limitations and literature basis for the next construction experiment. Signed-off-by: trvon <me@trevon.dev> * fix(search): serialize SymSpell readers SQLite-backed lookups reuse mutable prepared statements, so shared-reader locking is unsafe. Advance the owned SymSpell revision for batched delete insertion and cover concurrent root lookups under TSAN. Signed-off-by: trvon <me@trevon.dev> * fix(vector): make mutations atomic Preserve stored vectors on replacement and commit failures, reject cross-thread writes during explicit transactions, and restore exact reranking for quantized-primary PQ rows. * fix(search)!: surface SymSpell write errors Propagate persistent dictionary failures through metadata ingestion, roll back failed batches, and make clear remove stored terms. BREAKING CHANGE: SymSpellSearch mutations now return Result values. * feat(topology): formalize candidate expansion * feat(vector): add coordinate-aware embeddings * feat(topology): preserve protected relation cover * feat(search): add graph candidate rescue * feat(daemon): expose retrieval hydration telemetry * feat(xplan): add rescue and hydration gates * refactor(content): dedupe media handlers Reuse shared graph presenter helpers and centralize repeated audio/video/archive buffer processing, validation, lifecycle, and stats helper logic. * feat(lifecycle): prune stale corpus data Keep cleanup preview-first while retention stays opt-in and long prune work no longer holds IPC responses open. * feat(search): add topology candidate rescue Preserve high-value routed candidates across vector backends while keeping exact rerank diagnostics explicit. * feat(search): expose evidence paths * docs: define mobile host contract * formal: model bounded candidate rescue * fix(prune): inspect extensionless signatures * fix(prune): tolerate stale extensions --------- Signed-off-by: trvon <me@trevon.dev>
427 lines
17 KiB
C++
427 lines
17 KiB
C++
#include <algorithm>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <numeric>
|
|
#include <random>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <unordered_set>
|
|
#include <vector>
|
|
|
|
#include <spdlog/spdlog.h>
|
|
|
|
#include "bench_utils.h"
|
|
#include <yams/vector/sqlite_vec_backend.h>
|
|
|
|
using namespace yams::vector;
|
|
|
|
namespace {
|
|
|
|
struct Config {
|
|
size_t corpus = 5000;
|
|
size_t queries = 100;
|
|
size_t dim = 128;
|
|
size_t k = 10;
|
|
unsigned int seed = 42;
|
|
bool spq_sweep = false;
|
|
bool filter_cell = false;
|
|
};
|
|
|
|
struct EngineResult {
|
|
const char* name = "";
|
|
double build_ms = 0.0;
|
|
double mean_us = 0.0;
|
|
double p95_us = 0.0;
|
|
double p99_us = 0.0;
|
|
double qps = 0.0;
|
|
double recall = 0.0;
|
|
};
|
|
|
|
Config parseArgs(int argc, char* argv[]) {
|
|
Config cfg;
|
|
for (int i = 1; i < argc; ++i) {
|
|
std::string_view arg(argv[i]);
|
|
auto parse = [&](std::string_view prefix, auto& out) {
|
|
if (arg.rfind(prefix, 0) == 0) {
|
|
out = static_cast<std::decay_t<decltype(out)>>(
|
|
std::stoull(std::string(arg.substr(prefix.size()))));
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
if (parse("--corpus=", cfg.corpus) || parse("--queries=", cfg.queries) ||
|
|
parse("--dim=", cfg.dim) || parse("--k=", cfg.k) || parse("--seed=", cfg.seed)) {
|
|
continue;
|
|
}
|
|
if (arg == "--spq-sweep") {
|
|
cfg.spq_sweep = true;
|
|
continue;
|
|
}
|
|
if (arg == "--filter-cell") {
|
|
cfg.filter_cell = true;
|
|
continue;
|
|
}
|
|
if (arg == "--help") {
|
|
std::printf("Usage: %s [--corpus=N] [--queries=N] [--dim=N] [--k=N] [--seed=N] "
|
|
"[--spq-sweep] [--filter-cell]\n",
|
|
argv[0]);
|
|
std::exit(0);
|
|
}
|
|
}
|
|
if (cfg.queries == 0) {
|
|
throw std::invalid_argument("--queries must be >= 1");
|
|
}
|
|
if (cfg.k == 0) {
|
|
throw std::invalid_argument("--k must be >= 1");
|
|
}
|
|
return cfg;
|
|
}
|
|
|
|
std::vector<float> generateNormalizedVector(size_t dim, std::mt19937& rng) {
|
|
std::uniform_real_distribution<float> dist(-1.0f, 1.0f);
|
|
std::vector<float> vec(dim);
|
|
float norm_sq = 0.0f;
|
|
for (auto& value : vec) {
|
|
value = dist(rng);
|
|
norm_sq += value * value;
|
|
}
|
|
float norm = std::sqrt(norm_sq);
|
|
if (norm > 0.0f) {
|
|
for (auto& value : vec) {
|
|
value /= norm;
|
|
}
|
|
}
|
|
return vec;
|
|
}
|
|
|
|
std::vector<std::vector<float>> generateCorpus(size_t count, size_t dim, std::mt19937& rng) {
|
|
std::vector<std::vector<float>> corpus;
|
|
corpus.reserve(count);
|
|
for (size_t i = 0; i < count; ++i) {
|
|
corpus.push_back(generateNormalizedVector(dim, rng));
|
|
}
|
|
return corpus;
|
|
}
|
|
|
|
std::vector<size_t> bruteForceTopK(const std::vector<float>& query,
|
|
const std::vector<std::vector<float>>& corpus, size_t k) {
|
|
std::vector<std::pair<size_t, float>> scored;
|
|
scored.reserve(corpus.size());
|
|
for (size_t i = 0; i < corpus.size(); ++i) {
|
|
float dot = 0.0f;
|
|
for (size_t j = 0; j < query.size(); ++j) {
|
|
dot += query[j] * corpus[i][j];
|
|
}
|
|
scored.emplace_back(i, dot);
|
|
}
|
|
|
|
const size_t top = std::min(k, scored.size());
|
|
std::partial_sort(scored.begin(), scored.begin() + top, scored.end(),
|
|
[](const auto& lhs, const auto& rhs) { return lhs.second > rhs.second; });
|
|
|
|
std::vector<size_t> ids;
|
|
ids.reserve(top);
|
|
for (size_t i = 0; i < top; ++i) {
|
|
ids.push_back(scored[i].first);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
double percentile(std::vector<double> values, double p) {
|
|
return yams::bench::percentileNearestRank(std::move(values), p);
|
|
}
|
|
|
|
EngineResult runEngine(VectorSearchEngine engine, const Config& cfg,
|
|
const std::vector<std::vector<float>>& corpus,
|
|
const std::vector<std::vector<float>>& queries,
|
|
const std::vector<std::vector<size_t>>& ground_truth,
|
|
size_t spq_subquantizers = 0, size_t spq_rerank_factor = 0) {
|
|
SqliteVecBackend::Config backend_cfg;
|
|
backend_cfg.embedding_dim = cfg.dim;
|
|
backend_cfg.search_engine = engine;
|
|
if (spq_subquantizers > 0) {
|
|
backend_cfg.simeon_pq_subquantizers = spq_subquantizers;
|
|
}
|
|
if (spq_rerank_factor > 0) {
|
|
backend_cfg.simeon_pq_rerank_factor = spq_rerank_factor;
|
|
}
|
|
|
|
SqliteVecBackend backend(backend_cfg);
|
|
auto init = backend.initialize(":memory:");
|
|
if (!init) {
|
|
throw std::runtime_error(init.error().message);
|
|
}
|
|
auto create = backend.createTables(cfg.dim);
|
|
if (!create) {
|
|
throw std::runtime_error(create.error().message);
|
|
}
|
|
|
|
std::vector<VectorRecord> records;
|
|
records.reserve(corpus.size());
|
|
for (size_t i = 0; i < corpus.size(); ++i) {
|
|
VectorRecord rec;
|
|
rec.chunk_id = "chunk_" + std::to_string(i);
|
|
rec.document_hash = "doc_" + std::to_string(i / 4);
|
|
rec.embedding = corpus[i];
|
|
rec.content = "benchmark content " + std::to_string(i);
|
|
records.push_back(std::move(rec));
|
|
}
|
|
auto insert = backend.insertVectorsBatch(records);
|
|
if (!insert) {
|
|
throw std::runtime_error(insert.error().message);
|
|
}
|
|
|
|
const auto build_start = std::chrono::steady_clock::now();
|
|
auto build = backend.buildIndex();
|
|
if (!build) {
|
|
throw std::runtime_error(build.error().message);
|
|
}
|
|
const auto build_end = std::chrono::steady_clock::now();
|
|
|
|
// Exclude one-time lazy index initialization from steady-state query timing. Run the same
|
|
// unmeasured query for every engine so Vec0, Simeon PQ, and exact scan enter the measured loop
|
|
// from the same query-ready boundary.
|
|
auto warmup = backend.searchSimilar(queries.front(), cfg.k, 0.0f, std::nullopt, {});
|
|
if (!warmup) {
|
|
throw std::runtime_error(warmup.error().message);
|
|
}
|
|
|
|
std::vector<double> latencies_us;
|
|
latencies_us.reserve(queries.size());
|
|
size_t total_hits = 0;
|
|
|
|
for (size_t i = 0; i < queries.size(); ++i) {
|
|
const auto start = std::chrono::steady_clock::now();
|
|
auto result = backend.searchSimilar(queries[i], cfg.k, 0.0f, std::nullopt, {});
|
|
const auto end = std::chrono::steady_clock::now();
|
|
if (!result) {
|
|
throw std::runtime_error(result.error().message);
|
|
}
|
|
|
|
latencies_us.push_back(std::chrono::duration<double, std::micro>(end - start).count());
|
|
|
|
std::unordered_set<std::string> expected;
|
|
for (size_t idx : ground_truth[i]) {
|
|
expected.insert("chunk_" + std::to_string(idx));
|
|
}
|
|
for (const auto& rec : result.value()) {
|
|
total_hits += expected.contains(rec.chunk_id) ? 1 : 0;
|
|
}
|
|
}
|
|
|
|
const double total_us = std::accumulate(latencies_us.begin(), latencies_us.end(), 0.0);
|
|
EngineResult out;
|
|
switch (engine) {
|
|
case VectorSearchEngine::SimeonPqAdc:
|
|
out.name = "simeon-pq";
|
|
break;
|
|
case VectorSearchEngine::Vec0L2:
|
|
out.name = "vec0-l2";
|
|
break;
|
|
case VectorSearchEngine::ExactScan:
|
|
out.name = "exact-scan";
|
|
break;
|
|
}
|
|
out.build_ms = std::chrono::duration<double, std::milli>(build_end - build_start).count();
|
|
out.mean_us = total_us / static_cast<double>(latencies_us.size());
|
|
out.p95_us = percentile(latencies_us, 0.95);
|
|
out.p99_us = percentile(latencies_us, 0.99);
|
|
out.qps = out.mean_us > 0.0 ? (1e6 / out.mean_us) : 0.0;
|
|
out.recall = static_cast<double>(total_hits) / static_cast<double>(queries.size() * cfg.k);
|
|
return out;
|
|
}
|
|
|
|
void printResult(const EngineResult& result, size_t k) {
|
|
std::printf("%-12s build=%8.1f ms mean=%8.1f us p95=%8.1f us p99=%8.1f us qps=%8.0f "
|
|
"recall@%zu=%5.1f%%\n",
|
|
result.name, result.build_ms, result.mean_us, result.p95_us, result.p99_us,
|
|
result.qps, k, result.recall * 100.0);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char* argv[]) {
|
|
spdlog::set_level(spdlog::level::warn);
|
|
|
|
try {
|
|
const Config cfg = parseArgs(argc, argv);
|
|
std::mt19937 rng(cfg.seed);
|
|
auto corpus = generateCorpus(cfg.corpus, cfg.dim, rng);
|
|
auto queries = generateCorpus(cfg.queries, cfg.dim, rng);
|
|
|
|
std::vector<std::vector<size_t>> ground_truth;
|
|
ground_truth.reserve(queries.size());
|
|
for (const auto& query : queries) {
|
|
ground_truth.push_back(bruteForceTopK(query, corpus, cfg.k));
|
|
}
|
|
|
|
std::printf("============================================================\n");
|
|
std::printf("YAMS Vector Backend Engine Compare\n");
|
|
std::printf("============================================================\n");
|
|
std::printf("corpus=%zu queries=%zu dim=%zu k=%zu seed=%u\n", cfg.corpus, cfg.queries,
|
|
cfg.dim, cfg.k, cfg.seed);
|
|
std::printf("note: corpus/query vectors are normalized, so cosine and L2 rankings are "
|
|
"comparable\n\n");
|
|
|
|
if (cfg.filter_cell) {
|
|
std::printf("filtered-search cell: routed Simeon PQ/ADC versus exact allowed-set "
|
|
"control\n");
|
|
SqliteVecBackend::Config backend_cfg;
|
|
backend_cfg.embedding_dim = cfg.dim;
|
|
backend_cfg.search_engine = VectorSearchEngine::SimeonPqAdc;
|
|
SqliteVecBackend backend(backend_cfg);
|
|
if (!backend.initialize(":memory:")) {
|
|
throw std::runtime_error("init failed");
|
|
}
|
|
if (!backend.createTables(cfg.dim)) {
|
|
throw std::runtime_error("createTables failed");
|
|
}
|
|
std::vector<VectorRecord> records;
|
|
records.reserve(corpus.size());
|
|
for (size_t i = 0; i < corpus.size(); ++i) {
|
|
VectorRecord rec;
|
|
rec.chunk_id = "chunk_" + std::to_string(i);
|
|
rec.document_hash = "doc_" + std::to_string(i / 4);
|
|
rec.embedding = corpus[i];
|
|
rec.content = "benchmark content " + std::to_string(i);
|
|
records.push_back(std::move(rec));
|
|
}
|
|
if (!backend.insertVectorsBatch(records)) {
|
|
throw std::runtime_error("insert failed");
|
|
}
|
|
const auto buildStart = std::chrono::steady_clock::now();
|
|
if (!backend.buildIndex()) {
|
|
throw std::runtime_error("buildIndex failed");
|
|
}
|
|
const auto buildEnd = std::chrono::steady_clock::now();
|
|
std::printf("index_build_ms=%.1f vectors=%zu documents=%zu chunks_per_document=4\n",
|
|
std::chrono::duration<double, std::milli>(buildEnd - buildStart).count(),
|
|
cfg.corpus, (cfg.corpus + 3) / 4);
|
|
|
|
const size_t documentCount = (cfg.corpus + 3) / 4;
|
|
const std::vector<size_t> candidateCounts = {2UL, 7UL, 16UL, 64UL, documentCount};
|
|
for (const size_t nCandidates : candidateCounts) {
|
|
const size_t candidateCount = std::min(nCandidates, documentCount);
|
|
std::unordered_set<std::string> candidates;
|
|
for (size_t i = 0; i < candidateCount; ++i) {
|
|
candidates.insert("doc_" + std::to_string(i));
|
|
}
|
|
|
|
VectorSearchDiagnostics warmupDiagnostics;
|
|
auto warmup = backend.searchSimilarWithDiagnostics(
|
|
queries.front(), cfg.k, -1.0F, std::nullopt, candidates, {}, warmupDiagnostics);
|
|
if (!warmup) {
|
|
throw std::runtime_error(warmup.error().message);
|
|
}
|
|
|
|
std::vector<double> annLatencies;
|
|
std::vector<double> exactLatencies;
|
|
annLatencies.reserve(queries.size());
|
|
exactLatencies.reserve(queries.size());
|
|
std::uint64_t lookupNs = 0;
|
|
std::uint64_t projectionNs = 0;
|
|
std::uint64_t lutNs = 0;
|
|
std::uint64_t adcNs = 0;
|
|
std::uint64_t selectionNs = 0;
|
|
std::uint64_t materializationNs = 0;
|
|
std::uint64_t rerankNs = 0;
|
|
size_t candidateIndexPayloadBytes = 0;
|
|
size_t recallHits = 0;
|
|
size_t recallDenominator = 0;
|
|
for (const auto& q : queries) {
|
|
VectorSearchDiagnostics annDiagnostics;
|
|
const auto annStart = std::chrono::steady_clock::now();
|
|
auto ann = backend.searchSimilarWithDiagnostics(q, cfg.k, -1.0F, std::nullopt,
|
|
candidates, {}, annDiagnostics);
|
|
const auto annEnd = std::chrono::steady_clock::now();
|
|
if (!ann) {
|
|
throw std::runtime_error(ann.error().message);
|
|
}
|
|
VectorSearchDiagnostics exactDiagnostics;
|
|
const auto exactStart = std::chrono::steady_clock::now();
|
|
auto exact = backend.searchExactCandidatesWithDiagnostics(
|
|
q, cfg.k, -1.0F, candidates, exactDiagnostics);
|
|
const auto exactEnd = std::chrono::steady_clock::now();
|
|
if (!exact) {
|
|
throw std::runtime_error(exact.error().message);
|
|
}
|
|
|
|
annLatencies.push_back(
|
|
std::chrono::duration<double, std::micro>(annEnd - annStart).count());
|
|
exactLatencies.push_back(
|
|
std::chrono::duration<double, std::micro>(exactEnd - exactStart).count());
|
|
lookupNs += annDiagnostics.candidateLookupNanoseconds;
|
|
projectionNs += annDiagnostics.candidateProjectionNanoseconds;
|
|
lutNs += annDiagnostics.pqLutNanoseconds;
|
|
adcNs += annDiagnostics.adcScoringNanoseconds;
|
|
selectionNs += annDiagnostics.topKSelectionNanoseconds;
|
|
materializationNs += annDiagnostics.resultMaterializationNanoseconds;
|
|
rerankNs += annDiagnostics.exactRerankNanoseconds;
|
|
candidateIndexPayloadBytes = std::max(
|
|
candidateIndexPayloadBytes, annDiagnostics.candidateIndexPayloadBytes);
|
|
|
|
std::unordered_set<std::string> exactIds;
|
|
for (const auto& record : exact.value()) {
|
|
exactIds.insert(record.chunk_id);
|
|
}
|
|
recallDenominator += exactIds.size();
|
|
for (const auto& record : ann.value()) {
|
|
recallHits += exactIds.contains(record.chunk_id) ? 1 : 0;
|
|
}
|
|
}
|
|
const double divisor = static_cast<double>(queries.size());
|
|
const double annMean =
|
|
std::accumulate(annLatencies.begin(), annLatencies.end(), 0.0) / divisor;
|
|
const double exactMean =
|
|
std::accumulate(exactLatencies.begin(), exactLatencies.end(), 0.0) / divisor;
|
|
const auto meanUs = [divisor](std::uint64_t nanoseconds) {
|
|
return static_cast<double>(nanoseconds) / (1000.0 * divisor);
|
|
};
|
|
const double recall =
|
|
recallDenominator == 0
|
|
? 1.0
|
|
: static_cast<double>(recallHits) / static_cast<double>(recallDenominator);
|
|
std::printf(
|
|
"allowed_docs=%2zu allowed_vectors<=%3zu ann_mean=%8.1f us ann_p95=%8.1f us "
|
|
"exact_mean=%8.1f us recall=%5.1f%% phases_us{lookup=%.1f projection=%.1f "
|
|
"lut=%.1f adc=%.1f select=%.1f materialize=%.1f rerank=%.1f} "
|
|
"candidate_index_payload_kib=%.1f\n",
|
|
candidateCount, candidateCount * 4, annMean, percentile(annLatencies, 0.95),
|
|
exactMean, recall * 100.0, meanUs(lookupNs), meanUs(projectionNs),
|
|
meanUs(lutNs), meanUs(adcNs), meanUs(selectionNs), meanUs(materializationNs),
|
|
meanUs(rerankNs), static_cast<double>(candidateIndexPayloadBytes) / 1024.0);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
if (cfg.spq_sweep) {
|
|
std::printf("simeon-pq sweep: subquantizers x rerank_factor\n");
|
|
for (size_t m : {8UL, 16UL, 32UL, 64UL}) {
|
|
if (cfg.dim % m != 0) {
|
|
continue;
|
|
}
|
|
for (size_t rerank : {2UL, 4UL, 8UL}) {
|
|
auto result = runEngine(VectorSearchEngine::SimeonPqAdc, cfg, corpus, queries,
|
|
ground_truth, m, rerank);
|
|
std::printf("m=%-3zu rerank=%zu ", m, rerank);
|
|
printResult(result, cfg.k);
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
const auto spq =
|
|
runEngine(VectorSearchEngine::SimeonPqAdc, cfg, corpus, queries, ground_truth);
|
|
const auto vec0 = runEngine(VectorSearchEngine::Vec0L2, cfg, corpus, queries, ground_truth);
|
|
|
|
printResult(spq, cfg.k);
|
|
printResult(vec0, cfg.k);
|
|
return 0;
|
|
} catch (const std::exception& e) {
|
|
std::fprintf(stderr, "Benchmark failed: %s\n", e.what());
|
|
return 1;
|
|
}
|
|
}
|