using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using Microsoft.ML.Tokenizers; using System; using System.Collections.Generic; using System.IO; using System.Linq; namespace RMuseum.Utils.SemanticSearch { /// /// Embeds a single user-typed search query using the SAME ONNX model + tokenizer as /// scripts/generate_embeddings.py (the Python side that indexed the poem corpus) — this is /// non-negotiable: a query embedded in a different space than the documents it's being /// compared against produces meaningless similarity scores, silently (no error, just bad /// results), not something that shows up as a crash. /// /// UNTESTED — flagging this more strongly than usual, because this file stacks two risks /// that weren't present anywhere else in this project: /// 1. This environment has no .NET SDK and no network access to onnxruntime/the model, so /// none of this has actually been compiled or run. /// 2. Even once it compiles, BpeTokenizer.Create(vocab, merges) builds a plain BPE /// tokenizer from vocab.json + merges.txt — it may not capture every detail of Qwen's /// full tokenizer.json spec (custom pre-tokenizer regex, added/special tokens). A /// mismatch here wouldn't error either — it would silently tokenize text differently /// than the Python `tokenizers` library did at index time, degrading search quality /// without any visible failure. /// See VERIFICATION.md for a paired Python/C# test to run BEFORE trusting search results — /// do this before anything else in this phase. /// /// Query-time embedding is always exactly one query per call (a person typing into a search /// box), never a batch — unlike the Python indexing script, which batched many poems /// together and needed padding logic. That simplifies this class: no padding, no /// batch-dimension complexity, last-token pooling reduces to simply the final sequence /// position (no attention-mask lookup needed, since there's no padding to skip past). /// public class QueryEmbedder : IDisposable { // Confirmed via `python3 scripts/generate_embeddings.py --inspect-only` against the real // Qwen/Qwen3-Embedding-0.6B ONNX export (onnx-community/Qwen3-Embedding-0.6B-ONNX), // 2026-09-05. If the model is ever swapped for a different export/architecture, these // MUST be re-confirmed the same way (re-run --inspect-only, read off the real shapes) — // hardcoding them here (rather than trying to introspect symbolic dimension names from // .NET's ONNX Runtime API, whose support for that is less certain than Python's) is a // deliberate tradeoff: reliable given what we've already confirmed, but silently wrong // if the model changes without updating these. private const int NumLayers = 28; private const int NumKvHeads = 8; private const int HeadDim = 128; /// /// Per Qwen3-Embedding's documented convention, QUERIES (unlike documents) benefit from /// an instruction prefix. generate_embeddings.py deliberately does NOT add one to /// documents (poem summaries) — this asymmetry is the model's own documented design, not /// an inconsistency. If this template ever changes, or if it's ever added to/removed /// from the document side, both sides must be updated together, or queries and documents /// drift into subtly different embedding spaces. This exact wording hasn't been /// benchmarked for Persian poetry specifically — reasonable to A/B test once the basic /// pipeline is confirmed working (see VERIFICATION.md), not something to treat as final. /// private const string InstructionTemplate = "Instruct: Given a search query, retrieve Persian poems whose meaning matches it\nQuery: {0}"; private readonly InferenceSession _session; private readonly Tokenizer _tokenizer; private readonly int _dimension; public QueryEmbedder(string modelPath, string vocabPath, string mergesPath, int dimension = 1024) { _session = new InferenceSession(modelPath); using (var vocabStream = File.OpenRead(vocabPath)) using (var mergesStream = File.OpenRead(mergesPath)) { _tokenizer = BpeTokenizer.Create(vocabStream, mergesStream); } _dimension = dimension; } /// /// Returns an L2-normalized embedding for queryText, comparable via dot product against /// EmbeddingIndex's vectors (which are normalized the same way). /// public float[] EmbedQuery(string queryText) { string instructedText = string.Format(InstructionTemplate, queryText); IReadOnlyList tokenIds = _tokenizer.EncodeToIds(instructedText); int seqLen = tokenIds.Count; if (seqLen == 0) throw new ArgumentException("query tokenized to zero tokens — empty or whitespace-only query?", nameof(queryText)); var inputIds = new DenseTensor(new[] { 1, seqLen }); var attentionMask = new DenseTensor(new[] { 1, seqLen }); var positionIds = new DenseTensor(new[] { 1, seqLen }); for (int i = 0; i < seqLen; i++) { inputIds[0, i] = tokenIds[i]; attentionMask[0, i] = 1; positionIds[0, i] = i; } var inputs = new List { NamedOnnxValue.CreateFromTensor("input_ids", inputIds), NamedOnnxValue.CreateFromTensor("attention_mask", attentionMask), NamedOnnxValue.CreateFromTensor("position_ids", positionIds), }; // empty (zero-length) KV cache for every layer - a single uncached forward pass over // the whole query, same reasoning as build_extra_inputs() in generate_embeddings.py for (int layer = 0; layer < NumLayers; layer++) { var emptyKey = new DenseTensor(new[] { 1, NumKvHeads, 0, HeadDim }); var emptyValue = new DenseTensor(new[] { 1, NumKvHeads, 0, HeadDim }); inputs.Add(NamedOnnxValue.CreateFromTensor($"past_key_values.{layer}.key", emptyKey)); inputs.Add(NamedOnnxValue.CreateFromTensor($"past_key_values.{layer}.value", emptyValue)); } using (var outputs = _session.Run(inputs, new[] { "last_hidden_state" })) { var hiddenStates = outputs.First(o => o.Name == "last_hidden_state").AsTensor(); // hiddenStates shape: (1, seqLen, _dimension) // last-token pooling: batch size 1, no padding, so the last real token is simply // the final sequence position - see the class docstring for why this doesn't // need the attention-mask lookup the Python (batched, padded) version does var pooled = new float[_dimension]; for (int d = 0; d < _dimension; d++) { pooled[d] = hiddenStates[0, seqLen - 1, d]; } return L2Normalize(pooled); } } private static float[] L2Normalize(float[] vector) { double sumSquares = 0; foreach (var v in vector) sumSquares += (double)v * v; double norm = Math.Sqrt(sumSquares); if (norm < 1e-12) norm = 1e-12; // guard against a degenerate all-zero vector var result = new float[vector.Length]; for (int i = 0; i < vector.Length; i++) { result[i] = (float)(vector[i] / norm); } return result; } public void Dispose() { _session?.Dispose(); } } }