Fase B6: temporal relevance decay and vector-based "similar pages"
- combine-results now applies a bounded (never below 0.5x) exponential decay based on :block/updated-at, so more recently touched blocks/pages rank slightly higher for otherwise-equal matches. - New search/similar-blocks reuses the existing zvec index: fetch a block's own already-indexed embedding (new :fetch capability on open-vector-index, via zvec's fetchSync) and re-query the index with it instead of embedding a fresh query string. - Exposed as a :similar-pages render-resource (worker/handler/render-resource/similar.cljs), consistent with how linked/unlinked references are already wired, plus a raw :thread-api/search-similar-blocks for non-UI callers. - New "Similar pages" section on the page view, above the existing linked/unlinked references.
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
[frontend.worker.handler.render-resource.common :as common]
|
||||
[frontend.worker.handler.render-resource.property :as property]
|
||||
[frontend.worker.handler.render-resource.query :as query]
|
||||
[frontend.worker.handler.render-resource.similar :as similar]
|
||||
[frontend.worker.handler.render-resource.view :as view]
|
||||
[frontend.worker.state :as worker-state]))
|
||||
|
||||
@@ -14,6 +15,7 @@
|
||||
property/resource-renderers
|
||||
view/resource-renderers
|
||||
query/resource-renderers
|
||||
similar/resource-renderers
|
||||
{:block-sync-conflicts
|
||||
(common/renderer nil
|
||||
(fn [_db resource-key _runtime]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
(ns frontend.worker.handler.render-resource.similar
|
||||
"\"Similar to this\" renderer resource, backed by the vector search index."
|
||||
(:require [frontend.worker.handler.render-resource.common :as common]
|
||||
[frontend.worker.search :as search]
|
||||
[frontend.worker.state :as worker-state]))
|
||||
|
||||
(def ^:private max-similar-pages 5)
|
||||
|
||||
(defn- similar-pages
|
||||
[db resource-key {:keys [repo]}]
|
||||
(let [block-uuid (second resource-key)]
|
||||
(common/entity-by-uuid! db :block-uuid block-uuid)
|
||||
(let [vector-index (worker-state/get-vector-index repo)
|
||||
results (when vector-index
|
||||
(search/similar-blocks (atom db) vector-index block-uuid
|
||||
{:limit max-similar-pages}))]
|
||||
[#{} (mapv #(select-keys % [:db/id :block/uuid :block/title
|
||||
:block.temp/unique-title :page? :block/page])
|
||||
(or results []))])))
|
||||
|
||||
(def resource-renderers
|
||||
{:similar-pages (common/renderer 2 similar-pages)})
|
||||
@@ -286,6 +286,11 @@
|
||||
[repo q option]
|
||||
(<search-blocks repo q option))
|
||||
|
||||
(def-thread-api :thread-api/search-similar-blocks
|
||||
[repo block-uuid option]
|
||||
(when-let [conn (worker-state/get-datascript-conn repo)]
|
||||
(search/similar-blocks conn (worker-state/get-vector-index repo) block-uuid option)))
|
||||
|
||||
(def-thread-api :thread-api/search-upsert-blocks
|
||||
[repo blocks]
|
||||
(when-let [db (get-search-db repo)]
|
||||
|
||||
@@ -418,6 +418,19 @@
|
||||
(or (nil? page)
|
||||
(>= (count results) limit)))
|
||||
|
||||
(defn- fetch-zvec-doc
|
||||
[collection ids]
|
||||
(let [result (.fetchSync ^js collection (clj->js (vec ids)))]
|
||||
(into {}
|
||||
(keep (fn [id]
|
||||
(when-let [doc (gobj/get result id)]
|
||||
(let [vectors (gobj/get doc "vectors")
|
||||
fields (gobj/get doc "fields")]
|
||||
[id {:embedding (js->clj (gobj/get vectors zvec-vector-field))
|
||||
:page (gobj/get fields zvec-page-field)
|
||||
:vector-title (gobj/get fields zvec-title-field)}]))))
|
||||
ids)))
|
||||
|
||||
(defn- open-vector-index
|
||||
[{:keys [path dimension]}]
|
||||
(p/let [_ (ensure-dir! (node-path/dirname path))]
|
||||
@@ -449,6 +462,8 @@
|
||||
:delete! (fn [ids]
|
||||
(.deleteSync ^js @collection* (clj->js (vec ids)))
|
||||
nil)
|
||||
:fetch (fn [ids]
|
||||
(fetch-zvec-doc @collection* ids))
|
||||
:truncate! (fn []
|
||||
(.destroySync ^js @collection*)
|
||||
(reopen!)
|
||||
|
||||
@@ -158,6 +158,9 @@ DROP TRIGGER IF EXISTS blocks_au;
|
||||
(def ^:private title-term-match-boost 0.002)
|
||||
(def ^:private context-term-match-boost 0.0005)
|
||||
(def ^:private max-vector-term-match-boost 0.005)
|
||||
(def ^:private temporal-half-life-ms (* 60 24 60 60 1000)) ;; 60 days
|
||||
(def ^:private min-temporal-decay 0.5) ;; never discount a strong match by more than half
|
||||
(def ^:private max-similar-block-results 10)
|
||||
|
||||
(defn- query->terms
|
||||
[q]
|
||||
@@ -602,6 +605,7 @@ DROP TRIGGER IF EXISTS blocks_au;
|
||||
'[:db/id
|
||||
:block/uuid
|
||||
:block/title
|
||||
:block/updated-at
|
||||
{:block/page [:block/uuid :block/name :block/title]}
|
||||
{:block/parent [:db/id :block/uuid :block/title :logseq.property/built-in?
|
||||
:logseq.property/hide? :logseq.property/deleted-at]}
|
||||
@@ -743,6 +747,16 @@ DROP TRIGGER IF EXISTS blocks_au;
|
||||
(min max-vector-term-match-boost score))
|
||||
0))
|
||||
|
||||
(defn- temporal-decay
|
||||
"Recently updated blocks are ranked slightly higher. Bounded at
|
||||
`min-temporal-decay` so an old-but-strong match never gets crushed by a
|
||||
recent-but-weak one, and unset `:block/updated-at` decays to no-op (1.0)."
|
||||
[now updated-at]
|
||||
(if (number? updated-at)
|
||||
(let [age-ms (max 0 (- now updated-at))]
|
||||
(max min-temporal-decay (js/Math.pow 0.5 (/ age-ms temporal-half-life-ms))))
|
||||
1.0))
|
||||
|
||||
;; Combine and re-rank keyword and vector results
|
||||
(defn combine-results
|
||||
([db keyword-results]
|
||||
@@ -753,6 +767,7 @@ DROP TRIGGER IF EXISTS blocks_au;
|
||||
(let [keyword-results (vec (or keyword-results []))
|
||||
vector-results (vec (or vector-results []))
|
||||
use-rrf? (seq vector-results)
|
||||
now (js/Date.now)
|
||||
fused-score-by-id (when use-rrf?
|
||||
(rrf-score-by-id [keyword-results vector-results]
|
||||
[keyword-rrf-weight vector-rrf-weight]))
|
||||
@@ -773,19 +788,20 @@ DROP TRIGGER IF EXISTS blocks_au;
|
||||
vector-match-score (if use-rrf?
|
||||
(vector-term-match-score q result block)
|
||||
0)
|
||||
combined-score (+ base-score
|
||||
vector-match-score
|
||||
(* source-score-tie-break-weight
|
||||
(+ keyword-score vector-score))
|
||||
(if-not use-rrf?
|
||||
(cond
|
||||
page?
|
||||
0.02
|
||||
(:block/tags block)
|
||||
0.01
|
||||
:else
|
||||
0)
|
||||
0))]
|
||||
combined-score (* (+ base-score
|
||||
vector-match-score
|
||||
(* source-score-tie-break-weight
|
||||
(+ keyword-score vector-score))
|
||||
(if-not use-rrf?
|
||||
(cond
|
||||
page?
|
||||
0.02
|
||||
(:block/tags block)
|
||||
0.01
|
||||
:else
|
||||
0)
|
||||
0))
|
||||
(temporal-decay now (:block/updated-at block)))]
|
||||
(conj acc
|
||||
(assoc result
|
||||
search-result-block-key block
|
||||
@@ -1022,6 +1038,28 @@ DROP TRIGGER IF EXISTS blocks_au;
|
||||
:matched-count matched-count}
|
||||
(take limit result))))))
|
||||
|
||||
(defn similar-blocks
|
||||
"Blocks/pages whose already-indexed embedding is closest to the given
|
||||
block's own embedding, i.e. \"similar to this\" rather than a fresh query."
|
||||
[conn vector-index block-uuid option]
|
||||
(when-let [fetch-fn (:fetch vector-index)]
|
||||
(let [id (str block-uuid)
|
||||
source (get (fetch-fn [id]) id)]
|
||||
(when (seq (:embedding source))
|
||||
(let [limit (min max-similar-block-results (or (:limit option) max-similar-block-results))
|
||||
raw-result (vector-search-blocks vector-index
|
||||
{:limit (inc limit)
|
||||
:page (:page option)
|
||||
:query-embedding (:embedding source)})
|
||||
candidates (remove #(= (:id %) id) raw-result)
|
||||
combined-result (combine-results @conn nil candidates)]
|
||||
(->> combined-result
|
||||
(common-util/distinct-by :id)
|
||||
(keep #(search-result->block-result conn nil nil option %))
|
||||
(remove #(= (:block/uuid %) block-uuid))
|
||||
(take limit)
|
||||
vec))))))
|
||||
|
||||
(defn upsert-vector-blocks!
|
||||
[vector-index blocks]
|
||||
(when-let [upsert-fn (:upsert! vector-index)]
|
||||
|
||||
Reference in New Issue
Block a user