1use 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
25pub const DEFAULT_LIMIT: usize = 50;
27
28#[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#[derive(Clone, Debug, Default, PartialEq)]
43pub struct TextSearchResult {
44 pub hits: Vec<TextHit>,
45 pub truncated: bool,
47}
48
49pub 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
87struct SectionRow {
90 heading_text: String,
91 level: i64,
92 first_ordinal: i64,
93 last_ordinal: i64,
94}
95
96#[derive(Clone, Copy, Debug)]
98pub enum ContextScope<'a> {
99 Repo(&'a str),
100 Doc(&'a str),
101}
102
103pub struct BlockContexts {
109 title_by_doc: HashMap<String, String>,
110 sections_by_doc: HashMap<String, Vec<SectionRow>>,
111}
112
113impl BlockContexts {
114 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 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 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 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
207pub 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
258fn 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
267pub 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
332pub 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#[derive(Clone, Debug, PartialEq)]
353pub struct DocVectorRow {
354 pub vec: Vec<f32>,
355 pub method: DocEmbedMethod,
356}
357
358pub 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
385pub struct EmbedStats {
386 pub embedded: usize,
388 pub cached: usize,
390}
391
392#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
394pub struct DocEmbedStats {
395 pub embedded: usize,
397 pub cached: usize,
399 pub pooled: usize,
401}
402
403#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
405pub struct DrainStats {
406 pub blocks: EmbedStats,
407 pub docs: DocEmbedStats,
408}
409
410pub 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
454pub 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; };
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
544pub 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
556pub 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
569pub struct ForeignVectors {
570 pub blocks: usize,
571 pub docs: usize,
572}
573
574pub 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
592pub 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#[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#[derive(Clone, Debug, PartialEq)]
617pub struct DocVectorHit {
618 pub doc_id: String,
619 pub path: String,
620 pub cosine: f64,
621}
622
623pub 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
649fn 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
666pub fn vector_search(
672 conn: &Connection,
673 repo_id: &str,
674 model: &str,
675 query: &[f32],
676 limit: usize,
677) -> Result<Vec<VectorHit>> {
678 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
708pub 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 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#[derive(Clone, Debug, PartialEq)]
742pub struct QueryVector {
743 pub model: String,
744 pub vec: Vec<f32>,
745}
746
747#[derive(Clone, Debug, Default, PartialEq)]
749pub struct HybridQuery {
750 pub text: Option<String>,
752 pub vector: Option<QueryVector>,
753 pub terms: Option<Vec<String>>,
755 pub limit: Option<usize>,
756}
757
758#[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
768fn 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
788pub 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#[derive(Clone, Debug, PartialEq)]
848pub struct ResolveHit {
849 pub id: String,
850 pub locator: String,
852 pub preview: String,
854 pub evidence: Evidence,
855}
856
857pub 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
904impl Store {
907 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 pub fn build_embed_tasks(&self, repo_id: &str) -> Result<Vec<EmbedTask>> {
919 build_embed_tasks(&self.conn, repo_id)
920 }
921
922 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 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 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 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 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 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 pub fn stale_blocks(&self, tasks: &[EmbedTask], model: &str) -> Result<Vec<String>> {
973 stale_blocks(&self.conn, tasks, model)
974 }
975
976 pub fn stale_docs(&self, tasks: &[DocEmbedTask], model: &str) -> Result<Vec<String>> {
978 stale_docs(&self.conn, tasks, model)
979 }
980
981 pub fn foreign_vector_count(&self, model: &str) -> Result<ForeignVectors> {
983 foreign_vector_count(&self.conn, model)
984 }
985
986 pub fn prune_foreign_vectors(&self, model: &str) -> Result<ForeignVectors> {
988 prune_foreign_vectors(&self.conn, model)
989 }
990
991 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 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 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 pub fn hybrid_search(&self, repo_id: &str, q: &HybridQuery) -> Result<Vec<HybridHit>> {
1022 hybrid_search(&self.conn, repo_id, q)
1023 }
1024
1025 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}