feat: add multimodal search and granular performance diagnostics
Core CI / core (ubuntu-latest) (push) Failing after 3m31s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m39s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
Core CI / core (ubuntu-latest) (push) Failing after 3m31s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m39s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
This commit is contained in:
1 parent
d3bb8c7618
commit
b8400c4fbf
110 files changed
+79395
-250
No files matched your search
@@ -11,6 +11,9 @@ minilm-proof = ["dep:ort", "dep:tokenizers"]
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
base64 = "0.22"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "bmp"] }
|
||||
jpeg-decoder = { version = "0.3.2", default-features = false }
|
||||
ort = { version = "=2.0.0-rc.13", optional = true, default-features = false, features = ["std", "load-dynamic", "api-27"] }
|
||||
sha2 = "0.10"
|
||||
tokenizers = { version = "=0.22.2", optional = true, default-features = false, features = ["fancy-regex"] }
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Google worker image decoding
|
||||
|
||||
The production adapter verifies model/projector SHA256 and performs genuine
|
||||
Google MTMD image/audio inference. Images are decoded with established Rust
|
||||
libraries, not filename captions. `WorkerInfo.media_supported` comes from the
|
||||
native handshake; absent fields on older text workers mean false. Media APIs
|
||||
return an explicit runtime repair error for such workers while text remains
|
||||
compatible.
|
||||
|
||||
JPEG sources up to 64 Mi pixels (including 48 MP camera photos), 16,384 pixels
|
||||
per side, and 64 MiB compressed size are supported. For JPEG above 16 Mi pixels,
|
||||
`image-rs/jpeg-decoder` 0.3.2 applies scaled IDCT before final model resizing.
|
||||
Its default features are disabled, so this path adds no Rayon worker pool.
|
||||
The source dimensions are checked before pixels are decoded. The secondary
|
||||
header must match the first to detect file changes. RGB24 and grayscale camera
|
||||
JPEGs are supported on this scaled path; unusual large CMYK/16-bit JPEGs fail
|
||||
explicitly rather than silently taking an unbounded full-resolution fallback.
|
||||
|
||||
The published decoder offers 1/8, 1/4, 1/2, and full-scale decoding. It is in
|
||||
maintenance mode; the current image 0.25 zune JPEG adapter exposes no scaled
|
||||
IDCT interface. See the [decoder API](https://docs.rs/jpeg-decoder/0.3.2/jpeg_decoder/struct.Decoder.html)
|
||||
and [upstream maintenance note](https://github.com/image-rs/jpeg-decoder).
|
||||
Small JPEGs retain the existing image/zune path and pinned parity behavior.
|
||||
|
||||
PNG, WebP, and BMP remain capped at 16 Mi pixels with explicit diagnostics.
|
||||
Both paths enforce a 256 MiB advertised decoding-buffer ceiling and apply
|
||||
EXIF orientation. That ceiling is a buffer/output bound, not an absolute
|
||||
process RSS guarantee: compressed data, codec working state, and resizing
|
||||
have additional allocations. Extreme/progressive files need broader resource
|
||||
profiling. The media fingerprint includes `largejpeg-scaled-v2`; generations
|
||||
must additionally include visual token budget. Text fingerprints are unchanged.
|
||||
|
||||
An executed Windows CPU release test encodes and decodes an actual 8000x6000
|
||||
JPEG with EXIF rotation, verifies orientation and region colors, and resizes it
|
||||
to a 280-token grid. Decode plus resize took 176–220 ms and produced 672x912
|
||||
RGB (1,838,592 bytes). A sampled whole-test peak was 146.86 MiB, including
|
||||
144 MB of source fixture construction; it is not a decoder-only measurement
|
||||
or a semantic quality benchmark. An 81 MP JPEG header is rejected before
|
||||
pixel decode in the default tests.
|
||||
|
||||
```powershell
|
||||
cargo test --release -p searchi-semantic large_phone_jpeg -- --ignored --nocapture
|
||||
cargo test -p searchi-semantic --lib
|
||||
```
|
||||
@@ -41,11 +41,12 @@ set(LLAMA_BUILD_TOOLS OFF CACHE BOOL "No runtime tools" FORCE)
|
||||
set(LLAMA_BUILD_SERVER OFF CACHE BOOL "No HTTP server" FORCE)
|
||||
set(LLAMA_BUILD_EXAMPLES OFF CACHE BOOL "No examples" FORCE)
|
||||
set(LLAMA_BUILD_TESTS OFF CACHE BOOL "No upstream tests in worker build" FORCE)
|
||||
set(LLAMA_BUILD_MTMD OFF CACHE BOOL "Text worker; media encoder follows separately" FORCE)
|
||||
set(LLAMA_BUILD_MTMD ON CACHE BOOL "Google image/audio projector" FORCE)
|
||||
set(MTMD_VIDEO OFF CACHE BOOL "Frames decoded by bounded media adapter" FORCE)
|
||||
set(LLAMA_OPENSSL OFF CACHE BOOL "No network runtime dependencies" FORCE)
|
||||
add_subdirectory("${LLAMA_CPP_DIR}" llama-build)
|
||||
add_executable(searchi-gemma-worker worker.cpp)
|
||||
target_include_directories(searchi-gemma-worker PRIVATE "${LLAMA_CPP_DIR}/vendor")
|
||||
target_link_libraries(searchi-gemma-worker PRIVATE llama)
|
||||
target_link_libraries(searchi-gemma-worker PRIVATE llama mtmd)
|
||||
target_compile_definitions(searchi-gemma-worker PRIVATE SEARCHI_LLAMA_TAG="b11461-no-logits-v1")
|
||||
install(TARGETS searchi-gemma-worker RUNTIME DESTINATION bin)
|
||||
@@ -1,11 +1,14 @@
|
||||
// Local NDJSON helper around the pinned llama.cpp encoder. stdout is exclusively
|
||||
// protocol output; llama diagnostics go to stderr. No model download or HTTP.
|
||||
#include "llama.h"
|
||||
#include "mtmd.h"
|
||||
#include "nlohmann/json.hpp"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
@@ -13,7 +16,7 @@
|
||||
#include <vector>
|
||||
|
||||
using json = nlohmann::json;
|
||||
constexpr size_t max_request_bytes = 2 * 1024 * 1024;
|
||||
constexpr size_t max_request_bytes = 16 * 1024 * 1024;
|
||||
constexpr size_t max_passage_bytes = 1024 * 1024;
|
||||
constexpr size_t max_text_bytes = 65536;
|
||||
constexpr size_t max_windows = 256;
|
||||
@@ -34,12 +37,14 @@ class Encoder {
|
||||
llama_context * context = nullptr;
|
||||
llama_batch_ext * batch = nullptr;
|
||||
const llama_vocab * vocab = nullptr;
|
||||
mtmd_context * media = nullptr;
|
||||
std::string model_path;
|
||||
void release() {
|
||||
if (media) mtmd_free(media);
|
||||
if (batch) llama_batch_ext_free(batch);
|
||||
if (context) llama_free(context);
|
||||
if (model) llama_model_free(model);
|
||||
batch = nullptr; context = nullptr; model = nullptr; vocab = nullptr;
|
||||
batch = nullptr; context = nullptr; model = nullptr; vocab = nullptr; media = nullptr;
|
||||
}
|
||||
bool open(bool gpu) {
|
||||
auto mp = llama_model_default_params();
|
||||
@@ -68,6 +73,9 @@ public:
|
||||
llama_log_set([](ggml_log_level level, const char * text, void *) {
|
||||
if (level >= GGML_LOG_LEVEL_WARN) std::fputs(text,stderr);
|
||||
},nullptr);
|
||||
mtmd_log_set([](ggml_log_level level, const char * text, void *) {
|
||||
if (level >= GGML_LOG_LEVEL_WARN) std::fputs(text,stderr);
|
||||
},nullptr);
|
||||
llama_backend_init();
|
||||
bool use_gpu = want_gpu && llama_supports_gpu_offload();
|
||||
require(open(use_gpu) || (use_gpu && open(false)),"Cannot initialize local EmbeddingGemma 2 model/context");
|
||||
@@ -77,6 +85,52 @@ public:
|
||||
llama_backend_free();
|
||||
}
|
||||
bool healthy() const {return context != nullptr && batch != nullptr;}
|
||||
void ensure_media(const std::string & projector) {
|
||||
if (media) return;
|
||||
auto params = mtmd_context_params_default();
|
||||
params.use_gpu = device == "gpu"; params.n_threads = threads;
|
||||
params.print_timings = false; params.warmup = false;
|
||||
params.image_min_tokens = 1; params.image_max_tokens = 1120;
|
||||
media = mtmd_init_from_file(projector.c_str(),model,params);
|
||||
require_inference(media != nullptr,"Cannot initialize Google media projector");
|
||||
}
|
||||
std::vector<float> embed_bitmap(mtmd_bitmap * bitmap) {
|
||||
std::unique_ptr<mtmd_input_chunks,decltype(&mtmd_input_chunks_free)> chunks(mtmd_input_chunks_init(),mtmd_input_chunks_free);
|
||||
require_inference(chunks != nullptr,"Cannot allocate media chunks");
|
||||
const char * marker = mtmd_default_marker();
|
||||
mtmd_input_text text{marker,std::strlen(marker),true,true};
|
||||
const mtmd_bitmap * bitmaps[] = {bitmap};
|
||||
require_inference(mtmd_tokenize(media,chunks.get(),&text,bitmaps,1) == 0,"Media tokenization failed");
|
||||
llama_batch_ext_clear(batch); size_t position = 0;
|
||||
auto place = [&](int index) {
|
||||
require_inference(index >= 0 && position < context_tokens,"Media batch exceeds context");
|
||||
llama_pos pos[4] = {llama_pos(position++),0,0,0};
|
||||
require_inference(llama_batch_ext_set_pos(batch,index,pos) && llama_batch_ext_set_output_embd(batch,index,true),"Cannot place media row");
|
||||
};
|
||||
for (size_t i=0;i<mtmd_input_chunks_size(chunks.get());++i) {
|
||||
const auto * chunk = mtmd_input_chunks_get(chunks.get(),i);
|
||||
if (mtmd_input_chunk_get_type(chunk) == MTMD_INPUT_CHUNK_TYPE_TEXT) {
|
||||
size_t count=0; const auto * tokens=mtmd_input_chunk_get_tokens_text(chunk,&count);
|
||||
for(size_t j=0;j<count;++j) place(llama_batch_ext_add_token(batch,0,tokens[j]));
|
||||
} else {
|
||||
require_inference(mtmd_encode_chunk(media,chunk)==0,"Google media encoder failed");
|
||||
const float * rows=mtmd_get_output_embd(media);
|
||||
require_inference(rows != nullptr,"No projected media rows");
|
||||
size_t width=llama_model_n_embd_inp(model);
|
||||
for(size_t j=0;j<mtmd_input_chunk_get_n_tokens(chunk);++j) {
|
||||
llama_embd embd{rows+j*width,1,width};
|
||||
place(llama_batch_ext_add_embd(batch,0,embd));
|
||||
}
|
||||
}
|
||||
}
|
||||
require_inference(llama_process(context,LLAMA_PROCESS_TYPE_ENCODE,batch)==0,"Media text encoder failed");
|
||||
const float * pooled=llama_get_embeddings_seq(context,0);
|
||||
require_inference(pooled != nullptr,"No pooled media vector");
|
||||
double norm=0; for(int i=0;i<dimensions;++i){require_inference(std::isfinite(pooled[i]),"Nonfinite media vector");norm+=double(pooled[i])*pooled[i];}
|
||||
require_inference(norm>0 && std::isfinite(norm),"Invalid media vector norm");
|
||||
std::vector<float> vector(dimensions);for(int i=0;i<dimensions;++i)vector[i]=float(pooled[i]/std::sqrt(norm));
|
||||
return vector;
|
||||
}
|
||||
void retry_cpu() {
|
||||
require(device == "gpu","Only a GPU inference failure can request a CPU retry");
|
||||
release();
|
||||
@@ -103,10 +157,21 @@ public:
|
||||
require(title.size() <= 512, "Document title exceeds 512 UTF-8 bytes");
|
||||
return "title: " + (title.empty() ? "none" : title) + " | text: ";
|
||||
}
|
||||
std::vector<float> embed(const std::string & text, int cap) {
|
||||
std::vector<float> embed(const std::string & text, int cap, json * timings = nullptr) {
|
||||
using clock = std::chrono::steady_clock;
|
||||
auto started = timings ? clock::now() : clock::time_point{};
|
||||
auto mark = [&](const char * name) {
|
||||
if (timings) {
|
||||
auto now = clock::now();
|
||||
(*timings)[name] = std::chrono::duration_cast<std::chrono::microseconds>(now-started).count();
|
||||
started = now;
|
||||
}
|
||||
};
|
||||
require(!text.empty() && text.size() <= max_text_bytes, "Embedding text must be nonempty and at most 64 KiB");
|
||||
auto tokens = tokenize(text,true);
|
||||
require(!tokens.empty() && tokens.size() <= size_t(cap), "Prompt exceeds model token budget; split document windows first");
|
||||
if (timings) (*timings)["token_count"] = tokens.size();
|
||||
mark("tokenization_us");
|
||||
llama_batch_ext_clear(batch);
|
||||
for (size_t i = 0; i < tokens.size(); ++i) {
|
||||
int index = llama_batch_ext_add_token(batch,0,tokens[i]);
|
||||
@@ -115,7 +180,9 @@ public:
|
||||
require(llama_batch_ext_set_pos(batch,index,position), "Cannot set encoder position");
|
||||
require(llama_batch_ext_set_output_embd(batch,index,true), "Cannot select embedding output");
|
||||
}
|
||||
mark("batch_prepare_us");
|
||||
require_inference(llama_process(context,LLAMA_PROCESS_TYPE_ENCODE,batch) == 0, "llama_process encoder failed");
|
||||
mark("inference_us");
|
||||
const float * pooled = llama_get_embeddings_seq(context,0);
|
||||
require_inference(pooled != nullptr, "Encoder returned no pooled vector");
|
||||
double norm = 0;
|
||||
@@ -123,6 +190,7 @@ public:
|
||||
require_inference(norm > 0 && std::isfinite(norm), "Zero or nonfinite embedding norm");
|
||||
std::vector<float> vector(dimensions);
|
||||
for (int i = 0; i < dimensions; ++i) vector[i] = float(pooled[i]/std::sqrt(norm));
|
||||
mark("pool_normalize_us");
|
||||
return vector;
|
||||
}
|
||||
json windows(const std::string & text, const std::string & title) const {
|
||||
@@ -157,8 +225,42 @@ public:
|
||||
}
|
||||
json handle(const json & request) {
|
||||
auto op = request.at("op").get<std::string>();
|
||||
if(op=="image" || op=="audio") {
|
||||
auto encoded=request.at("data").get<std::string>();
|
||||
require(encoded.size()<=12000000 && encoded.size()%4==0,"Invalid media payload length");
|
||||
static const std::string alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::vector<unsigned char> bytes; bytes.reserve(encoded.size()/4*3);
|
||||
unsigned accumulator=0;int bits=0;bool padding=false;
|
||||
for(char c:encoded){if(c=='='){padding=true;continue;} require(!padding,"Invalid base64 padding");auto n=alphabet.find(c);require(n!=std::string::npos,"Invalid base64");accumulator=(accumulator<<6)|unsigned(n);bits+=6;if(bits>=8){bits-=8;bytes.push_back(static_cast<unsigned char>(accumulator>>bits));}}
|
||||
auto projector=request.at("projector").get<std::string>();
|
||||
require(!projector.empty() && projector.size()<32768,"Invalid projector path");
|
||||
ensure_media(projector);
|
||||
mtmd_bitmap * raw=nullptr;
|
||||
if(op=="image") {
|
||||
int w=request.at("width"),h=request.at("height");
|
||||
require(w>=48 && h>=48 && w%48==0 && h%48==0 && uint64_t(w)*h<=1120*48*48,"Invalid bounded image dimensions");
|
||||
require(bytes.size()==size_t(w)*h*3,"RGB image length mismatch");
|
||||
raw=mtmd_bitmap_init(w,h,bytes.data());
|
||||
} else {
|
||||
require(bytes.size()%4==0 && !bytes.empty() && bytes.size()/4<=30*16000,"Audio must be at most30seconds mono16kHz PCM");
|
||||
std::vector<float> pcm(bytes.size()/4);
|
||||
std::memcpy(pcm.data(),bytes.data(),bytes.size());
|
||||
for(float f:pcm)require(std::isfinite(f) && std::abs(f)<=1,"Audio PCM outside normalized range");
|
||||
raw=mtmd_bitmap_init_from_audio(pcm.size(),pcm.data());
|
||||
}
|
||||
std::unique_ptr<mtmd_bitmap,decltype(&mtmd_bitmap_free)> bitmap(raw,mtmd_bitmap_free);
|
||||
require_inference(bitmap != nullptr,"Cannot allocate media bitmap");
|
||||
return {{"vectors",json::array({embed_bitmap(bitmap.get())})}};
|
||||
}
|
||||
if (op == "windows") return {{"windows",windows(request.at("text").get<std::string>(),request.value("title",std::string()))}};
|
||||
if (op == "query") return {{"vectors",json::array({embed("task: search result | query: "+request.at("text").get<std::string>(),512)})}};
|
||||
if (op == "query") {
|
||||
bool debug = request.value("debug_performance",false);
|
||||
json timings;
|
||||
auto vector = embed("task: search result | query: "+request.at("text").get<std::string>(),512,debug ? &timings : nullptr);
|
||||
json response = {{"vectors",json::array({vector})}};
|
||||
if (debug) response["performance"] = timings;
|
||||
return response;
|
||||
}
|
||||
if (op == "documents") {
|
||||
const auto & texts = request.at("texts");
|
||||
require(texts.is_array() && !texts.empty() && texts.size() <= 8,"Batch size must be between 1 and 8");
|
||||
@@ -187,11 +289,11 @@ int main(int argc, char ** argv) {
|
||||
bool force_cpu = std::getenv("SEARCHI_FORCE_CPU") && std::string(std::getenv("SEARCHI_FORCE_CPU")) == "1";
|
||||
Encoder encoder(path,threads,device == "auto" && !force_cpu);
|
||||
std::cout << json({{"ready",true},{"model","google/embeddinggemma-2"},{"dimensions",dimensions},
|
||||
{"device",encoder.device},{"threads",threads},{"runtime",SEARCHI_LLAMA_TAG}}).dump() << '\n' << std::flush;
|
||||
{"device",encoder.device},{"threads",threads},{"runtime",SEARCHI_LLAMA_TAG},{"media_supported",true}}).dump() << '\n' << std::flush;
|
||||
for (;;) {
|
||||
std::string line; char c;
|
||||
while (std::cin.get(c) && c != '\n') {
|
||||
require(line.size() < max_request_bytes,"Request exceeds 2 MiB"); line.push_back(c);
|
||||
require(line.size() < max_request_bytes,"Request exceeds 16 MiB"); line.push_back(c);
|
||||
}
|
||||
if (!std::cin && line.empty()) break;
|
||||
json response;
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
use crate::TextWindow;
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, ChildStdin, Command, Stdio},
|
||||
sync::mpsc::{self, Receiver},
|
||||
thread,
|
||||
time::Duration,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub const GEMMA_DIMENSIONS: usize = 768;
|
||||
pub const GEMMA_FINGERPRINT: &str = "google/embeddinggemma-2:bfcd298762cc34d0357ece5ebdd31791a3a374d8:q8_0:llama-b11461-no-logits-v1:mean:l2:query512:doc2048:windows-overlap128:prompts-v1";
|
||||
pub const GEMMA_MEDIA_FINGERPRINT: &str = "google/embeddinggemma-2:bfcd298762cc34d0357ece5ebdd31791a3a374d8:q8_0:llama-b11461-no-logits-v1:mtmd:mean:l2:rgb-catmullrom48-exif-largejpeg-scaled-v2:pcm16k30s-v1";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageEmbedding {
|
||||
pub vector: Vec<f32>,
|
||||
pub encoded_width: u32,
|
||||
pub encoded_height: u32,
|
||||
pub token_budget: u32,
|
||||
}
|
||||
const MODEL_FILE: &str = "embeddinggemma-2-Q8_0.gguf";
|
||||
const MODEL_BYTES: u64 = 309855456;
|
||||
const MODEL_SHA256: &str = "2188ac1deca4b77dffefd603c2776a9d76d9d74ec01841392982ebb840b09135";
|
||||
const MAX_REQUEST: usize = 2 * 1024 * 1024;
|
||||
const MAX_REQUEST: usize = 16 * 1024 * 1024;
|
||||
const MAX_RESPONSE: usize = 4 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -35,6 +46,8 @@ pub struct WorkerInfo {
|
||||
pub device: String,
|
||||
pub threads: usize,
|
||||
pub runtime: String,
|
||||
#[serde(default)]
|
||||
pub media_supported: bool,
|
||||
}
|
||||
|
||||
/// Persistent isolated native worker. No HTTP or remote model fallback.
|
||||
@@ -47,6 +60,8 @@ pub struct GemmaEmbedder {
|
||||
force_cpu: bool,
|
||||
model_dir: PathBuf,
|
||||
worker_path: PathBuf,
|
||||
projector_verified: bool,
|
||||
query_profile: Option<BTreeMap<String, u64>>,
|
||||
}
|
||||
|
||||
impl Drop for GemmaEmbedder {
|
||||
@@ -161,6 +176,8 @@ impl GemmaEmbedder {
|
||||
force_cpu: cpu,
|
||||
model_dir,
|
||||
worker_path: worker,
|
||||
projector_verified: false,
|
||||
query_profile: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,10 +186,12 @@ impl GemmaEmbedder {
|
||||
self.healthy,
|
||||
"Gemma worker session failed; reopen it explicitly"
|
||||
);
|
||||
let encode_started = self.query_profile.as_ref().map(|_| Instant::now());
|
||||
let encoded = serde_json::to_vec(&request)?;
|
||||
self.record_query_duration("request_encode_us", encode_started);
|
||||
ensure!(
|
||||
encoded.len() < MAX_REQUEST,
|
||||
"Gemma request exceeds 2 MiB protocol budget"
|
||||
"Gemma request exceeds 16 MiB protocol budget"
|
||||
);
|
||||
let response = match self.exchange(&encoded) {
|
||||
Ok(response) => response,
|
||||
@@ -180,6 +199,7 @@ impl GemmaEmbedder {
|
||||
// GPU runtimes can abort rather than return an encoder error.
|
||||
// Restart once as CPU and retry this same bounded request.
|
||||
let _ = self.child.wait();
|
||||
let restart_started = self.query_profile.as_ref().map(|_| Instant::now());
|
||||
let replacement = Self::load_with_device(
|
||||
&self.model_dir,
|
||||
&self.worker_path,
|
||||
@@ -189,7 +209,13 @@ impl GemmaEmbedder {
|
||||
.with_context(|| {
|
||||
format!("CPU worker restart failed after GPU transport error: {error}")
|
||||
})?;
|
||||
let diagnostics = self.query_profile.take();
|
||||
*self = replacement;
|
||||
self.query_profile = diagnostics;
|
||||
self.record_query_duration("cpu_restart_us", restart_started);
|
||||
if let Some(profile) = &mut self.query_profile {
|
||||
profile.insert("cpu_restart_count".into(), 1);
|
||||
}
|
||||
self.exchange(&encoded)?
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
@@ -201,6 +227,11 @@ impl GemmaEmbedder {
|
||||
);
|
||||
self.info.device = device.into();
|
||||
}
|
||||
if response["fallback"].as_str() == Some("gpu_inference_failure") {
|
||||
if let Some(profile) = &mut self.query_profile {
|
||||
profile.insert("worker_cpu_retry_count".into(), 1);
|
||||
}
|
||||
}
|
||||
if response["fatal"] == true {
|
||||
self.healthy = false;
|
||||
self.info.ready = false;
|
||||
@@ -215,18 +246,21 @@ impl GemmaEmbedder {
|
||||
}
|
||||
|
||||
fn exchange(&mut self, encoded: &[u8]) -> Result<Value> {
|
||||
let write_started = self.query_profile.as_ref().map(|_| Instant::now());
|
||||
let sent = (|| {
|
||||
self.stdin.write_all(encoded)?;
|
||||
self.stdin.write_all(b"\n")?;
|
||||
self.stdin.flush()
|
||||
})()
|
||||
.context("Cannot write local Gemma worker request");
|
||||
self.record_query_duration("request_write_us", write_started);
|
||||
if let Err(error) = sent {
|
||||
self.healthy = false;
|
||||
self.info.ready = false;
|
||||
let _ = self.child.kill();
|
||||
return Err(error);
|
||||
}
|
||||
let wait_started = self.query_profile.as_ref().map(|_| Instant::now());
|
||||
let response = match self.responses.recv_timeout(Duration::from_secs(180)) {
|
||||
Ok(Ok(value)) => value,
|
||||
other => {
|
||||
@@ -236,6 +270,7 @@ impl GemmaEmbedder {
|
||||
anyhow::bail!("Gemma worker request failed: {other:?}");
|
||||
}
|
||||
};
|
||||
self.record_query_duration("worker_wait_and_response_decode_us", wait_started);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -281,15 +316,265 @@ impl GemmaEmbedder {
|
||||
}
|
||||
|
||||
pub fn embed_query(&mut self, query: &str) -> Result<Vec<f32>> {
|
||||
self.query_profile = None;
|
||||
self.embed_query_inner(query, false)
|
||||
}
|
||||
|
||||
/// Opt-in diagnostics contain only durations and counts, never query text.
|
||||
pub fn embed_query_debug(&mut self, query: &str) -> Result<Vec<f32>> {
|
||||
self.query_profile = Some(BTreeMap::new());
|
||||
self.embed_query_inner(query, true)
|
||||
}
|
||||
|
||||
pub fn query_diagnostics(&self) -> BTreeMap<String, u64> {
|
||||
self.query_profile.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn record_query_duration(&mut self, name: &str, started: Option<Instant>) {
|
||||
if let (Some(profile), Some(started)) = (&mut self.query_profile, started) {
|
||||
let micros = started.elapsed().as_micros().min(u64::MAX as u128) as u64;
|
||||
*profile.entry(name.into()).or_default() += micros;
|
||||
}
|
||||
}
|
||||
|
||||
fn embed_query_inner(&mut self, query: &str, debug: bool) -> Result<Vec<f32>> {
|
||||
ensure!(
|
||||
!query.trim().is_empty() && query.len() <= 4096,
|
||||
"Query must be nonempty and at most 4096 UTF-8 bytes"
|
||||
);
|
||||
let response = self.request(json!({"op":"query","text":query}))?;
|
||||
let response =
|
||||
self.request(json!({"op":"query","text":query,"debug_performance":debug}))?;
|
||||
if debug {
|
||||
if let Some(profile) = &mut self.query_profile {
|
||||
for name in [
|
||||
"tokenization_us",
|
||||
"batch_prepare_us",
|
||||
"inference_us",
|
||||
"pool_normalize_us",
|
||||
"token_count",
|
||||
] {
|
||||
if let Some(value) = response["performance"][name].as_u64() {
|
||||
profile.insert(format!("worker_{name}"), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let validation_started = self.query_profile.as_ref().map(|_| Instant::now());
|
||||
let result = extract_vectors(response, 1)?.remove(0);
|
||||
self.record_query_duration("response_validate_us", validation_started);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn projector(&mut self) -> Result<PathBuf> {
|
||||
ensure!(
|
||||
self.info.media_supported,
|
||||
"Installed embedding worker does not support media; repair/rebuild the native runtime"
|
||||
);
|
||||
let path = self.model_dir.join("mmproj-embeddinggemma-2-Q8_0.gguf");
|
||||
if !self.projector_verified {
|
||||
verify_artifact(
|
||||
&path,
|
||||
554821024,
|
||||
"c4a8a52691ecef40618438928bdf9e68379b854e24166f292592353db0aab64f",
|
||||
)?;
|
||||
self.projector_verified = true;
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Genuine Google pixel embeddings, serial bounded image operations.
|
||||
pub fn embed_images(&mut self, paths: &[&Path]) -> Result<Vec<Vec<f32>>> {
|
||||
self.embed_images_with_budget(paths, 280)
|
||||
}
|
||||
|
||||
/// 70 tokens for video frames, 140 for photos, 280 or 1120 for screenshots.
|
||||
pub fn embed_images_with_budget(
|
||||
&mut self,
|
||||
paths: &[&Path],
|
||||
budget: u32,
|
||||
) -> Result<Vec<Vec<f32>>> {
|
||||
Ok(self
|
||||
.embed_images_with_metadata(paths, budget)?
|
||||
.into_iter()
|
||||
.map(|image| image.vector)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn embed_images_with_metadata(
|
||||
&mut self,
|
||||
paths: &[&Path],
|
||||
budget: u32,
|
||||
) -> Result<Vec<ImageEmbedding>> {
|
||||
ensure!(
|
||||
!paths.is_empty() && paths.len() <= 8,
|
||||
"Image batch must contain 1–8 paths"
|
||||
);
|
||||
let projector = self.projector()?;
|
||||
let mut vectors = Vec::with_capacity(paths.len());
|
||||
for path in paths {
|
||||
let pixels = decode_image(path, budget)?;
|
||||
let response=self.request(json!({"op":"image","projector":projector,"width":pixels.width(),"height":pixels.height(),"data":base64::engine::general_purpose::STANDARD.encode(pixels.as_raw())}))?;
|
||||
vectors.push(ImageEmbedding {
|
||||
vector: extract_vectors(response, 1)?.remove(0),
|
||||
encoded_width: pixels.width(),
|
||||
encoded_height: pixels.height(),
|
||||
token_budget: budget,
|
||||
});
|
||||
}
|
||||
Ok(vectors)
|
||||
}
|
||||
|
||||
/// One mono 16 kHz normalized PCM window, at most 30 seconds. Callers
|
||||
/// segment entire recordings and preserve real timestamps; no truncation.
|
||||
pub fn embed_audio_pcm(&mut self, samples: &[f32]) -> Result<Vec<f32>> {
|
||||
ensure!(
|
||||
!samples.is_empty() && samples.len() <= 480000,
|
||||
"Audio window must contain 1–480000 mono 16 kHz samples"
|
||||
);
|
||||
ensure!(
|
||||
samples.iter().all(|v| v.is_finite() && v.abs() <= 1.0),
|
||||
"Invalid normalized audio PCM"
|
||||
);
|
||||
ensure!(
|
||||
cfg!(target_endian = "little"),
|
||||
"Native PCM worker requires little-endian host"
|
||||
);
|
||||
let projector = self.projector()?;
|
||||
let bytes: Vec<u8> = samples.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
let response=self.request(json!({"op":"audio","projector":projector,"data":base64::engine::general_purpose::STANDARD.encode(bytes)}))?;
|
||||
Ok(extract_vectors(response, 1)?.remove(0))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_image(path: &Path, budget: u32) -> Result<image::RgbImage> {
|
||||
ensure!(
|
||||
(70..=1120).contains(&budget),
|
||||
"Image token budget must be 70–1120"
|
||||
);
|
||||
ensure!(
|
||||
std::fs::metadata(path)?.len() <= 64 * 1024 * 1024,
|
||||
"Image file exceeds 64 MiB"
|
||||
);
|
||||
let mut reader = image::ImageReader::open(path)?.with_guessed_format()?;
|
||||
let is_jpeg = reader.format() == Some(image::ImageFormat::Jpeg);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(256 * 1024 * 1024);
|
||||
limits.max_image_width = Some(16384);
|
||||
limits.max_image_height = Some(16384);
|
||||
reader.limits(limits);
|
||||
use image::ImageDecoder;
|
||||
let mut decoder = reader
|
||||
.into_decoder()
|
||||
.context("Cannot initialize bounded image decoder")?;
|
||||
let (source_width, source_height) = decoder.dimensions();
|
||||
ensure!(
|
||||
u64::from(source_width) * u64::from(source_height) <= 64 * 1024 * 1024
|
||||
&& decoder.total_bytes() <= 256 * 1024 * 1024,
|
||||
"Decoded image exceeds memory/pixel budget"
|
||||
);
|
||||
let orientation = decoder
|
||||
.orientation()
|
||||
.context("Cannot read image EXIF orientation")?;
|
||||
if is_jpeg && u64::from(source_width) * u64::from(source_height) > 16 * 1024 * 1024 {
|
||||
drop(decoder);
|
||||
return decode_large_jpeg(path, budget, source_width, source_height, orientation);
|
||||
}
|
||||
ensure!(u64::from(source_width)*u64::from(source_height)<=16*1024*1024,"Non-JPEG image exceeds16megapixels; bounded scaled decoding is currently available for JPEG");
|
||||
let mut decoded = image::DynamicImage::from_decoder(decoder)
|
||||
.context("Cannot decode supported image pixels")?;
|
||||
decoded.apply_orientation(orientation);
|
||||
ensure!(
|
||||
u64::from(decoded.width()) * u64::from(decoded.height()) <= 16 * 1024 * 1024,
|
||||
"Image exceeds 16 megapixels"
|
||||
);
|
||||
let (w, h) = image_target(decoded.width(), decoded.height(), budget)?;
|
||||
Ok(image::imageops::resize(
|
||||
&decoded.to_rgb8(),
|
||||
w,
|
||||
h,
|
||||
image::imageops::FilterType::CatmullRom,
|
||||
))
|
||||
}
|
||||
|
||||
fn decode_large_jpeg(
|
||||
path: &Path,
|
||||
budget: u32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
orientation: image::metadata::Orientation,
|
||||
) -> Result<image::RgbImage> {
|
||||
let (target_width, target_height) = image_target(width, height, budget)?;
|
||||
let mut decoder = jpeg_decoder::Decoder::new(BufReader::new(File::open(path)?));
|
||||
decoder.set_max_decoding_buffer_size(256 * 1024 * 1024);
|
||||
decoder
|
||||
.read_info()
|
||||
.context("Cannot read scaled JPEG header")?;
|
||||
let source = decoder.info().context("Missing scaled JPEG header")?;
|
||||
ensure!(
|
||||
u32::from(source.width) == width && u32::from(source.height) == height,
|
||||
"JPEG changed during decoding"
|
||||
);
|
||||
ensure!(
|
||||
matches!(
|
||||
source.pixel_format,
|
||||
jpeg_decoder::PixelFormat::RGB24 | jpeg_decoder::PixelFormat::L8
|
||||
),
|
||||
"Large JPEG requires supported RGB24 or grayscale scaled decoding"
|
||||
);
|
||||
let (w, h) = decoder.scale(u16::try_from(target_width)?, u16::try_from(target_height)?)?;
|
||||
ensure!(
|
||||
u64::from(w) * u64::from(h) * 4 <= 256 * 1024 * 1024,
|
||||
"Scaled JPEG output exceeds memory budget"
|
||||
);
|
||||
let pixels = decoder
|
||||
.decode()
|
||||
.context("Cannot decode large JPEG with scaled IDCT")?;
|
||||
let mut decoded = match source.pixel_format {
|
||||
jpeg_decoder::PixelFormat::RGB24 => image::DynamicImage::ImageRgb8(
|
||||
image::RgbImage::from_raw(u32::from(w), u32::from(h), pixels)
|
||||
.context("Invalid scaled JPEG RGB length")?,
|
||||
),
|
||||
jpeg_decoder::PixelFormat::L8 => image::DynamicImage::ImageLuma8(
|
||||
image::GrayImage::from_raw(u32::from(w), u32::from(h), pixels)
|
||||
.context("Invalid scaled JPEG grayscale length")?,
|
||||
),
|
||||
other => anyhow::bail!(
|
||||
"Large JPEG pixel format {other:?} is not supported by bounded scaled decoder"
|
||||
),
|
||||
};
|
||||
decoded.apply_orientation(orientation);
|
||||
let (w, h) = image_target(decoded.width(), decoded.height(), budget)?;
|
||||
Ok(image::imageops::resize(
|
||||
&decoded.into_rgb8(),
|
||||
w,
|
||||
h,
|
||||
image::imageops::FilterType::CatmullRom,
|
||||
))
|
||||
}
|
||||
|
||||
fn image_target(width: u32, height: u32, budget: u32) -> Result<(u32, u32)> {
|
||||
ensure!(
|
||||
width > 0 && height > 0 && (70..=1120).contains(&budget),
|
||||
"Invalid image dimensions/budget"
|
||||
);
|
||||
let scale = (f64::from(budget) * 2304.0 / (f64::from(width) * f64::from(height))).sqrt();
|
||||
let mut w = ((f64::from(width) * scale / 48.0).floor() as u32) * 48;
|
||||
let mut h = ((f64::from(height) * scale / 48.0).floor() as u32) * 48;
|
||||
if w == 0 {
|
||||
w = 48;
|
||||
h = (height / width * 48).min(budget * 48);
|
||||
}
|
||||
if h == 0 {
|
||||
h = 48;
|
||||
w = (width / height * 48).min(budget * 48);
|
||||
}
|
||||
ensure!(
|
||||
w >= 48 && h >= 48 && u64::from(w) * u64::from(h) <= u64::from(budget) * 2304,
|
||||
"Image target outside token budget"
|
||||
);
|
||||
Ok((w, h))
|
||||
}
|
||||
|
||||
fn can_retry_cpu(actual_device: &str, force_cpu: bool) -> bool {
|
||||
actual_device == "gpu" && !force_cpu
|
||||
}
|
||||
@@ -331,6 +616,10 @@ fn extract_vectors(response: Value, count: usize) -> Result<Vec<Vec<f32>>> {
|
||||
}
|
||||
|
||||
fn verify_model(path: &Path) -> Result<()> {
|
||||
verify_artifact(path, MODEL_BYTES, MODEL_SHA256)
|
||||
}
|
||||
|
||||
fn verify_artifact(path: &Path, bytes: u64, sha: &str) -> Result<()> {
|
||||
let file = File::open(path).with_context(|| {
|
||||
format!(
|
||||
"Missing Google EmbeddingGemma 2 model {}; run explicit model setup first",
|
||||
@@ -338,7 +627,7 @@ fn verify_model(path: &Path) -> Result<()> {
|
||||
)
|
||||
})?;
|
||||
ensure!(
|
||||
file.metadata()?.len() == MODEL_BYTES,
|
||||
file.metadata()?.len() == bytes,
|
||||
"EmbeddingGemma 2 model size mismatch"
|
||||
);
|
||||
let mut reader = BufReader::new(file);
|
||||
@@ -352,7 +641,7 @@ fn verify_model(path: &Path) -> Result<()> {
|
||||
hash.update(&buffer[..count]);
|
||||
}
|
||||
ensure!(
|
||||
format!("{:x}", hash.finalize()) == MODEL_SHA256,
|
||||
format!("{:x}", hash.finalize()) == sha,
|
||||
"EmbeddingGemma 2 model SHA256 mismatch"
|
||||
);
|
||||
Ok(())
|
||||
@@ -362,6 +651,101 @@ fn verify_model(path: &Path) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn old_text_worker_handshake_does_not_claim_media_support() {
|
||||
let mut handshake = json!({"ready":true,"model":"google/embeddinggemma-2","dimensions":768,"device":"cpu","threads":1,"runtime":"b11461-no-logits-v1"});
|
||||
let old: WorkerInfo = serde_json::from_value(handshake.clone()).unwrap();
|
||||
assert!(!old.media_supported);
|
||||
handshake["media_supported"] = json!(true);
|
||||
assert!(
|
||||
serde_json::from_value::<WorkerInfo>(handshake)
|
||||
.unwrap()
|
||||
.media_supported
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn media_image_grid_is_bounded_for_extreme_aspects_and_full_budget() {
|
||||
for budget in [70, 140, 280, 1120] {
|
||||
for (width, height) in [(1, 16384), (16384, 1), (1920, 1080), (500, 500)] {
|
||||
let (w, h) = image_target(width, height, budget).unwrap();
|
||||
assert_eq!(w % 48, 0);
|
||||
assert_eq!(h % 48, 0);
|
||||
assert!(u64::from(w) * u64::from(h) <= u64::from(budget) * 2304);
|
||||
}
|
||||
}
|
||||
assert!(image_target(0, 100, 280).is_err());
|
||||
assert!(image_target(100, 100, 1121).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn artifact_verification_rejects_projector_size_and_checksum_mismatch() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/LICENSE-DigUp.txt");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let sha = format!("{:x}", Sha256::digest(&bytes));
|
||||
assert!(verify_artifact(&path, bytes.len() as u64, &sha).is_ok());
|
||||
assert!(verify_artifact(&path, bytes.len() as u64 + 1, &sha).is_err());
|
||||
assert!(verify_artifact(&path, bytes.len() as u64, &"0".repeat(64)).is_err());
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "Explicit48MP JPEG resource test; no model needed, run release CPU"]
|
||||
fn large_phone_jpeg_scaled_decode_preserves_exif_and_bounds() {
|
||||
use image::ImageEncoder;
|
||||
let image = image::RgbImage::from_fn(8000, 6000, |x, _| {
|
||||
if x < 4000 {
|
||||
image::Rgb([240, 10, 10])
|
||||
} else {
|
||||
image::Rgb([10, 10, 240])
|
||||
}
|
||||
});
|
||||
let mut encoded = Vec::new();
|
||||
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, 80);
|
||||
// Little-endian TIFF orientation tag6: rotate90clockwise.
|
||||
encoder
|
||||
.set_exif_metadata(vec![
|
||||
b'I', b'I', 42, 0, 8, 0, 0, 0, 1, 0, 0x12, 1, 3, 0, 1, 0, 0, 0, 6, 0, 0, 0, 0, 0,
|
||||
0, 0,
|
||||
])
|
||||
.unwrap();
|
||||
encoder.encode_image(&image).unwrap();
|
||||
drop(image);
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("searchi-phone48mp-{}.jpg", std::process::id()));
|
||||
std::fs::write(&path, &encoded).unwrap();
|
||||
drop(encoded);
|
||||
let started = std::time::Instant::now();
|
||||
let result = decode_image(&path, 280);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let pixels = result.unwrap();
|
||||
println!(
|
||||
"48MPactualJPEG scaleddecode+EXIF+resize {:?}; output{}x{} RGB{}bytes",
|
||||
started.elapsed(),
|
||||
pixels.width(),
|
||||
pixels.height(),
|
||||
pixels.as_raw().len()
|
||||
);
|
||||
assert!(pixels.height() > pixels.width());
|
||||
assert!(pixels.get_pixel(pixels.width() / 2, pixels.height() / 4)[0] > 200);
|
||||
assert!(pixels.get_pixel(pixels.width() / 2, pixels.height() * 3 / 4)[2] > 200);
|
||||
assert!(u64::from(pixels.width()) * u64::from(pixels.height()) <= 280 * 2304);
|
||||
}
|
||||
#[test]
|
||||
fn oversized_jpeg_header_rejected_before_pixel_decode() {
|
||||
let mut encoded = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new(&mut encoded)
|
||||
.encode_image(&image::RgbImage::new(16, 16))
|
||||
.unwrap();
|
||||
let sof = encoded.windows(2).position(|p| p == [0xff, 0xc0]).unwrap();
|
||||
encoded[sof + 5..sof + 7].copy_from_slice(&9000u16.to_be_bytes());
|
||||
encoded[sof + 7..sof + 9].copy_from_slice(&9000u16.to_be_bytes());
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("searchi-too-large-{}.jpg", std::process::id()));
|
||||
std::fs::write(&path, encoded).unwrap();
|
||||
let result = decode_image(&path, 280);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("memory/pixel budget"));
|
||||
}
|
||||
#[test]
|
||||
fn missing_google_model_fails_without_starting_worker() {
|
||||
assert!(GemmaEmbedder::load("missing-google-model", "missing-worker", 1).is_err());
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Local Google EmbeddingGemma 2 inference through an isolated native worker.
|
||||
//! The rejected MiniLM experiment is available only under `minilm-proof`.
|
||||
mod gemma;
|
||||
pub use gemma::{Device, GemmaEmbedder, WorkerInfo, GEMMA_DIMENSIONS, GEMMA_FINGERPRINT};
|
||||
pub use gemma::{
|
||||
Device, GemmaEmbedder, ImageEmbedding, WorkerInfo, GEMMA_DIMENSIONS, GEMMA_FINGERPRINT,
|
||||
GEMMA_MEDIA_FINGERPRINT,
|
||||
};
|
||||
|
||||
/// A verified source byte range covered by a model token window.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Pinned Google media reference fixtures
|
||||
|
||||
The `digup-photo.jpg`, `digup-screen.png`, `digup-speech.wav`, and
|
||||
`digup-media-reference.json` files are copied unchanged from
|
||||
`ARahim3/DigUp`, commit `6faa29e4c315a3a591de1b41bd78751bdf39588d`,
|
||||
`Tests/LlamaRuntimeTests/Reference/`. They and the existing text reference
|
||||
fixture are retained under the adjacent `LICENSE-DigUp.txt` MIT notice.
|
||||
|
||||
The source test uses image budgets 280 (screen), 140 (photo), 70 (frame),
|
||||
and a mono 16 kHz speech recording. The native CPU test compares actual
|
||||
768-dimensional Google vectors with the pinned llama.cpp b11461 reference
|
||||
at cosine >=0.999. This is runtime parity evidence, not a production media
|
||||
relevance corpus. Independent images, audio, and video require separate
|
||||
quality evaluation.
|
||||
@@ -0,0 +1 @@
|
||||
{"made_with":"llama.cpp b11461","vector_version":1,"vectors":{"document":[0.0023304052,-0.021797102,-0.025986522,-0.025894934,0.0046086097,-0.029744277,-0.04186634,0.03234369,0.015701862,-0.13444579,-0.049679857,-0.067124754,-0.008888296,0.049783457,0.04075608,-0.0027419934,0.0494676,0.0092225745,-0.003042357,0.021539804,-0.066572726,-0.04184304,-0.008146685,-0.015800655,0.0028940293,0.026850378,0.023585942,0.012522315,-0.018817414,0.19809185,-0.0541878,-0.033899978,0.06363426,-0.012184333,0.026218394,-0.030783277,-0.016514625,0.0010143976,0.016249185,0.00673781,0.07569905,-0.032087695,0.01618515,0.008316216,0.009405525,0.035788074,0.011760172,-0.0018986556,0.03811712,0.04686964,-0.0019338331,-0.06858261,-0.020950682,0.0061196685,-0.012488069,0.055386145,0.00828158,-0.028973866,0.029866058,0.019298207,0.0064102146,0.03210796,0.031232368,-0.024290783,-0.009193791,-0.00788917,0.07007842,-0.036232397,0.048318822,0.03329114,-0.021271994,-0.01810213,0.056723867,0.026635557,-0.0066668643,-0.03339305,-0.09275154,-0.005457068,-0.037602138,0.06852603,-0.086277,0.0108406255,-0.022301262,0.054799795,0.048695795,-0.0094250385,0.04326083,-0.05584021,0.009427591,0.018164966,0.0077456376,0.012890579,0.011140378,0.009403773,-0.013322473,0.014014791,0.0045265807,0.0026267597,-0.02777846,0.03337617,-0.014311906,-0.0027436074,0.0016559366,0.04267254,-0.0074306116,0.010555651,0.00801582,0.019190526,0.026427642,-0.02305468,0.017346293,-0.254231,-0.037654083,0.004254816,-0.029559055,0.30139065,-0.0017701226,0.012945749,0.045082387,-0.005745192,0.023981802,0.11611545,0.02849787,0.0050043385,0.019032571,0.01543062,0.013486391,-0.0073420904,-0.0120351305,0.027507395,0.017718071,0.0048503615,0.0070377868,-0.00946547,0.015142535,0.013139711,0.019906517,-0.038481727,0.060138095,0.0027306078,-0.010124241,0.020947324,-0.005082965,0.009129657,-0.06960446,0.04814667,0.012469531,-0.017168682,-0.007835558,-0.018339474,0.04499317,-0.048769984,-0.021295255,0.0008460758,0.042394932,0.02629723,0.015862137,-0.027827857,0.02877754,0.044078708,0.02864664,0.015502866,-0.011931367,0.006051371,-0.0676744,-0.032051444,-0.0051915697,0.022663914,0.010766299,-0.045957707,-0.071434505,-0.0071970616,-0.00080499344,-0.019176258,0.039329063,0.07615527,-0.02934876,0.03178097,0.030727895,-0.0014563488,0.033275653,-0.016663045,0.016650142,0.03149862,-0.042355653,-0.01447139,-0.031222051,-0.0371221,-0.071084455,0.02895739,0.012906462,-0.034588896,0.005292062,0.04567936,0.057948757,0.016161263,0.026154758,0.037441123,0.020264363,0.0021852585,0.010503882,0.02564951,-0.0034963347,-0.002975977,-0.05896342,0.090469755,-0.02141068,-0.018266847,0.03979448,0.006404804,0.048994806,-0.0013431136,0.003767527,0.009192836,0.03172842,0.0018301627,-0.032883424,0.070755646,-0.008929331,0.007731602,0.039488316,-0.0026821592,0.047416102,0.017939921,-0.046493225,-0.003571527,0.017453436,0.027679116,-0.045083456,-0.0018627502,-0.0058596916,-0.016741525,-0.041559335,-0.07398639,0.035852198,-0.01250213,-0.027620463,-0.022970477,-0.007722595,0.006369175,-0.027909646,0.05087572,0.04319605,0.0249135,-0.045503743,-0.050388955,-0.07399011,-0.031614304,-0.009977491,-0.011919013,0.017587284,0.046735547,0.051120915,-0.01913088,-0.0040965537,-0.030126736,0.022246089,-0.024694057,-0.0011967963,-0.0076620826,-0.0001045683,0.025630128,0.0023922755,-0.016251149,0.029261485,0.026775412,-0.0011348778,-0.023079926,0.0046075485,0.013615946,-0.022271913,0.0063756416,-0.011097959,-0.057059642,0.017879428,0.039326653,-0.010385263,-0.024296982,-0.0056849266,-0.021441974,0.023893535,-0.004312103,-0.0029023795,0.011346968,-0.05022057,-0.00962676,-0.030292084,0.0022458949,-0.0012382208,0.014016518,0.0130033605,-0.0075721424,0.022480633,0.0378293,-0.0060748337,0.020296352,-0.038013346,0.02737668,0.051704004,0.029559156,-0.008555367,-0.040540576,0.0028047708,-0.01888827,-0.017475538,0.022049233,-0.018655647,0.00035712068,0.0007242559,-0.031585597,0.002809768,-0.010847336,0.019464653,-0.038227003,0.004094894,-0.0045381063,-0.032818098,-0.025449943,-0.0048597767,-0.052715924,0.024247607,-0.025617195,-0.028776655,-0.01491966,0.027141426,0.022653097,0.01347185,0.0010195217,0.0077305734,0.006165836,-0.009121481,-0.011312435,0.0010023145,0.033321623,0.0020216978,-0.04601626,-0.020557044,-0.023531808,0.013099387,0.049464565,-0.0083905,-0.12218178,-0.0090951715,-0.037435923,-0.043180246,0.008593723,0.003133877,0.03239143,-0.02740052,0.028175933,0.00713765,0.010822759,-0.011402951,-0.042760924,-0.03611006,0.011457286,0.060568936,0.0027335465,-0.024305783,-0.004420147,0.0068441983,0.002837347,-0.02218109,-0.022486687,-0.01941801,0.01667096,-0.03958557,-0.0077348636,-0.0012779543,-0.009598079,-0.008962751,0.033793483,0.018625712,-0.006633453,0.026242023,0.021685809,0.013799533,-0.0060389466,0.0020937775,0.02297482,-0.02592566,0.0011351658,0.00793861,-0.01809629,0.050504446,-0.026299631,-0.028017156,0.006029957,-0.02648004,0.03030571,0.016809817,0.29351303,-0.028621448,0.01694766,0.021401705,0.012320341,0.0015229117,-0.Line truncated
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
Binary file not shown.
@@ -1,6 +1,49 @@
|
||||
use anyhow::Result;
|
||||
use searchi_semantic::{Device, GemmaEmbedder, GEMMA_DIMENSIONS};
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires explicitly provisioned Google model and CPU worker"]
|
||||
fn google_cpu_debug_query_reports_actual_worker_stages_without_changing_vectors() -> Result<()> {
|
||||
let model = std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Set SEARCHI_GEMMA_MODEL_DIR");
|
||||
let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Set SEARCHI_GEMMA_WORKER");
|
||||
let mut embedder = GemmaEmbedder::load_with_device(model, worker, 1, Device::Cpu)?;
|
||||
let query = "supplier contract cancellation policy";
|
||||
let debug = embedder.embed_query_debug(query)?;
|
||||
let diagnostics = embedder.query_diagnostics();
|
||||
for name in [
|
||||
"request_encode_us",
|
||||
"request_write_us",
|
||||
"worker_wait_and_response_decode_us",
|
||||
"response_validate_us",
|
||||
"worker_tokenization_us",
|
||||
"worker_batch_prepare_us",
|
||||
"worker_inference_us",
|
||||
"worker_pool_normalize_us",
|
||||
"worker_token_count",
|
||||
] {
|
||||
assert!(
|
||||
diagnostics.contains_key(name),
|
||||
"Missing actual stage {name}: {diagnostics:?}"
|
||||
);
|
||||
}
|
||||
assert!(diagnostics["worker_inference_us"] > 0);
|
||||
assert!(diagnostics["worker_token_count"] > 0);
|
||||
assert!(!serde_json::to_string(&diagnostics)?.contains(query));
|
||||
let normal = embedder.embed_query(query)?;
|
||||
assert!(embedder.query_diagnostics().is_empty());
|
||||
let cosine: f64 = debug
|
||||
.iter()
|
||||
.zip(&normal)
|
||||
.map(|(a, b)| f64::from(*a) * f64::from(*b))
|
||||
.sum();
|
||||
assert!(
|
||||
cosine >= 0.99999,
|
||||
"Debug tracing changed embeddings: {cosine}"
|
||||
);
|
||||
println!("CPU query diagnostics: {diagnostics:?}; debug/normal cosine {cosine:.8}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires explicitly provisioned Google model and CPU worker"]
|
||||
fn google_cpu_real_embeddings_and_verified_tail_windows() -> Result<()> {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
use anyhow::Result;
|
||||
use searchi_semantic::{Device, GemmaEmbedder};
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires explicitly provisioned Google media model and CPU worker"]
|
||||
fn google_cpu_pixels_match_digup_reference() -> Result<()> {
|
||||
let model = std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Set model path");
|
||||
let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Set worker path");
|
||||
let mut embedder = GemmaEmbedder::load_with_device(model, worker, 1, Device::Cpu)?;
|
||||
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
|
||||
let reference: serde_json::Value =
|
||||
serde_json::from_str(include_str!("fixtures/digup-media-reference.json"))?;
|
||||
for (name, file, budget) in [
|
||||
("screen-280", "digup-screen.png", 280),
|
||||
("photo-140", "digup-photo.jpg", 140),
|
||||
("frame-70", "digup-photo.jpg", 70),
|
||||
] {
|
||||
let path = fixtures.join(file);
|
||||
let vector = embedder
|
||||
.embed_images_with_budget(&[path.as_path()], budget)?
|
||||
.remove(0);
|
||||
let cosine: f64 = vector
|
||||
.iter()
|
||||
.zip(reference["vectors"][name].as_array().unwrap())
|
||||
.map(|(a, b)| f64::from(*a) * b.as_f64().unwrap())
|
||||
.sum();
|
||||
println!("Google image {name} reference cosine={cosine:.8}");
|
||||
assert!(cosine >= 0.999, "Image parity below0.999: {name} {cosine}");
|
||||
}
|
||||
assert_eq!(embedder.info.device, "cpu");
|
||||
let wav = include_bytes!("fixtures/digup-speech.wav");
|
||||
let mut offset = 12;
|
||||
let mut samples = Vec::new();
|
||||
while offset + 8 <= wav.len() {
|
||||
let length = u32::from_le_bytes(wav[offset + 4..offset + 8].try_into()?) as usize;
|
||||
if &wav[offset..offset + 4] == b"data" {
|
||||
for pair in wav[offset + 8..offset + 8 + length].as_chunks::<2>().0 {
|
||||
samples.push(f32::from(i16::from_le_bytes(*pair)) / 32768.0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
offset += 8 + length + (length % 2);
|
||||
}
|
||||
let vector = embedder.embed_audio_pcm(&samples)?;
|
||||
let cosine: f64 = vector
|
||||
.iter()
|
||||
.zip(reference["vectors"]["speech"].as_array().unwrap())
|
||||
.map(|(a, b)| f64::from(*a) * b.as_f64().unwrap())
|
||||
.sum();
|
||||
println!("Google audio reference cosine={cosine:.8}");
|
||||
assert!(
|
||||
cosine >= 0.999,
|
||||
"Audio reference parity below0.999: {cosine}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user