Skip to main content

lean_ctx/core/
dense_backend.rs

1#[cfg(feature = "embeddings")]
2use std::path::Path;
3
4#[cfg(feature = "embeddings")]
5use crate::core::bm25_index::BM25Index;
6#[cfg(any(feature = "qdrant", feature = "pgvector"))]
7use crate::core::bm25_index::ChunkKind;
8#[cfg(feature = "embeddings")]
9use crate::core::hnsw::FlatEmbeddings;
10#[cfg(feature = "embeddings")]
11use crate::core::hybrid_search::{DenseSearchResult, HybridConfig, HybridResult};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum DenseBackendKind {
15    Local,
16    #[cfg(feature = "qdrant")]
17    Qdrant,
18    #[cfg(feature = "pgvector")]
19    Pgvector,
20}
21
22impl DenseBackendKind {
23    pub fn try_from_env() -> Result<Self, String> {
24        let explicit = std::env::var("LEANCTX_DENSE_BACKEND")
25            .ok()
26            .map(|v| v.trim().to_ascii_lowercase())
27            .filter(|v| !v.is_empty());
28
29        let inferred_qdrant =
30            std::env::var("LEANCTX_QDRANT_URL").is_ok_and(|v| !v.trim().is_empty());
31        let inferred_pgvector =
32            std::env::var("LEANCTX_PGVECTOR_URL").is_ok_and(|v| !v.trim().is_empty());
33
34        let requested = explicit
35            .or_else(|| inferred_qdrant.then_some("qdrant".to_string()))
36            .or_else(|| inferred_pgvector.then_some("pgvector".to_string()));
37
38        match requested.as_deref() {
39            None | Some("local") => Ok(Self::Local),
40            Some("qdrant") => {
41                #[cfg(feature = "qdrant")]
42                {
43                    Ok(Self::Qdrant)
44                }
45                #[cfg(not(feature = "qdrant"))]
46                {
47                    Err("Dense backend 'qdrant' requested, but feature 'qdrant' is not enabled. Rebuild with --features qdrant.".to_string())
48                }
49            }
50            Some("pgvector") => {
51                #[cfg(feature = "pgvector")]
52                {
53                    Ok(Self::Pgvector)
54                }
55                #[cfg(not(feature = "pgvector"))]
56                {
57                    Err("Dense backend 'pgvector' requested, but feature 'pgvector' is not enabled. Rebuild with --features pgvector.".to_string())
58                }
59            }
60            Some(other) => Err(format!(
61                "Unknown LEANCTX_DENSE_BACKEND={other:?} (expected 'local', 'qdrant' or 'pgvector')"
62            )),
63        }
64    }
65
66    pub fn label(&self) -> &'static str {
67        match self {
68            Self::Local => "local",
69            #[cfg(feature = "qdrant")]
70            Self::Qdrant => "qdrant",
71            #[cfg(feature = "pgvector")]
72            Self::Pgvector => "pgvector",
73        }
74    }
75}
76
77#[cfg(feature = "embeddings")]
78#[allow(clippy::too_many_arguments)]
79pub fn dense_results_as_hybrid(
80    backend: DenseBackendKind,
81    root: &Path,
82    index: &BM25Index,
83    engine: &crate::core::embeddings::EmbeddingEngine,
84    aligned_embeddings: &FlatEmbeddings,
85    changed_files: &[String],
86    query: &str,
87    top_k: usize,
88    filter: Option<&dyn Fn(&str) -> bool>,
89) -> Result<Vec<HybridResult>, String> {
90    // Pack and role pinned for the whole search; a change while it ran
91    // returns nothing (`source_gate::pinned`).
92    let dense =
93        crate::core::bm25_index::source_gate::pinned(gate_root(backend, root, index), || {
94            dense_results(
95                backend,
96                root,
97                index,
98                engine,
99                aligned_embeddings,
100                changed_files,
101                query,
102                top_k,
103                filter,
104            )
105        })
106        .unwrap_or_else(|| Ok(Vec::new()))?;
107
108    Ok(dense
109        .into_iter()
110        .map(|d| HybridResult {
111            file_path: d.file_path,
112            symbol_name: d.symbol_name,
113            kind: d.kind,
114            start_line: d.start_line,
115            end_line: d.end_line,
116            snippet: d.snippet,
117            rrf_score: d.similarity as f64,
118            bm25_score: None,
119            dense_score: Some(d.similarity),
120            bm25_rank: None,
121            dense_rank: None,
122        })
123        .collect())
124}
125
126/// The root whose gate a search of `backend` decides under: the root the
127/// index was handed out for (none for a view built in the running call), and
128/// for a remote backend always the project root its rows belong to.
129#[cfg(feature = "embeddings")]
130fn gate_root<'a>(
131    backend: DenseBackendKind,
132    root: &'a Path,
133    index: &'a BM25Index,
134) -> Option<&'a Path> {
135    if matches!(backend, DenseBackendKind::Local) {
136        index.source_root.as_deref()
137    } else {
138        Some(root)
139    }
140}
141
142/// Lexical + dense search with fusion. Pack and role are pinned for the
143/// whole search — both candidate lists and every gate decision — and a
144/// change of either while it ran returns nothing (`source_gate::pinned`).
145#[cfg(feature = "embeddings")]
146#[allow(clippy::too_many_arguments)]
147pub fn hybrid_results(
148    backend: DenseBackendKind,
149    root: &Path,
150    index: &BM25Index,
151    engine: &crate::core::embeddings::EmbeddingEngine,
152    aligned_embeddings: &FlatEmbeddings,
153    changed_files: &[String],
154    query: &str,
155    top_k: usize,
156    config: &HybridConfig,
157    filter: Option<&dyn Fn(&str) -> bool>,
158    graph_file_ranks: Option<&std::collections::HashMap<String, usize>>,
159) -> Result<Vec<HybridResult>, String> {
160    crate::core::bm25_index::source_gate::pinned(gate_root(backend, root, index), || {
161        hybrid_results_pinned(
162            backend,
163            root,
164            index,
165            engine,
166            aligned_embeddings,
167            changed_files,
168            query,
169            top_k,
170            config,
171            filter,
172            graph_file_ranks,
173        )
174    })
175    .unwrap_or_else(|| Ok(Vec::new()))
176}
177
178#[cfg(feature = "embeddings")]
179#[allow(clippy::too_many_arguments)]
180fn hybrid_results_pinned(
181    backend: DenseBackendKind,
182    root: &Path,
183    index: &BM25Index,
184    engine: &crate::core::embeddings::EmbeddingEngine,
185    aligned_embeddings: &FlatEmbeddings,
186    changed_files: &[String],
187    query: &str,
188    top_k: usize,
189    config: &HybridConfig,
190    filter: Option<&dyn Fn(&str) -> bool>,
191    graph_file_ranks: Option<&std::collections::HashMap<String, usize>>,
192) -> Result<Vec<HybridResult>, String> {
193    match backend {
194        DenseBackendKind::Local => {
195            let _ = (root, changed_files);
196            let mut results = crate::core::hybrid_search::hybrid_search(
197                query,
198                index,
199                Some(engine),
200                Some(aligned_embeddings),
201                top_k,
202                config,
203                graph_file_ranks,
204            );
205            if let Some(pred) = filter {
206                results.retain(|r| pred(&r.file_path));
207            }
208            results.truncate(top_k);
209            Ok(results)
210        }
211        #[cfg(feature = "qdrant")]
212        DenseBackendKind::Qdrant => remote_hybrid_results(
213            backend,
214            root,
215            index,
216            engine,
217            aligned_embeddings,
218            changed_files,
219            query,
220            top_k,
221            config,
222            filter,
223            graph_file_ranks,
224        ),
225        #[cfg(feature = "pgvector")]
226        DenseBackendKind::Pgvector => remote_hybrid_results(
227            backend,
228            root,
229            index,
230            engine,
231            aligned_embeddings,
232            changed_files,
233            query,
234            top_k,
235            config,
236            filter,
237            graph_file_ranks,
238        ),
239    }
240}
241
242/// Shared BM25+dense RRF pipeline for remote vector backends (qdrant, pgvector):
243/// the backends differ only in where `dense_results` fetches from.
244#[cfg(all(feature = "embeddings", any(feature = "qdrant", feature = "pgvector")))]
245#[allow(clippy::too_many_arguments)]
246fn remote_hybrid_results(
247    backend: DenseBackendKind,
248    root: &Path,
249    index: &BM25Index,
250    engine: &crate::core::embeddings::EmbeddingEngine,
251    aligned_embeddings: &FlatEmbeddings,
252    changed_files: &[String],
253    query: &str,
254    top_k: usize,
255    config: &HybridConfig,
256    filter: Option<&dyn Fn(&str) -> bool>,
257    graph_file_ranks: Option<&std::collections::HashMap<String, usize>>,
258) -> Result<Vec<HybridResult>, String> {
259    let bm25_k = config.bm25_candidates.max(top_k);
260    let dense_k = config.dense_candidates.max(top_k);
261
262    let mut bm25 = index.search(query, bm25_k);
263    if let Some(pred) = filter {
264        bm25.retain(|r| pred(&r.file_path));
265    }
266
267    let dense = dense_results(
268        backend,
269        root,
270        index,
271        engine,
272        aligned_embeddings,
273        changed_files,
274        query,
275        dense_k,
276        filter,
277    )?;
278
279    let mut fused = crate::core::hybrid_search::reciprocal_rank_fusion(
280        &bm25,
281        &dense,
282        config,
283        top_k,
284        graph_file_ranks,
285    );
286    if let Some(pred) = filter {
287        fused.retain(|r| pred(&r.file_path));
288    }
289    fused.truncate(top_k);
290    Ok(fused)
291}
292
293#[cfg(feature = "embeddings")]
294#[allow(clippy::too_many_arguments)]
295fn dense_results(
296    backend: DenseBackendKind,
297    root: &Path,
298    index: &BM25Index,
299    engine: &crate::core::embeddings::EmbeddingEngine,
300    aligned_embeddings: &FlatEmbeddings,
301    changed_files: &[String],
302    query: &str,
303    top_k: usize,
304    filter: Option<&dyn Fn(&str) -> bool>,
305) -> Result<Vec<DenseSearchResult>, String> {
306    match backend {
307        DenseBackendKind::Local => {
308            let _ = (root, changed_files);
309            dense_results_local(index, engine, aligned_embeddings, query, top_k, filter)
310        }
311        #[cfg(feature = "qdrant")]
312        DenseBackendKind::Qdrant => {
313            let vecs: Vec<Vec<f32>> = (0..aligned_embeddings.n_vectors())
314                .map(|i| aligned_embeddings.get_vec(i))
315                .collect();
316            dense_results_qdrant(
317                root,
318                index,
319                engine,
320                &vecs,
321                changed_files,
322                query,
323                top_k,
324                filter,
325            )
326        }
327        #[cfg(feature = "pgvector")]
328        DenseBackendKind::Pgvector => {
329            let vecs: Vec<Vec<f32>> = (0..aligned_embeddings.n_vectors())
330                .map(|i| aligned_embeddings.get_vec(i))
331                .collect();
332            dense_results_pgvector(
333                root,
334                index,
335                engine,
336                &vecs,
337                changed_files,
338                query,
339                top_k,
340                filter,
341            )
342        }
343    }
344}
345
346#[cfg(feature = "embeddings")]
347fn dense_results_local(
348    index: &BM25Index,
349    engine: &crate::core::embeddings::EmbeddingEngine,
350    aligned_embeddings: &FlatEmbeddings,
351    query: &str,
352    top_k: usize,
353    filter: Option<&dyn Fn(&str) -> bool>,
354) -> Result<Vec<DenseSearchResult>, String> {
355    let query_embedding = engine
356        .embed_query(query)
357        .map_err(|e| format!("embedding failed: {e}"))?;
358
359    // Serve-time source gate (G04): the vector of a withdrawn source may still
360    // be the nearest one, but its chunk is not served. A gated search fetches
361    // a bounded surplus and cuts after the gate, so such a chunk neither
362    // occupies a slot nor moves the cut.
363    let mut gate = index.source_gate();
364    let fetch = if gate.is_some() {
365        crate::core::bm25_index::source_gate::overfetch(top_k)
366    } else {
367        top_k
368    };
369    let top = top_k_by_similarity(&query_embedding, aligned_embeddings, fetch, |i| {
370        let Some(pred) = filter else { return true };
371        index.chunks.get(i).is_some_and(|c| pred(&c.file_path))
372    });
373
374    Ok(top
375        .into_iter()
376        .filter_map(|(idx, sim)| {
377            let chunk = index.chunks.get(idx)?;
378            if !crate::core::bm25_index::source_gate::chunk_served(&mut gate, chunk) {
379                return None;
380            }
381            let snippet = chunk.content.lines().take(5).collect::<Vec<_>>().join("\n");
382            Some(DenseSearchResult {
383                chunk_idx: idx,
384                similarity: sim,
385                file_path: chunk.file_path.clone(),
386                symbol_name: chunk.symbol_name.clone(),
387                kind: chunk.kind.clone(),
388                start_line: chunk.start_line,
389                end_line: chunk.end_line,
390                snippet,
391            })
392        })
393        .take(top_k)
394        .collect())
395}
396
397/// Min-heap based Top-K selection over a flat embedding buffer.
398/// O(n log k) instead of O(n log n) full sort. The filter is applied inline
399/// during the scan so only matching chunks are considered — post-filtering
400/// cannot drop below `top_k` results regardless of filter selectivity.
401///
402/// Uses sequential memory access (one dereference) via `FlatEmbeddings::get`,
403/// unlike the old `Arc<[Vec<f32>]>` layout which had two-level indirection.
404#[cfg(feature = "embeddings")]
405fn top_k_by_similarity(
406    query: &[f32],
407    embeddings: &FlatEmbeddings,
408    k: usize,
409    filter: impl Fn(usize) -> bool,
410) -> Vec<(usize, f32)> {
411    use std::cmp::Ordering;
412    use std::collections::BinaryHeap;
413
414    #[derive(PartialEq)]
415    struct MinEntry(f32, usize);
416
417    impl Eq for MinEntry {}
418    impl PartialOrd for MinEntry {
419        fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
420            Some(self.cmp(other))
421        }
422    }
423    impl Ord for MinEntry {
424        fn cmp(&self, other: &Self) -> Ordering {
425            other
426                .0
427                .partial_cmp(&self.0)
428                .unwrap_or(Ordering::Equal)
429                .then_with(|| self.1.cmp(&other.1))
430        }
431    }
432
433    let n = embeddings.n_vectors();
434    let mut heap: BinaryHeap<MinEntry> = BinaryHeap::with_capacity(k + 1);
435
436    for i in 0..n {
437        if !filter(i) {
438            continue;
439        }
440        let emb = embeddings.get(i);
441        let sim = crate::core::embeddings::cosine_similarity(query, emb);
442        if heap.len() < k {
443            heap.push(MinEntry(sim, i));
444        } else if let Some(min) = heap.peek()
445            && sim > min.0
446        {
447            heap.pop();
448            heap.push(MinEntry(sim, i));
449        }
450    }
451
452    let mut result: Vec<(usize, f32)> = heap.into_iter().map(|e| (e.1, e.0)).collect();
453    result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
454    result
455}
456
457#[cfg(feature = "qdrant")]
458#[cfg(feature = "embeddings")]
459fn dense_results_qdrant(
460    root: &Path,
461    index: &BM25Index,
462    engine: &crate::core::embeddings::EmbeddingEngine,
463    aligned_embeddings: &[Vec<f32>],
464    changed_files: &[String],
465    query: &str,
466    top_k: usize,
467    filter: Option<&dyn Fn(&str) -> bool>,
468) -> Result<Vec<DenseSearchResult>, String> {
469    let store = crate::core::qdrant_store::QdrantStore::from_env()?;
470    let collection = store.collection_name(root, engine.dimensions())?;
471    let created_new = store.ensure_collection(&collection, engine.dimensions())?;
472    store.sync_index(
473        &collection,
474        index,
475        aligned_embeddings,
476        changed_files,
477        created_new,
478    )?;
479
480    let query_vec = engine
481        .embed_query(query)
482        .map_err(|e| format!("embedding failed: {e}"))?;
483
484    // A row of a source the admitted reader no longer returns is not served,
485    // path and symbol name included (G04). Where a source authority is
486    // required the backend ranks admitted sources only (`remote_candidates`).
487    let mut gate = crate::core::bm25_index::source_gate::SourceGate::new(root, "ctx_search")
488        .with_origins(&index.provider_origins);
489    let hits = remote_candidates(
490        &mut gate,
491        index,
492        query,
493        top_k,
494        |limit, sources| match sources {
495            Some(sources) => store.search_within(&collection, &query_vec, limit, sources),
496            None => store.search(&collection, &query_vec, limit),
497        },
498    )?;
499    let mut out = Vec::with_capacity(top_k.min(hits.len()));
500    let mut undigested = Vec::new();
501    for hit in hits {
502        if out.len() >= top_k {
503            break;
504        }
505        if let Some(pred) = filter
506            && !pred(&hit.file_path)
507        {
508            continue;
509        }
510        let row = RemoteRow {
511            source: &hit.file_path,
512            start_line: hit.start_line,
513            end_line: hit.end_line,
514            content_digest: hit.content_digest.as_deref(),
515        };
516        let Some(snippet) = remote_row_snippet(&mut gate, index, root, &row, &mut undigested)
517        else {
518            continue;
519        };
520        out.push(DenseSearchResult {
521            chunk_idx: 0,
522            similarity: hit.score,
523            file_path: hit.file_path,
524            symbol_name: hit.symbol_name,
525            kind: hit.kind,
526            start_line: hit.start_line,
527            end_line: hit.end_line,
528            snippet,
529        });
530    }
531    forget_deleted_sources(root, &gate, |gone| {
532        store.sync_index(&collection, &BM25Index::new(), &[], gone, false)
533    });
534    resync_undigested(undigested, |sources| {
535        store.sync_index(&collection, index, aligned_embeddings, sources, false)
536    });
537    Ok(out)
538}
539
540#[cfg(feature = "pgvector")]
541#[cfg(feature = "embeddings")]
542#[allow(clippy::too_many_arguments)]
543fn dense_results_pgvector(
544    root: &Path,
545    index: &BM25Index,
546    engine: &crate::core::embeddings::EmbeddingEngine,
547    aligned_embeddings: &[Vec<f32>],
548    changed_files: &[String],
549    query: &str,
550    top_k: usize,
551    filter: Option<&dyn Fn(&str) -> bool>,
552) -> Result<Vec<DenseSearchResult>, String> {
553    let store = crate::core::pgvector_store::PgvectorStore::from_env()?;
554    let table = store.table_name(root, engine.dimensions())?;
555    let created_new = store.ensure_table(&table, engine.dimensions())?;
556    store.sync_index(
557        &table,
558        index,
559        aligned_embeddings,
560        changed_files,
561        created_new,
562    )?;
563
564    let query_vec = engine
565        .embed_query(query)
566        .map_err(|e| format!("embedding failed: {e}"))?;
567
568    // As `dense_results_qdrant`: admitted sources only where a source
569    // authority is required, a bounded surplus cut after the gate otherwise.
570    let mut gate = crate::core::bm25_index::source_gate::SourceGate::new(root, "ctx_search")
571        .with_origins(&index.provider_origins);
572    let hits = remote_candidates(
573        &mut gate,
574        index,
575        query,
576        top_k,
577        |limit, sources| match sources {
578            Some(sources) => store.search_within(&table, &query_vec, limit, sources),
579            None => store.search(&table, &query_vec, limit),
580        },
581    )?;
582    let mut out = Vec::with_capacity(top_k.min(hits.len()));
583    let mut undigested = Vec::new();
584    for hit in hits {
585        if out.len() >= top_k {
586            break;
587        }
588        if let Some(pred) = filter
589            && !pred(&hit.file_path)
590        {
591            continue;
592        }
593        let row = RemoteRow {
594            source: &hit.file_path,
595            start_line: hit.start_line,
596            end_line: hit.end_line,
597            content_digest: hit.content_digest.as_deref(),
598        };
599        let Some(snippet) = remote_row_snippet(&mut gate, index, root, &row, &mut undigested)
600        else {
601            continue;
602        };
603        out.push(DenseSearchResult {
604            chunk_idx: 0,
605            similarity: hit.score,
606            file_path: hit.file_path,
607            symbol_name: hit.symbol_name,
608            kind: hit.kind,
609            start_line: hit.start_line,
610            end_line: hit.end_line,
611            snippet,
612        });
613    }
614    forget_deleted_sources(root, &gate, |gone| {
615        store.sync_index(&table, &BM25Index::new(), &[], gone, false)
616    });
617    resync_undigested(undigested, |sources| {
618        store.sync_index(&table, index, aligned_embeddings, sources, false)
619    });
620    Ok(out)
621}
622
623/// The rows a remote dense search is served from, best first.
624///
625/// Without protection (Community developer mode): the backend's nearest
626/// rows with a bounded surplus; the caller gates them and cuts afterwards —
627/// today's behaviour.
628///
629/// Where a source authority is required the backend must not rank what may
630/// not be served, and no unrestricted request is sent at all. The admitted
631/// sources are decided FIRST, from what the Engine holds locally — the
632/// sources of `index`, the index the remote table is synchronised from —
633/// through the gate, in the deterministic bounded order of
634/// `BM25Index::admitted_sources` (lexical candidates of the query, then the
635/// rest by path, at most the gate's bound). The ONE request is then
636/// restricted, in the backend, to those sources (`search_within`: a payload
637/// filter in Qdrant, a bound variable in PostgreSQL, on the `file_path`
638/// every row has carried since the tables exist). Rows of refused,
639/// undecided or locally unknown sources are not ranked: no score, no place
640/// before the limit.
641///
642/// No admitted source: no request, nothing served. A restriction the
643/// backend cannot take (too large to bind) is an error, never an unfiltered
644/// ranking. A corpus of more sources than the bound is searched in its
645/// first sources of that order only (truncated closed). Cost under
646/// protection: up to the bound in admitted source reads before the request
647/// (the lexical search of the same call decides through its own gate).
648#[cfg(all(feature = "embeddings", any(feature = "qdrant", feature = "pgvector")))]
649fn remote_candidates<Hit>(
650    gate: &mut crate::core::bm25_index::source_gate::SourceGate,
651    index: &BM25Index,
652    query: &str,
653    top_k: usize,
654    search: impl Fn(usize, Option<&[String]>) -> Result<Vec<Hit>, String>,
655) -> Result<Vec<Hit>, String> {
656    let fetch = crate::core::bm25_index::source_gate::overfetch(top_k);
657    if !gate.protected() {
658        return search(fetch, None);
659    }
660    let admitted = index.admitted_sources(query, gate);
661    if admitted.is_empty() {
662        return Ok(Vec::new());
663    }
664    search(fetch, Some(&admitted))
665}
666
667/// What a remote row says about the chunk it was written for.
668#[cfg(any(feature = "qdrant", feature = "pgvector"))]
669struct RemoteRow<'a> {
670    source: &'a str,
671    start_line: usize,
672    end_line: usize,
673    /// `external_origin::chunk_digest` of the chunk, on rows written since
674    /// the stores keep it.
675    content_digest: Option<&'a str>,
676}
677
678/// The snippet of a remote row that may be served now; `None` when it may
679/// not.
680///
681/// A row of a project file is served while the admitted reader still
682/// returns the lines it names. A row of an external chunk (`scheme://…`)
683/// has no file to read; where a source authority is required it is served
684/// only as the chunk the local index holds: the row's content digest must
685/// be the digest of a chunk of that source in `index`, and the gate must
686/// serve that chunk — for an origin bound to one text that is the digest in
687/// the origin on record. A row without a digest (written before the stores
688/// kept one) is not served there and its source is noted in `undigested`
689/// for re-synchronisation; it is never served unverified.
690#[cfg(any(feature = "qdrant", feature = "pgvector"))]
691fn remote_row_snippet(
692    gate: &mut crate::core::bm25_index::source_gate::SourceGate,
693    index: &BM25Index,
694    root: &Path,
695    row: &RemoteRow<'_>,
696    undigested: &mut Vec<String>,
697) -> Option<String> {
698    use crate::core::bm25_index::external_origin::chunk_digest;
699    if row.source.contains("://") && gate.protected() {
700        let Some(digest) = row.content_digest else {
701            undigested.push(row.source.to_owned());
702            return None;
703        };
704        let chunk = index.chunks.iter().find(|chunk| {
705            chunk.file_path == row.source && chunk_digest(&chunk.content) == digest
706        })?;
707        return gate
708            .serves_with(
709                &chunk.file_path,
710                &chunk.content,
711                chunk.input_origin.as_ref(),
712            )
713            .then(|| chunk.content.lines().take(5).collect::<Vec<_>>().join("\n"));
714    }
715    let snippet = snippet_from_disk(root, row.source, row.start_line, row.end_line, 5);
716    gate.serves(row.source, &snippet).then_some(snippet)
717}
718
719/// Write the rows of external sources that were found without a content
720/// digest again, through the store's replace path (`sync_index` deletes the
721/// source's rows and upserts its current chunks, now with their digest).
722/// Bounded and best effort: until it succeeds those rows stay unserved.
723#[cfg(any(feature = "qdrant", feature = "pgvector"))]
724fn resync_undigested(
725    mut sources: Vec<String>,
726    resync: impl FnOnce(&[String]) -> Result<(), String>,
727) {
728    /// Sources one search writes again.
729    const MAX_RESYNCED_SOURCES: usize = 8;
730    sources.sort();
731    sources.dedup();
732    sources.truncate(MAX_RESYNCED_SOURCES);
733    if !sources.is_empty() && resync(&sources).is_err() {
734        tracing::debug!("[dense] rows without a content digest could not be written again");
735    }
736}
737
738/// Remove the rows of the sources `gate` refused that no longer exist from a
739/// remote table, through the store's own replace path (`sync_index` deletes a
740/// changed file's rows and upserts its chunks — none, for an empty index).
741/// Only a deleted source: admission is the reader's, the table is shared, so
742/// a refusal for this reader must not remove a row for another. Bounded and
743/// best effort — a failed delete leaves rows the gate keeps refusing.
744#[cfg(any(feature = "qdrant", feature = "pgvector"))]
745fn forget_deleted_sources(
746    root: &Path,
747    gate: &crate::core::bm25_index::source_gate::SourceGate,
748    delete: impl FnOnce(&[String]) -> Result<(), String>,
749) {
750    /// Deleted sources one search removes from the remote table.
751    const MAX_DELETED_SOURCES: usize = 8;
752    let mut gone: Vec<String> = gate
753        .refused_sources()
754        .filter(|source| !source.contains("://") && !root.join(source).exists())
755        .map(str::to_owned)
756        .collect();
757    gone.sort();
758    gone.truncate(MAX_DELETED_SOURCES);
759    if !gone.is_empty() && delete(&gone).is_err() {
760        tracing::debug!("[dense] rows of deleted sources could not be removed");
761    }
762}
763
764#[cfg(any(feature = "qdrant", feature = "pgvector"))]
765fn snippet_from_disk(
766    root: &Path,
767    rel_path: &str,
768    start_line: usize,
769    end_line: usize,
770    max_lines: usize,
771) -> String {
772    let Ok(path) = crate::core::pathjail::jail_path(&root.join(rel_path), root) else {
773        return String::new();
774    };
775    // Snippets are served to the model: admitted text only (G5).
776    let Some(content) = crate::core::context_admission::stores::admitted_store_text(&path) else {
777        return String::new();
778    };
779    let lines: Vec<&str> = content.lines().collect();
780    if lines.is_empty() {
781        return String::new();
782    }
783    let start = start_line.saturating_sub(1).min(lines.len());
784    let end = end_line.max(start_line).min(lines.len());
785    let mut slice = &lines[start..end];
786    if slice.len() > max_lines {
787        slice = &slice[..max_lines];
788    }
789    slice.join("\n")
790}
791
792#[cfg(any(feature = "qdrant", feature = "pgvector"))]
793pub fn kind_to_str(kind: &ChunkKind) -> &'static str {
794    match kind {
795        ChunkKind::Function => "Function",
796        ChunkKind::Struct => "Struct",
797        ChunkKind::Impl => "Impl",
798        ChunkKind::Module => "Module",
799        ChunkKind::Class => "Class",
800        ChunkKind::Method => "Method",
801        ChunkKind::Issue => "Issue",
802        ChunkKind::PullRequest => "PullRequest",
803        ChunkKind::WikiPage => "WikiPage",
804        ChunkKind::DbSchema => "DbSchema",
805        ChunkKind::ApiEndpoint => "ApiEndpoint",
806        ChunkKind::Ticket => "Ticket",
807        ChunkKind::ExternalOther => "ExternalOther",
808        ChunkKind::Other => "Other",
809    }
810}
811
812#[cfg(any(feature = "qdrant", feature = "pgvector"))]
813pub fn kind_from_str(s: &str) -> ChunkKind {
814    match s {
815        "Function" => ChunkKind::Function,
816        "Struct" => ChunkKind::Struct,
817        "Impl" => ChunkKind::Impl,
818        "Module" => ChunkKind::Module,
819        "Class" => ChunkKind::Class,
820        "Method" => ChunkKind::Method,
821        "Issue" => ChunkKind::Issue,
822        "PullRequest" => ChunkKind::PullRequest,
823        "WikiPage" => ChunkKind::WikiPage,
824        "DbSchema" => ChunkKind::DbSchema,
825        "ApiEndpoint" => ChunkKind::ApiEndpoint,
826        "Ticket" => ChunkKind::Ticket,
827        "ExternalOther" => ChunkKind::ExternalOther,
828        _ => ChunkKind::Other,
829    }
830}
831
832#[cfg(test)]
833pub mod tests {
834    use super::*;
835    fn set_env(key: &str, value: Option<&str>) -> Option<String> {
836        let old = std::env::var(key).ok();
837        match value {
838            Some(v) => crate::test_env::set_var(key, v),
839            None => crate::test_env::remove_var(key),
840        }
841        old
842    }
843
844    fn restore_env(key: &str, old: Option<String>) {
845        match old {
846            Some(v) => crate::test_env::set_var(key, v),
847            None => crate::test_env::remove_var(key),
848        }
849    }
850
851    #[test]
852    fn dense_backend_defaults_to_local() {
853        let _g = crate::core::data_dir::test_env_lock();
854        let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
855        let old_url = set_env("LEANCTX_QDRANT_URL", None);
856        let old_pg = set_env("LEANCTX_PGVECTOR_URL", None);
857
858        let got = DenseBackendKind::try_from_env().unwrap();
859        assert_eq!(got, DenseBackendKind::Local);
860
861        restore_env("LEANCTX_DENSE_BACKEND", old_backend);
862        restore_env("LEANCTX_QDRANT_URL", old_url);
863        restore_env("LEANCTX_PGVECTOR_URL", old_pg);
864    }
865
866    #[test]
867    fn dense_backend_unknown_value_errors() {
868        let _g = crate::core::data_dir::test_env_lock();
869        let old_backend = set_env("LEANCTX_DENSE_BACKEND", Some("wat"));
870        let old_url = set_env("LEANCTX_QDRANT_URL", None);
871
872        let err = DenseBackendKind::try_from_env().unwrap_err();
873        assert!(err.contains("Unknown LEANCTX_DENSE_BACKEND"));
874
875        restore_env("LEANCTX_DENSE_BACKEND", old_backend);
876        restore_env("LEANCTX_QDRANT_URL", old_url);
877    }
878
879    #[cfg(feature = "qdrant")]
880    #[test]
881    fn dense_backend_infers_qdrant_from_url() {
882        let _g = crate::core::data_dir::test_env_lock();
883        let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
884        let old_url = set_env("LEANCTX_QDRANT_URL", Some("http://127.0.0.1:6333"));
885        let old_pg = set_env("LEANCTX_PGVECTOR_URL", None);
886
887        let got = DenseBackendKind::try_from_env().unwrap();
888        assert_eq!(got, DenseBackendKind::Qdrant);
889
890        restore_env("LEANCTX_DENSE_BACKEND", old_backend);
891        restore_env("LEANCTX_QDRANT_URL", old_url);
892        restore_env("LEANCTX_PGVECTOR_URL", old_pg);
893    }
894
895    #[cfg(feature = "pgvector")]
896    #[test]
897    fn dense_backend_infers_pgvector_from_url() {
898        let _g = crate::core::data_dir::test_env_lock();
899        let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
900        let old_url = set_env("LEANCTX_QDRANT_URL", None);
901        let old_pg = set_env("LEANCTX_PGVECTOR_URL", Some("postgres://localhost/lctx"));
902
903        let got = DenseBackendKind::try_from_env().unwrap();
904        assert_eq!(got, DenseBackendKind::Pgvector);
905
906        restore_env("LEANCTX_DENSE_BACKEND", old_backend);
907        restore_env("LEANCTX_QDRANT_URL", old_url);
908        restore_env("LEANCTX_PGVECTOR_URL", old_pg);
909    }
910
911    #[cfg(all(feature = "qdrant", feature = "pgvector"))]
912    #[test]
913    fn dense_backend_qdrant_wins_when_both_urls_set() {
914        let _g = crate::core::data_dir::test_env_lock();
915        let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
916        let old_url = set_env("LEANCTX_QDRANT_URL", Some("http://127.0.0.1:6333"));
917        let old_pg = set_env("LEANCTX_PGVECTOR_URL", Some("postgres://localhost/lctx"));
918
919        let got = DenseBackendKind::try_from_env().unwrap();
920        assert_eq!(got, DenseBackendKind::Qdrant);
921
922        // Explicit selection overrides inference order.
923        crate::test_env::set_var("LEANCTX_DENSE_BACKEND", "pgvector");
924        let got = DenseBackendKind::try_from_env().unwrap();
925        assert_eq!(got, DenseBackendKind::Pgvector);
926
927        restore_env("LEANCTX_DENSE_BACKEND", old_backend);
928        restore_env("LEANCTX_QDRANT_URL", old_url);
929        restore_env("LEANCTX_PGVECTOR_URL", old_pg);
930    }
931
932    #[cfg(not(feature = "qdrant"))]
933    #[test]
934    fn dense_backend_qdrant_requires_feature() {
935        let _g = crate::core::data_dir::test_env_lock();
936        let old_backend = set_env("LEANCTX_DENSE_BACKEND", Some("qdrant"));
937        let old_url = set_env("LEANCTX_QDRANT_URL", None);
938
939        let err = DenseBackendKind::try_from_env().unwrap_err();
940        assert!(err.contains("feature 'qdrant' is not enabled"));
941
942        restore_env("LEANCTX_DENSE_BACKEND", old_backend);
943        restore_env("LEANCTX_QDRANT_URL", old_url);
944    }
945
946    #[cfg(not(feature = "pgvector"))]
947    #[test]
948    fn dense_backend_pgvector_requires_feature() {
949        let _g = crate::core::data_dir::test_env_lock();
950        let old_backend = set_env("LEANCTX_DENSE_BACKEND", Some("pgvector"));
951        let old_url = set_env("LEANCTX_QDRANT_URL", None);
952
953        let err = DenseBackendKind::try_from_env().unwrap_err();
954        assert!(err.contains("feature 'pgvector' is not enabled"));
955
956        restore_env("LEANCTX_DENSE_BACKEND", old_backend);
957        restore_env("LEANCTX_QDRANT_URL", old_url);
958    }
959}