Skip to content

Retrieval and RAG

Virgile Thonnier edited this page Aug 29, 2026 · 1 revision

Retrieval & RAG

How a query becomes a ranked list of files — and how that same machinery grounds the chat assistant. This is the retrieval half of "RAG"; the generation half lives in AI Chat & Agent.

SenseTree does not do naive vector search. The pipeline is hybrid retrieval → rank fusion → cross-encoder reranking → per-file deduplication, with contextual enrichment applied back at indexing time.

The full pipeline

                    query (natural language)
                              │
              ┌───────────────┴───────────────┐
              ▼                               ▼
     DENSE  (embedding)                SPARSE  (BM25)
     cosine over `chunks`         full-text index on `text`
     scope: path LIKE prefix%      scope: path LIKE prefix%
              │                               │
              └───────────────┬───────────────┘
                              ▼
              Reciprocal Rank Fusion   (k = 60, per path+chunk)
                              ▼
              Cross-encoder rerank  (top ~3×limit, 800 chars each)
                              ▼
              Dedup per file (best chunk) + on-disk existence check
                              ▼
                     ranked results + snippets

Both retrieval legs are toggleable in Settings → Search (RAG): retrieval.hybrid and retrieval.rerank. Turning both off gives plain dense search, which is what earlier versions did.

1. Dense retrieval — meaning

The query is embedded with the same model that indexed your files (with the query: prefix for E5 models), then LanceDB returns the nearest chunks by cosine distance.

  • Candidate pool: clamp(limit × 6, 30, 160) chunks — generous on purpose, because everything downstream can only reorder what this stage found.
  • A scope restricts the query with a path-prefix filter, evaluated inside LanceDB.
  • Score reported is 1 − cosine_distance, clamped to [0, 1] and used raw. There is no cosmetic remapping from an arbitrary band: score distributions differ wildly between embedding models, so any fixed band would misreport confidence.

Dense retrieval is what finds a document about "the deadline for the tender" when the file says "submission cut-off". It is also what quietly fails on exact tokens — a serial number, IBAN FR76…, a person's surname, .blend. Which is why:

2. Sparse retrieval — exact words

The same query runs against LanceDB's native BM25 full-text index on the text column (the stored text, so it includes the qualification prepended to chunk #0).

  • The index is built lazily and rebuilt whenever new chunks were written since the last keyword search — a dirty flag set by every upsert, not a periodic job.
  • If the index doesn't exist yet (first search on a fresh table), it is built and the query retried once.
  • Failure is non-fatal: an unavailable BM25 index degrades the search to dense-only rather than erroring.

BM25 is what makes proper nouns, codes, extensions and rare tokens reliable.

3. Reciprocal Rank Fusion

Dense scores are cosines in [0, 1]; BM25 scores are unbounded relevance numbers. They are not comparable, and normalising them would just invent a comparison. RRF sidesteps the problem by ignoring the scores and using only the ranks:

score(chunk) = Σ  1 / (60 + rank_in_that_list)

k = 60 is the value from the literature. Fusion is keyed on (path, chunk_index), so a chunk found by both legs accumulates both contributions and rises above one found by a single leg — which is exactly the desired behaviour.

4. Cross-encoder reranking

The top clamp(limit × 3, 20, 100) fused candidates go to a cross-encoder: a model that reads (query, passage) together and outputs a relevance logit. Because it attends across both texts, it is far more accurate than comparing two independently produced vectors — and far too expensive to run over a whole index, which is why it only ever sees this shortlist.

  • Model: retrieval.reranker_model, default bge-reranker-v2-m3 (multilingual). Runs locally through fastembed/ONNX, cached like any other local model.
  • Each document is truncated to 800 characters before scoring. Cross-encoders truncate to ~512 tokens anyway; bounding it upstream cuts latency on large chunks with no measurable loss, since a chunk's opening is its most informative part.
  • The reranker's logit is unbounded, so the displayed score is its sigmoid — a readable 0–100 %.
  • Best-effort: if the model can't be loaded, the RRF order is kept and the search still returns.

5. Deduplication and verification

Ranked chunks become ranked files:

  • one result per path — the best-scoring chunk wins, and carries its snippet;
  • paths that no longer exist on disk are dropped (the index can lag behind a deletion by seconds);
  • results are cut at limit, clamped to 1..100 (default 20).

Scoring, as displayed

Situation Score shown
Reranking on sigmoid(reranker logit)
Hybrid on, reranking off the chunk's cosine similarity, or its RRF share when it was found by BM25 only
Both off raw cosine similarity

Scores are comparable within one result list, not across models or across queries.

Scoping

Every search accepts an optional scope — a folder path. It becomes a path LIKE 'C:\...\%' filter applied inside LanceDB to both retrieval legs, so "where in this project did I mention the deadline?" costs nothing extra and returns nothing from elsewhere on disk.

Path matching is Windows-aware: separators are normalised and LIKE metacharacters escaped, so a folder named 100%_backup doesn't turn into a wildcard.

The meaning tree

A second view over the same index: instead of a flat list, a tree of the scope where each node carries a relevance heat.

  • Dense retrieval only (up to 400 chunks by default, clamped 10..1000), no BM25, no rerank — this is a map, not a precision instrument.
  • The best score per existing file is kept, then propagated upward: a folder's score is the maximum of its descendants'.
  • Children are sorted by score, so the eye follows the strongest branch down to the file.

It answers a different question from search: not "which file?" but "which part of my tree is about this?".

Retrieval in the chat

The assistant uses the exact same run_semantic_search in two places:

  1. Pre-RAG seeding — before the model is even called, the last user message is searched (top 6, honouring the current scope) and the excerpts are injected into the system prompt. This grounds the answer immediately, and is what makes the chat work at all with models that cannot call tools.
  2. The search_files tool — the agent can search again, as many times as it needs, with its own query and its own scope (top 8 per call). Every result is folded into the response's sources, so citations stay clickable.

Why a file might not come back

In rough order of likelihood:

  • It isn't indexed yet. The Explorer shows a per-file status; the queue modal shows what is pending or failed.
  • It's inside a block folder. Block folders are indexed as a single unit and their contents are deliberately not searchable individually — see Indexing Pipeline.
  • The embedding endpoint was down when it was processed. Vectors and summaries are only written after a successful embedding. Fix the endpoint, then re-index the path.
  • You changed the embedding model. That wipes the vector store and re-indexes everything; results return progressively.
  • Your query is a filename, not a meaning. Hybrid search covers this well, but describing the content still works better: "quarterly revenue chart" beats "q3.xlsx".

Tuning

Knob Where Effect
retrieval.hybrid Settings → Search (RAG) Off = dense only. Loses exact-token robustness.
retrieval.rerank Settings → Search (RAG) Off = faster, no local cross-encoder loaded, noticeably lower precision.
retrieval.reranker_model Settings → Search (RAG) bge-reranker-v2-m3 (multilingual, best default), bge-reranker-base (lighter, English-leaning), jina-reranker-v2-base-multilingual.
indexing.chunk_size / overlap Settings → Embedding Granularity of what can be retrieved. Changing these only affects files indexed afterwards.
Qualification toggles Settings → Sense qualification Turning them off removes the LLM-written summaries that chunk #0 and the contextual prefixes rely on — cheaper indexing, weaker retrieval.

Clone this wiki locally