Skip to main content

omgbase_store/
search.rs

1//! Search over the store (`spec/search`, store 13.3): `text_search` (§1.3),
2//! the embedding tasks and the drain over the `embeddings`/`doc_embeddings`
3//! caches (§2), `vector_search`/`doc_vector_search` (§3), `hybrid_search` and
4//! `resolve` (§4). The pure pieces — sanitizer, inputs, pooling, cosine,
5//! fusion — are `omgbase-search`; this module runs the SQL around them.
6
7use std::collections::HashMap;
8
9use omgbase_search::{
10    BoostFacts, DocEmbedBlockRef, DocEmbedMethod, DocEmbedTask, EMBED_BATCH, EmbedTask,
11    EmbeddingProvider, Evidence, HYBRID_PASS_LIMIT, RESOLVE_DEFAULT_LIMIT, apply_boosts,
12    blob_to_f32, compute_boosts, context_prefix, cosine_f32, ctx_hash, default_terms, doc_header,
13    doc_input, estimate_tokens, f32_to_blob, fuse, hex, locator, lower_terms, preview,
14    property_to_string, sanitize_fts_query, should_embed, sort_by_score, token_budget,
15};
16use rusqlite::{Connection, OptionalExtension, params};
17use serde_json::Value as Json;
18
19use crate::derived::LIVE_LEAF_SQL;
20use crate::error::Result;
21use crate::read::reconstruct;
22use crate::tree::from_hex;
23use crate::{Store, properties};
24
25/// §1.3 / §4: the default `limit`.
26pub const DEFAULT_LIMIT: usize = 50;
27
28// ---- text search (§1.3) --------------------------------------------------------------
29
30/// One `text_search` hit; `score = −bm25` (higher is better).
31#[derive(Clone, Debug, PartialEq)]
32pub struct TextHit {
33    pub block_id: String,
34    pub doc_id: String,
35    pub path: String,
36    pub block_type: String,
37    pub text: String,
38    pub score: f64,
39}
40
41/// `text_search`'s result.
42#[derive(Clone, Debug, Default, PartialEq)]
43pub struct TextSearchResult {
44    pub hits: Vec<TextHit>,
45    /// More than `limit` blocks matched.
46    pub truncated: bool,
47}
48
49/// §1.3: the live blocks of the repo whose FTS row matches the sanitized
50/// query, by `bm25(blocks_fts)` ascending, limited; no hits when the query
51/// sanitizes to nothing.
52pub fn text_search(
53    conn: &Connection,
54    repo_id: &str,
55    query: &str,
56    limit: usize,
57) -> Result<TextSearchResult> {
58    let m = sanitize_fts_query(query);
59    if m.is_empty() {
60        return Ok(TextSearchResult::default());
61    }
62    let mut stmt = conn.prepare_cached(
63        "SELECT b.block_id, b.doc_id, d.path, b.type, b.text, bm25(blocks_fts) AS score
64         FROM blocks_fts
65         JOIN blocks b ON b.rowid = blocks_fts.rowid
66         JOIN docs d ON d.doc_id = b.doc_id
67         WHERE blocks_fts MATCH ?1 AND b.repo_id = ?2 AND b.deleted_commit IS NULL
68         ORDER BY score
69         LIMIT ?3",
70    )?;
71    let rows = stmt.query_map(params![m, repo_id, (limit + 1) as i64], |r| {
72        Ok(TextHit {
73            block_id: r.get(0)?,
74            doc_id: r.get(1)?,
75            path: r.get(2)?,
76            block_type: r.get(3)?,
77            text: r.get(4)?,
78            score: -r.get::<_, f64>(5)?,
79        })
80    })?;
81    let mut hits = rows.collect::<std::result::Result<Vec<_>, _>>()?;
82    let truncated = hits.len() > limit;
83    hits.truncate(limit);
84    Ok(TextSearchResult { hits, truncated })
85}
86
87// ---- embed tasks (§2.2, §2.4) ----------------------------------------------------------
88
89struct SectionRow {
90    heading_text: String,
91    level: i64,
92    first_ordinal: i64,
93    last_ordinal: i64,
94}
95
96/// The scope of a [`BlockContexts`] load: every doc of a repo, or one doc.
97#[derive(Clone, Copy, Debug)]
98pub enum ContextScope<'a> {
99    Repo(&'a str),
100    Doc(&'a str),
101}
102
103/// A block's *current* embedding context (§2.2) — the one rule every reader of
104/// the `embeddings` cache keys with: the drain (tasks), the pooled document
105/// vector, `vector_search` (§3) and `semantic()` (`spec/surface` §1.3). Loaded
106/// once per scope (one scan of the frontmatter titles and one of the
107/// sections), then a per-block walk of the doc's sections for the chain.
108pub struct BlockContexts {
109    title_by_doc: HashMap<String, String>,
110    sections_by_doc: HashMap<String, Vec<SectionRow>>,
111}
112
113impl BlockContexts {
114    /// Load the title and section facts of `scope`.
115    pub fn load(conn: &Connection, scope: ContextScope<'_>) -> Result<Self> {
116        let (by_repo, key) = match scope {
117            ContextScope::Repo(r) => (true, r),
118            ContextScope::Doc(d) => (false, d),
119        };
120        // The frontmatter `title` per doc: source frontmatter, card scalar, type
121        // string, non-empty.
122        let mut title_by_doc: HashMap<String, String> = HashMap::new();
123        {
124            let sql = format!(
125                "SELECT p.doc_id, p.val_text FROM properties p
126                 WHERE {} AND p.source = 'frontmatter' AND p.key = 'title'
127                   AND p.card = 'scalar' AND p.type = 'string' AND p.deleted_commit IS NULL
128                 ORDER BY p.rowid",
129                if by_repo {
130                    "p.repo_id = ?1"
131                } else {
132                    "p.doc_id = ?1"
133                }
134            );
135            let mut stmt = conn.prepare_cached(&sql)?;
136            let it = stmt.query_map(params![key], |r| {
137                Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
138            })?;
139            for row in it {
140                let (doc_id, title) = row?;
141                if let Some(t) = title.filter(|t| !t.is_empty()) {
142                    title_by_doc.insert(doc_id, t);
143                }
144            }
145        }
146
147        // Every section in scope with its heading text, in first_ordinal order
148        // per doc (the first is the title fallback).
149        let mut sections_by_doc: HashMap<String, Vec<SectionRow>> = HashMap::new();
150        {
151            let sql = format!(
152                "SELECT s.doc_id, hb.text, s.level, s.first_ordinal, s.last_ordinal
153                 FROM sections s JOIN blocks hb ON hb.block_id = s.heading_block
154                 WHERE {}
155                 ORDER BY s.doc_id, s.first_ordinal, s.rowid",
156                if by_repo {
157                    "hb.repo_id = ?1"
158                } else {
159                    "s.doc_id = ?1"
160                }
161            );
162            let mut stmt = conn.prepare_cached(&sql)?;
163            let it = stmt.query_map(params![key], |r| {
164                Ok((
165                    r.get::<_, String>(0)?,
166                    SectionRow {
167                        heading_text: r.get(1)?,
168                        level: r.get(2)?,
169                        first_ordinal: r.get(3)?,
170                        last_ordinal: r.get(4)?,
171                    },
172                ))
173            })?;
174            for row in it {
175                let (doc_id, s) = row?;
176                sections_by_doc.entry(doc_id).or_default().push(s);
177            }
178        }
179        Ok(Self {
180            title_by_doc,
181            sections_by_doc,
182        })
183    }
184
185    /// §2.2: the context prefix of a block of `doc_id` at `ordinal`.
186    pub fn ctx(&self, doc_id: &str, path: &str, ordinal: i64, block_type: &str) -> String {
187        let sections = self
188            .sections_by_doc
189            .get(doc_id)
190            .map_or(&[][..], Vec::as_slice);
191        let doc_title = match self.title_by_doc.get(doc_id) {
192            Some(t) if !t.trim_matches(omgbase_search::is_js_whitespace).is_empty() => t.clone(),
193            _ => sections
194                .first()
195                .map_or_else(|| path.to_owned(), |s| s.heading_text.clone()),
196        };
197        let mut containing: Vec<&SectionRow> = sections
198            .iter()
199            .filter(|s| ordinal >= s.first_ordinal && ordinal <= s.last_ordinal)
200            .collect();
201        containing.sort_by_key(|s| s.level);
202        let chain: Vec<String> = containing.iter().map(|s| s.heading_text.clone()).collect();
203        context_prefix(&doc_title, path, &chain, block_type)
204    }
205}
206
207/// §2.1–§2.2: an [`EmbedTask`] for every embeddable live block of the repo —
208/// the live **leaves** (1.2: a container's text is its children's text, so
209/// embedding it would pool the same words twice) that clear `should_embed` —
210/// in `(path, ordinal, block_id)` order.
211pub fn build_embed_tasks(conn: &Connection, repo_id: &str) -> Result<Vec<EmbedTask>> {
212    struct BlockRow {
213        block_id: String,
214        doc_id: String,
215        path: String,
216        ordinal: i64,
217        block_type: String,
218        text: String,
219        raw_hash: Vec<u8>,
220    }
221    let blocks: Vec<BlockRow> = {
222        let mut stmt = conn.prepare_cached(&format!(
223            "SELECT b.block_id, b.doc_id, d.path, b.ordinal, b.type, b.text, b.raw_hash
224             FROM blocks b JOIN docs d ON d.doc_id = b.doc_id
225             WHERE b.repo_id = ?1 AND {LIVE_LEAF_SQL}
226             ORDER BY d.path, b.ordinal, b.block_id"
227        ))?;
228        let it = stmt.query_map(params![repo_id], |r| {
229            Ok(BlockRow {
230                block_id: r.get(0)?,
231                doc_id: r.get(1)?,
232                path: r.get(2)?,
233                ordinal: r.get(3)?,
234                block_type: r.get(4)?,
235                text: r.get(5)?,
236                raw_hash: r.get(6)?,
237            })
238        })?;
239        it.collect::<std::result::Result<Vec<_>, _>>()?
240    };
241    let contexts = BlockContexts::load(conn, ContextScope::Repo(repo_id))?;
242
243    let mut tasks = Vec::new();
244    for b in &blocks {
245        if !should_embed(&b.text) {
246            continue;
247        }
248        tasks.push(EmbedTask {
249            block_id: b.block_id.clone(),
250            content_hash: hex(&b.raw_hash),
251            ctx: contexts.ctx(&b.doc_id, &b.path, b.ordinal, &b.block_type),
252            text: b.text.clone(),
253        });
254    }
255    Ok(tasks)
256}
257
258/// A merged property when it is a non-blank string.
259fn nonblank_string(props: &Json, key: &str) -> Option<String> {
260    props
261        .get(key)
262        .and_then(Json::as_str)
263        .filter(|s| !s.trim_matches(omgbase_search::is_js_whitespace).is_empty())
264        .map(str::to_owned)
265}
266
267/// §2.4: a [`DocEmbedTask`] for every live document with reconstructable
268/// content, in `path` order; each carries its embeddable blocks' cache keys
269/// and weights for pooling.
270pub fn build_doc_embed_tasks(conn: &Connection, repo_id: &str) -> Result<Vec<DocEmbedTask>> {
271    let block_tasks = build_embed_tasks(conn, repo_id)?;
272    let doc_of_block: HashMap<String, String> = {
273        let mut stmt = conn.prepare_cached(
274            "SELECT block_id, doc_id FROM blocks WHERE repo_id = ?1 AND deleted_commit IS NULL",
275        )?;
276        let it = stmt.query_map(params![repo_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
277        it.collect::<std::result::Result<HashMap<_, _>, _>>()?
278    };
279    let mut blocks_by_doc: HashMap<String, Vec<DocEmbedBlockRef>> = HashMap::new();
280    for t in &block_tasks {
281        let Some(doc_id) = doc_of_block.get(&t.block_id) else {
282            continue;
283        };
284        blocks_by_doc
285            .entry(doc_id.clone())
286            .or_default()
287            .push(DocEmbedBlockRef {
288                content_hash: t.content_hash.clone(),
289                ctx: t.ctx.clone(),
290                tokens: estimate_tokens(&t.text),
291            });
292    }
293    let docs: Vec<(String, String)> = {
294        let mut stmt = conn.prepare_cached(
295            "SELECT doc_id, path FROM docs WHERE repo_id = ?1 AND deleted_commit IS NULL ORDER BY path",
296        )?;
297        let it = stmt.query_map(params![repo_id], |r| Ok((r.get(0)?, r.get(1)?)))?;
298        it.collect::<std::result::Result<Vec<_>, _>>()?
299    };
300    let mut tasks = Vec::new();
301    for (doc_id, path) in docs {
302        let Some(body) = reconstruct(conn, &doc_id)? else {
303            continue;
304        };
305        let props = omgbase_properties::merged(&properties::read_doc_properties(conn, &doc_id)?);
306        let title = nonblank_string(&props, "$title")
307            .or_else(|| nonblank_string(&props, "title"))
308            .unwrap_or_else(|| path.clone());
309        let header = doc_header(
310            &title,
311            &path,
312            nonblank_string(&props, "type").as_deref(),
313            nonblank_string(&props, "layer").as_deref(),
314        );
315        let input = doc_input(&header, &body);
316        if input
317            .trim_matches(omgbase_search::is_js_whitespace)
318            .is_empty()
319        {
320            continue;
321        }
322        tasks.push(DocEmbedTask {
323            doc_id: doc_id.clone(),
324            header,
325            input,
326            blocks: blocks_by_doc.remove(&doc_id).unwrap_or_default(),
327        });
328    }
329    Ok(tasks)
330}
331
332// ---- the caches and the drain (§2.3–§2.6) ---------------------------------------------
333
334/// The cached vector for a block cache key under `model`, if any.
335pub fn get_cached(
336    conn: &Connection,
337    content_hash_hex: &str,
338    ctx: &str,
339    model: &str,
340) -> Result<Option<Vec<f32>>> {
341    let blob: Option<Vec<u8>> = conn
342        .query_row(
343            "SELECT vec FROM embeddings WHERE content_hash = ?1 AND ctx_hash = ?2 AND model = ?3",
344            params![from_hex(content_hash_hex)?, &ctx_hash(ctx)[..], model],
345            |r| r.get(0),
346        )
347        .optional()?;
348    Ok(blob.map(|b| blob_to_f32(&b)))
349}
350
351/// A cached document vector and how it was computed.
352#[derive(Clone, Debug, PartialEq)]
353pub struct DocVectorRow {
354    pub vec: Vec<f32>,
355    pub method: DocEmbedMethod,
356}
357
358/// The fresh cached vector for a document task under `model`: `None` when
359/// absent or stale (`input_hash` differs from `sha256(task.input)`).
360pub fn get_cached_doc(
361    conn: &Connection,
362    task: &DocEmbedTask,
363    model: &str,
364) -> Result<Option<DocVectorRow>> {
365    let row: Option<(Vec<u8>, String, Vec<u8>)> = conn
366        .query_row(
367            "SELECT input_hash, method, vec FROM doc_embeddings WHERE doc_id = ?1 AND model = ?2",
368            params![task.doc_id, model],
369            |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
370        )
371        .optional()?;
372    Ok(row.and_then(|(input_hash, method, vec)| {
373        if input_hash[..] != task.input_hash()[..] {
374            return None;
375        }
376        Some(DocVectorRow {
377            vec: blob_to_f32(&vec),
378            method: DocEmbedMethod::parse(&method)?,
379        })
380    }))
381}
382
383/// What a block pass did.
384#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
385pub struct EmbedStats {
386    /// Cache misses embedded and written.
387    pub embedded: usize,
388    /// Tasks whose vector was already cached.
389    pub cached: usize,
390}
391
392/// What a document pass did.
393#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
394pub struct DocEmbedStats {
395    /// Whole-document inputs embedded and written.
396    pub embedded: usize,
397    /// Tasks whose fresh vector was already cached.
398    pub cached: usize,
399    /// Over-budget documents pooled from cached block vectors.
400    pub pooled: usize,
401}
402
403/// One drain: the block pass, then the document pass (§2.6).
404#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
405pub struct DrainStats {
406    pub blocks: EmbedStats,
407    pub docs: DocEmbedStats,
408}
409
410/// §2.3 `process`: partition into cached and misses; embed the misses'
411/// inputs in batches of 32; `INSERT OR REPLACE` each vector under the
412/// provider's `model` and `dim`.
413pub fn embed_process(
414    conn: &Connection,
415    tasks: &[EmbedTask],
416    provider: &dyn EmbeddingProvider,
417) -> Result<EmbedStats> {
418    let model = provider.model();
419    let mut misses: Vec<&EmbedTask> = Vec::new();
420    let mut cached = 0;
421    for t in tasks {
422        if get_cached(conn, &t.content_hash, &t.ctx, model)?.is_some() {
423            cached += 1;
424        } else {
425            misses.push(t);
426        }
427    }
428    let mut embedded = 0;
429    for chunk in misses.chunks(EMBED_BATCH) {
430        let inputs: Vec<String> = chunk.iter().map(|t| t.input()).collect();
431        let vectors = provider.embed(&inputs)?;
432        let tx = conn.unchecked_transaction()?;
433        {
434            let mut insert = tx.prepare_cached(
435                "INSERT OR REPLACE INTO embeddings (content_hash, ctx_hash, model, dim, vec)
436                 VALUES (?1, ?2, ?3, ?4, ?5)",
437            )?;
438            for (t, v) in chunk.iter().zip(&vectors) {
439                insert.execute(params![
440                    from_hex(&t.content_hash)?,
441                    &ctx_hash(&t.ctx)[..],
442                    model,
443                    provider.dim() as i64,
444                    f32_to_blob(v),
445                ])?;
446            }
447        }
448        tx.commit()?;
449        embedded += chunk.len();
450    }
451    Ok(EmbedStats { embedded, cached })
452}
453
454/// §2.4–§2.5 `process_docs`: skip fresh cached vectors; within budget →
455/// embed the input whole (batches of 32); over budget → pool the cached block
456/// vectors with no provider call (a document with nothing cached stays queued).
457pub fn embed_process_docs(
458    conn: &Connection,
459    tasks: &[DocEmbedTask],
460    provider: &dyn EmbeddingProvider,
461) -> Result<DocEmbedStats> {
462    let model = provider.model();
463    let budget = token_budget(provider.max_input_tokens());
464    let mut misses: Vec<&DocEmbedTask> = Vec::new();
465    let mut cached = 0;
466    for t in tasks {
467        if get_cached_doc(conn, t, model)?.is_some() {
468            cached += 1;
469        } else {
470            misses.push(t);
471        }
472    }
473    let (whole, pooled_tasks): (Vec<&DocEmbedTask>, Vec<&DocEmbedTask>) = misses
474        .into_iter()
475        .partition(|t| DocEmbedMethod::for_input(&t.input, budget) == DocEmbedMethod::Whole);
476
477    const INSERT: &str =
478        "INSERT OR REPLACE INTO doc_embeddings (doc_id, model, input_hash, method, dim, vec)
479                          VALUES (?1, ?2, ?3, ?4, ?5, ?6)";
480    let mut embedded = 0;
481    for chunk in whole.chunks(EMBED_BATCH) {
482        let inputs: Vec<String> = chunk.iter().map(|t| t.input.clone()).collect();
483        let vectors = provider.embed(&inputs)?;
484        let tx = conn.unchecked_transaction()?;
485        {
486            let mut insert = tx.prepare_cached(INSERT)?;
487            for (t, v) in chunk.iter().zip(&vectors) {
488                insert.execute(params![
489                    t.doc_id,
490                    model,
491                    &t.input_hash()[..],
492                    DocEmbedMethod::Whole.as_str(),
493                    provider.dim() as i64,
494                    f32_to_blob(v),
495                ])?;
496            }
497        }
498        tx.commit()?;
499        embedded += chunk.len();
500    }
501
502    let mut pooled = 0;
503    if !pooled_tasks.is_empty() {
504        let tx = conn.unchecked_transaction()?;
505        {
506            let mut insert = tx.prepare_cached(INSERT)?;
507            for t in &pooled_tasks {
508                let mut lookup_err: Option<crate::Error> = None;
509                let v = omgbase_search::pool_block_vectors(provider.dim(), &t.blocks, |r| {
510                    match get_cached(&tx, &r.content_hash, &r.ctx, model) {
511                        Ok(v) => v,
512                        Err(e) => {
513                            lookup_err = Some(e);
514                            None
515                        }
516                    }
517                });
518                if let Some(e) = lookup_err {
519                    return Err(e);
520                }
521                let Some(v) = v else {
522                    continue; // nothing cached yet — stays queued
523                };
524                insert.execute(params![
525                    t.doc_id,
526                    model,
527                    &t.input_hash()[..],
528                    DocEmbedMethod::Pooled.as_str(),
529                    provider.dim() as i64,
530                    f32_to_blob(&v),
531                ])?;
532                pooled += 1;
533            }
534        }
535        tx.commit()?;
536    }
537    Ok(DocEmbedStats {
538        embedded,
539        cached,
540        pooled,
541    })
542}
543
544/// The block ids among `tasks` whose current key has no cached vector under
545/// `model` (the worker queue).
546pub fn stale_blocks(conn: &Connection, tasks: &[EmbedTask], model: &str) -> Result<Vec<String>> {
547    let mut out = Vec::new();
548    for t in tasks {
549        if should_embed(&t.text) && get_cached(conn, &t.content_hash, &t.ctx, model)?.is_none() {
550            out.push(t.block_id.clone());
551        }
552    }
553    Ok(out)
554}
555
556/// The doc ids among `tasks` with no fresh cached vector under `model`.
557pub fn stale_docs(conn: &Connection, tasks: &[DocEmbedTask], model: &str) -> Result<Vec<String>> {
558    let mut out = Vec::new();
559    for t in tasks {
560        if get_cached_doc(conn, t, model)?.is_none() {
561            out.push(t.doc_id.clone());
562        }
563    }
564    Ok(out)
565}
566
567/// Counts of `(block, doc)` vectors under a model other than `model`.
568#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
569pub struct ForeignVectors {
570    pub blocks: usize,
571    pub docs: usize,
572}
573
574/// §2.6: how many cached vectors belong to another model.
575pub fn foreign_vector_count(conn: &Connection, model: &str) -> Result<ForeignVectors> {
576    let blocks: i64 = conn.query_row(
577        "SELECT count(*) FROM embeddings WHERE model != ?1",
578        params![model],
579        |r| r.get(0),
580    )?;
581    let docs: i64 = conn.query_row(
582        "SELECT count(*) FROM doc_embeddings WHERE model != ?1",
583        params![model],
584        |r| r.get(0),
585    )?;
586    Ok(ForeignVectors {
587        blocks: blocks as usize,
588        docs: docs as usize,
589    })
590}
591
592/// §2.6: delete every cached vector not produced by `model`.
593pub fn prune_foreign_vectors(conn: &Connection, model: &str) -> Result<ForeignVectors> {
594    let tx = conn.unchecked_transaction()?;
595    let blocks = tx.execute("DELETE FROM embeddings WHERE model != ?1", params![model])?;
596    let docs = tx.execute(
597        "DELETE FROM doc_embeddings WHERE model != ?1",
598        params![model],
599    )?;
600    tx.commit()?;
601    Ok(ForeignVectors { blocks, docs })
602}
603
604// ---- vector search (§3) ---------------------------------------------------------------
605
606/// One `vector_search` hit.
607#[derive(Clone, Debug, PartialEq)]
608pub struct VectorHit {
609    pub block_id: String,
610    pub doc_id: String,
611    pub path: String,
612    pub cosine: f64,
613}
614
615/// One `doc_vector_search` hit.
616#[derive(Clone, Debug, PartialEq)]
617pub struct DocVectorHit {
618    pub doc_id: String,
619    pub path: String,
620    pub cosine: f64,
621}
622
623/// §3 (1.1): the block's vector is the `embeddings` row for `model` keyed by
624/// its *current* `(content_hash, ctx_hash)`, `None` when it is stale (§2.3) —
625/// or not a live block at all.
626pub fn block_vector(conn: &Connection, block_id: &str, model: &str) -> Result<Option<Vec<u8>>> {
627    let row: Option<(String, String, i64, String, Vec<u8>)> = conn
628        .prepare_cached(
629            "SELECT b.doc_id, d.path, b.ordinal, b.type, b.raw_hash
630             FROM blocks b JOIN docs d ON d.doc_id = b.doc_id
631             WHERE b.block_id = ?1 AND b.deleted_commit IS NULL",
632        )?
633        .query_row(params![block_id], |r| {
634            Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
635        })
636        .optional()?;
637    let Some((doc_id, path, ordinal, block_type, raw_hash)) = row else {
638        return Ok(None);
639    };
640    let ctx = BlockContexts::load(conn, ContextScope::Doc(&doc_id))?.ctx(
641        &doc_id,
642        &path,
643        ordinal,
644        &block_type,
645    );
646    current_vector(conn, &raw_hash, &ctx, model)
647}
648
649/// The cache row for `(content_hash, sha256(ctx), model)`, raw blob.
650fn current_vector(
651    conn: &Connection,
652    content_hash: &[u8],
653    ctx: &str,
654    model: &str,
655) -> Result<Option<Vec<u8>>> {
656    Ok(conn
657        .prepare_cached(
658            "SELECT vec FROM embeddings WHERE content_hash = ?1 AND ctx_hash = ?2 AND model = ?3",
659        )?
660        .query_row(params![content_hash, &ctx_hash(ctx)[..], model], |r| {
661            r.get(0)
662        })
663        .optional()?)
664}
665
666/// §3: over every live embeddable block of the repo, the block's vector is the
667/// row for its current `(content_hash, ctx_hash)` under `model` (a block with
668/// no such row is stale and not a hit — one candidate per block by
669/// construction, 1.1); scored by cosine, by score descending then `block_id`
670/// bytewise, limited.
671pub fn vector_search(
672    conn: &Connection,
673    repo_id: &str,
674    model: &str,
675    query: &[f32],
676    limit: usize,
677) -> Result<Vec<VectorHit>> {
678    // The candidates are exactly the drain's tasks (§2.2): one walk of the
679    // repo's blocks and sections per query, then an indexed point lookup per
680    // embeddable block.
681    let tasks = build_embed_tasks(conn, repo_id)?;
682    let mut meta = conn.prepare_cached(
683        "SELECT b.doc_id, d.path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ?1",
684    )?;
685    let mut hits = Vec::new();
686    for t in &tasks {
687        let Some(vec) = current_vector(conn, &from_hex(&t.content_hash)?, &t.ctx, model)? else {
688            continue;
689        };
690        let m: Option<(String, String)> = meta
691            .query_row(params![t.block_id], |r| Ok((r.get(0)?, r.get(1)?)))
692            .optional()?;
693        let Some((doc_id, path)) = m else {
694            continue;
695        };
696        hits.push(VectorHit {
697            block_id: t.block_id.clone(),
698            doc_id,
699            path,
700            cosine: cosine_f32(query, &blob_to_f32(&vec)),
701        });
702    }
703    sort_by_score(&mut hits, |h| h.cosine, |h| &h.block_id);
704    hits.truncate(limit);
705    Ok(hits)
706}
707
708/// §3: the same over `doc_embeddings` joined to live docs (one hit per doc),
709/// ties by `doc_id`.
710pub fn doc_vector_search(
711    conn: &Connection,
712    repo_id: &str,
713    model: &str,
714    query: &[f32],
715    limit: usize,
716) -> Result<Vec<DocVectorHit>> {
717    let mut stmt = conn.prepare_cached(
718        "SELECT d.doc_id, d.path, e.vec
719         FROM doc_embeddings e
720         JOIN docs d ON d.doc_id = e.doc_id AND d.deleted_commit IS NULL
721         WHERE d.repo_id = ?1 AND e.model = ?2",
722    )?;
723    let rows = stmt.query_map(params![repo_id, model], |r| {
724        Ok(DocVectorHit {
725            doc_id: r.get(0)?,
726            path: r.get(1)?,
727            cosine: cosine_f32(query, &blob_to_f32(&r.get::<_, Vec<u8>>(2)?)),
728        })
729    })?;
730    // `(doc_id, model)` is the primary key: one row per live doc already.
731    let mut hits = rows.collect::<std::result::Result<Vec<_>, _>>()?;
732    sort_by_score(&mut hits, |h| h.cosine, |h| &h.doc_id);
733    hits.truncate(limit);
734    Ok(hits)
735}
736
737// ---- hybrid and resolve (§4) ------------------------------------------------------------
738
739/// The vector side of a hybrid query: the model whose cache to read and the
740/// query vector.
741#[derive(Clone, Debug, PartialEq)]
742pub struct QueryVector {
743    pub model: String,
744    pub vec: Vec<f32>,
745}
746
747/// The inputs of [`hybrid_search`].
748#[derive(Clone, Debug, Default, PartialEq)]
749pub struct HybridQuery {
750    /// The FTS query.
751    pub text: Option<String>,
752    pub vector: Option<QueryVector>,
753    /// Boost terms; default: `text` split on whitespace.
754    pub terms: Option<Vec<String>>,
755    pub limit: Option<usize>,
756}
757
758/// One `hybrid_search` hit.
759#[derive(Clone, Debug, PartialEq)]
760pub struct HybridHit {
761    pub block_id: String,
762    pub doc_id: String,
763    pub path: String,
764    pub score: f64,
765    pub evidence: Evidence,
766}
767
768/// §4 step 4's facts for one block.
769fn boost_facts(conn: &Connection, doc_id: &str, block_id: &str, path: &str) -> Result<BoostFacts> {
770    let props = omgbase_properties::merged(&properties::read_doc_properties(conn, doc_id)?);
771    let headings: Vec<String> = {
772        let mut stmt = conn.prepare_cached(
773            "SELECT hb.text FROM sections s JOIN blocks hb ON hb.block_id = s.heading_block
774             JOIN blocks b ON b.doc_id = s.doc_id AND b.ordinal BETWEEN s.first_ordinal AND s.last_ordinal
775             WHERE b.block_id = ?1",
776        )?;
777        let it = stmt.query_map(params![block_id], |r| r.get(0))?;
778        it.collect::<std::result::Result<Vec<_>, _>>()?
779    };
780    Ok(BoostFacts {
781        title: property_to_string(props.get("title")),
782        layer: property_to_string(props.get("layer")),
783        path: path.to_owned(),
784        headings,
785    })
786}
787
788/// §4: RRF-fuse the FTS and vector rankings (each capped at 200), drop
789/// candidates whose block no longer exists, apply the boosts, sort by score
790/// then `block_id`, limit.
791pub fn hybrid_search(conn: &Connection, repo_id: &str, q: &HybridQuery) -> Result<Vec<HybridHit>> {
792    let limit = q.limit.unwrap_or(DEFAULT_LIMIT);
793    let terms = lower_terms(
794        &q.terms
795            .clone()
796            .unwrap_or_else(|| default_terms(q.text.as_deref())),
797    );
798    let fts_ids: Vec<String> = match q.text.as_deref().filter(|t| !t.is_empty()) {
799        Some(text) => text_search(conn, repo_id, text, HYBRID_PASS_LIMIT)?
800            .hits
801            .into_iter()
802            .map(|h| h.block_id)
803            .collect(),
804        None => Vec::new(),
805    };
806    let vec_hits: Vec<(String, f64)> = match &q.vector {
807        Some(v) => vector_search(conn, repo_id, &v.model, &v.vec, HYBRID_PASS_LIMIT)?
808            .into_iter()
809            .map(|h| (h.block_id, h.cosine))
810            .collect(),
811        None => Vec::new(),
812    };
813    let mut meta = conn.prepare_cached(
814        "SELECT doc_id, (SELECT path FROM docs WHERE doc_id = blocks.doc_id) FROM blocks WHERE block_id = ?1",
815    )?;
816    let mut hits = Vec::new();
817    for c in fuse(&fts_ids, &vec_hits) {
818        let m: Option<(String, Option<String>)> = meta
819            .query_row(params![c.block_id], |r| Ok((r.get(0)?, r.get(1)?)))
820            .optional()?;
821        let Some((doc_id, path)) = m else {
822            continue;
823        };
824        let path = path.unwrap_or_default();
825        let boosts = compute_boosts(&boost_facts(conn, &doc_id, &c.block_id, &path)?, &terms);
826        let evidence = Evidence {
827            fts_rank: c.fts_rank,
828            vector_rank: c.vector_rank,
829            cosine: c.cosine,
830            rrf: c.rrf,
831            boosts,
832        };
833        hits.push(HybridHit {
834            block_id: c.block_id,
835            doc_id,
836            path,
837            score: apply_boosts(c.rrf, &boosts),
838            evidence,
839        });
840    }
841    sort_by_score(&mut hits, |h| h.score, |h| &h.block_id);
842    hits.truncate(limit);
843    Ok(hits)
844}
845
846/// One `resolve` candidate.
847#[derive(Clone, Debug, PartialEq)]
848pub struct ResolveHit {
849    pub id: String,
850    /// `path#type[ordinal]`.
851    pub locator: String,
852    /// The first 12 words of the text (`…` when cut).
853    pub preview: String,
854    pub evidence: Evidence,
855}
856
857/// §4 `resolve`: [`hybrid_search`] over `query` (+ an optional vector),
858/// limit default 10, reshaped to `{ id, locator, preview, evidence }`.
859pub fn resolve(
860    conn: &Connection,
861    repo_id: &str,
862    query: &str,
863    vector: Option<QueryVector>,
864    limit: Option<usize>,
865) -> Result<Vec<ResolveHit>> {
866    let hits = hybrid_search(
867        conn,
868        repo_id,
869        &HybridQuery {
870            text: Some(query.to_owned()),
871            vector,
872            terms: None,
873            limit: Some(limit.unwrap_or(RESOLVE_DEFAULT_LIMIT)),
874        },
875    )?;
876    let mut row = conn.prepare_cached(
877        "SELECT d.path, b.ordinal, b.type, b.text FROM blocks b JOIN docs d ON d.doc_id = b.doc_id
878         WHERE b.block_id = ?1",
879    )?;
880    let mut out = Vec::with_capacity(hits.len());
881    for h in hits {
882        let r: Option<(String, i64, String, String)> = row
883            .query_row(params![h.block_id], |r| {
884                Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
885            })
886            .optional()?;
887        let (loc, prev) = match r {
888            Some((path, ordinal, ty, text)) => (
889                locator(&path, &ty, ordinal),
890                preview(&text, omgbase_search::PREVIEW_WORDS),
891            ),
892            None => (h.block_id.clone(), String::new()),
893        };
894        out.push(ResolveHit {
895            id: h.block_id,
896            locator: loc,
897            preview: prev,
898            evidence: h.evidence,
899        });
900    }
901    Ok(out)
902}
903
904// ---- Store surface -------------------------------------------------------------------------
905
906impl Store {
907    /// `spec/search` §1.3.
908    pub fn text_search(
909        &self,
910        repo_id: &str,
911        query: &str,
912        limit: usize,
913    ) -> Result<TextSearchResult> {
914        text_search(&self.conn, repo_id, query, limit)
915    }
916
917    /// `spec/search` §2.2: the embeddable blocks' tasks, `(path, ordinal)` order.
918    pub fn build_embed_tasks(&self, repo_id: &str) -> Result<Vec<EmbedTask>> {
919        build_embed_tasks(&self.conn, repo_id)
920    }
921
922    /// `spec/search` §2.4: the live documents' tasks, `path` order.
923    pub fn build_doc_embed_tasks(&self, repo_id: &str) -> Result<Vec<DocEmbedTask>> {
924        build_doc_embed_tasks(&self.conn, repo_id)
925    }
926
927    /// `spec/search` §2.3: embed the cache misses among `tasks`.
928    pub fn embed_process(
929        &self,
930        tasks: &[EmbedTask],
931        provider: &dyn EmbeddingProvider,
932    ) -> Result<EmbedStats> {
933        embed_process(&self.conn, tasks, provider)
934    }
935
936    /// `spec/search` §2.4–§2.5: one vector per document, whole or pooled.
937    pub fn embed_process_docs(
938        &self,
939        tasks: &[DocEmbedTask],
940        provider: &dyn EmbeddingProvider,
941    ) -> Result<DocEmbedStats> {
942        embed_process_docs(&self.conn, tasks, provider)
943    }
944
945    /// `spec/search` §2.6: one drain — the block pass, then the document pass.
946    pub fn drain(&self, repo_id: &str, provider: &dyn EmbeddingProvider) -> Result<DrainStats> {
947        let blocks = self.embed_process(&self.build_embed_tasks(repo_id)?, provider)?;
948        let docs = self.embed_process_docs(&self.build_doc_embed_tasks(repo_id)?, provider)?;
949        Ok(DrainStats { blocks, docs })
950    }
951
952    /// The cached vector for a block cache key under `model`.
953    pub fn cached_vector(
954        &self,
955        content_hash_hex: &str,
956        ctx: &str,
957        model: &str,
958    ) -> Result<Option<Vec<f32>>> {
959        get_cached(&self.conn, content_hash_hex, ctx, model)
960    }
961
962    /// The fresh cached document vector for a task under `model`.
963    pub fn cached_doc_vector(
964        &self,
965        task: &DocEmbedTask,
966        model: &str,
967    ) -> Result<Option<DocVectorRow>> {
968        get_cached_doc(&self.conn, task, model)
969    }
970
971    /// Block ids among `tasks` with no cached vector under `model`.
972    pub fn stale_blocks(&self, tasks: &[EmbedTask], model: &str) -> Result<Vec<String>> {
973        stale_blocks(&self.conn, tasks, model)
974    }
975
976    /// Doc ids among `tasks` with no fresh cached vector under `model`.
977    pub fn stale_docs(&self, tasks: &[DocEmbedTask], model: &str) -> Result<Vec<String>> {
978        stale_docs(&self.conn, tasks, model)
979    }
980
981    /// `spec/search` §2.6: vectors under another model.
982    pub fn foreign_vector_count(&self, model: &str) -> Result<ForeignVectors> {
983        foreign_vector_count(&self.conn, model)
984    }
985
986    /// `spec/search` §2.6: delete vectors under another model.
987    pub fn prune_foreign_vectors(&self, model: &str) -> Result<ForeignVectors> {
988        prune_foreign_vectors(&self.conn, model)
989    }
990
991    /// `spec/search` §3 (1.1): a live block's vector — the row for its current
992    /// `(content_hash, ctx_hash)` under `model` — as the stored blob, `None`
993    /// when stale or not live. Serves `semantic()` (`spec/surface` §1.3).
994    pub fn block_vector(&self, block_id: &str, model: &str) -> Result<Option<Vec<u8>>> {
995        block_vector(&self.conn, block_id, model)
996    }
997
998    /// `spec/search` §3 over blocks.
999    pub fn vector_search(
1000        &self,
1001        repo_id: &str,
1002        model: &str,
1003        query: &[f32],
1004        limit: usize,
1005    ) -> Result<Vec<VectorHit>> {
1006        vector_search(&self.conn, repo_id, model, query, limit)
1007    }
1008
1009    /// `spec/search` §3 over documents.
1010    pub fn doc_vector_search(
1011        &self,
1012        repo_id: &str,
1013        model: &str,
1014        query: &[f32],
1015        limit: usize,
1016    ) -> Result<Vec<DocVectorHit>> {
1017        doc_vector_search(&self.conn, repo_id, model, query, limit)
1018    }
1019
1020    /// `spec/search` §4.
1021    pub fn hybrid_search(&self, repo_id: &str, q: &HybridQuery) -> Result<Vec<HybridHit>> {
1022        hybrid_search(&self.conn, repo_id, q)
1023    }
1024
1025    /// `spec/search` §4 `resolve`.
1026    pub fn resolve(
1027        &self,
1028        repo_id: &str,
1029        query: &str,
1030        vector: Option<QueryVector>,
1031        limit: Option<usize>,
1032    ) -> Result<Vec<ResolveHit>> {
1033        resolve(&self.conn, repo_id, query, vector, limit)
1034    }
1035}