secondbrain-py 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brain/__init__.py +0 -0
- brain/__main__.py +18 -0
- brain/_capture_command.py +445 -0
- brain/_compose.py +52 -0
- brain/activity.py +206 -0
- brain/ask.py +631 -0
- brain/audio.py +591 -0
- brain/backfill/__init__.py +12 -0
- brain/backfill/search_extras.py +141 -0
- brain/backfill/source_rows.py +101 -0
- brain/bin/__init__.py +1 -0
- brain/bin/_launcher.py +107 -0
- brain/bin/down.py +8 -0
- brain/bin/launchd.py +268 -0
- brain/bin/monitor.py +570 -0
- brain/bin/rebuild.py +8 -0
- brain/bin/status.py +8 -0
- brain/bin/up.py +8 -0
- brain/brief.py +272 -0
- brain/capture.py +49 -0
- brain/chat.py +293 -0
- brain/cli.py +9760 -0
- brain/cli_claude.py +81 -0
- brain/cli_connect.py +285 -0
- brain/cli_demo.py +266 -0
- brain/config.py +1949 -0
- brain/connect.py +925 -0
- brain/db.py +540 -0
- brain/demo/__init__.py +452 -0
- brain/demo/corpus/manifest.json +403 -0
- brain/demo/embedder.py +74 -0
- brain/durations.py +84 -0
- brain/edit_session.py +156 -0
- brain/editor.py +67 -0
- brain/elicit/__init__.py +16 -0
- brain/elicit/detectors.py +250 -0
- brain/elicit/drafter.py +70 -0
- brain/elicit/queue.py +220 -0
- brain/elicit/schema.py +48 -0
- brain/elicit/session.py +445 -0
- brain/embedding_targets.py +54 -0
- brain/embeddings.py +424 -0
- brain/enrichment.py +808 -0
- brain/errors.py +357 -0
- brain/eval/__init__.py +129 -0
- brain/eval/answer_eval.py +281 -0
- brain/eval/baseline.py +265 -0
- brain/eval/concept_extraction.py +378 -0
- brain/eval/corpus.py +152 -0
- brain/eval/errors.py +19 -0
- brain/eval/graph_baseline.py +226 -0
- brain/eval/graph_retrieval.py +202 -0
- brain/eval/graph_runner.py +319 -0
- brain/eval/metrics.py +101 -0
- brain/eval/runner.py +223 -0
- brain/format.py +783 -0
- brain/gaps.py +390 -0
- brain/graph_rag/__init__.py +94 -0
- brain/graph_rag/_retrieval_common.py +113 -0
- brain/graph_rag/aggregates.py +303 -0
- brain/graph_rag/aliases/__init__.py +583 -0
- brain/graph_rag/backends/__init__.py +10 -0
- brain/graph_rag/backends/_age_helpers.py +473 -0
- brain/graph_rag/backends/age.py +782 -0
- brain/graph_rag/backends/base.py +272 -0
- brain/graph_rag/build.py +344 -0
- brain/graph_rag/communities.py +644 -0
- brain/graph_rag/communities_summary.py +437 -0
- brain/graph_rag/concepts.py +202 -0
- brain/graph_rag/cooccur.py +193 -0
- brain/graph_rag/cross_type.py +312 -0
- brain/graph_rag/extract.py +885 -0
- brain/graph_rag/fuse.py +371 -0
- brain/graph_rag/global_.py +412 -0
- brain/graph_rag/grouping.py +372 -0
- brain/graph_rag/person_resolver.py +167 -0
- brain/graph_rag/reconcile.py +792 -0
- brain/graph_rag/relational.py +353 -0
- brain/graph_rag/retrieve.py +526 -0
- brain/graph_rag/router.py +288 -0
- brain/graph_rag/schema.py +320 -0
- brain/graph_rag/sync.py +237 -0
- brain/graph_rag/tenancy.py +43 -0
- brain/graph_rag/themes.py +501 -0
- brain/graph_rag/weighting.py +202 -0
- brain/ingest/__init__.py +1926 -0
- brain/ingest/chunker.py +249 -0
- brain/ingest/docx.py +40 -0
- brain/ingest/gmail.py +621 -0
- brain/ingest/markdown.py +37 -0
- brain/ingest/pdf.py +61 -0
- brain/ingest/stdin.py +22 -0
- brain/ingest/sub_tokens.py +91 -0
- brain/ingest/text.py +16 -0
- brain/interactions.py +205 -0
- brain/maintenance.py +355 -0
- brain/mcp_server.py +3405 -0
- brain/migrations/001_init.sql +43 -0
- brain/migrations/002_qwen3_embedding.sql +17 -0
- brain/migrations/003_vault_model.sql +41 -0
- brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
- brain/migrations/005_derived_links.sql +67 -0
- brain/migrations/006_dedup_file_by_source_path.sql +25 -0
- brain/migrations/007_email_thread_and_draft.sql +15 -0
- brain/migrations/008_gmail_thread_unique.sql +11 -0
- brain/migrations/009_chunks_weighted_tsv.sql +28 -0
- brain/migrations/010_interactions.sql +30 -0
- brain/migrations/011_documents_summary.sql +23 -0
- brain/migrations/012_graphrag.sql +171 -0
- brain/migrations/013_graphrag_communities.sql +125 -0
- brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
- brain/migrations/015_interactions_graph_targets.sql +89 -0
- brain/migrations/016_index_hygiene.sql +61 -0
- brain/migrations/017_elicit.sql +30 -0
- brain/migrations/018_review_gap_signal_kinds.sql +40 -0
- brain/migrations/019_search_queries.sql +35 -0
- brain/migrations/020_link_suggestions.sql +40 -0
- brain/migrations/021_timeline_doc_date.sql +34 -0
- brain/migrations/022_link_suggestions_undirected.sql +84 -0
- brain/migrations/023_search_queries_fts_count.sql +28 -0
- brain/quartz_overrides/__init__.py +8 -0
- brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
- brain/quartz_overrides/quartz/build.ts +568 -0
- brain/quartz_overrides/quartz/cli/args.js +152 -0
- brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
- brain/quartz_overrides/quartz/cli/handlers.js +636 -0
- brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
- brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
- brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
- brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
- brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
- brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
- brain/quartz_overrides/quartz/components/Search.tsx +161 -0
- brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
- brain/quartz_overrides/quartz/components/index.ts +92 -0
- brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
- brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
- brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
- brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
- brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
- brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
- brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
- brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
- brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
- brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
- brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
- brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
- brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
- brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
- brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
- brain/quartz_overrides/quartz/processors/parse.ts +371 -0
- brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
- brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
- brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
- brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
- brain/quartz_overrides/quartz/static/emailThread.js +334 -0
- brain/quartz_overrides/quartz/static/favicon.ico +0 -0
- brain/quartz_overrides/quartz/static/icon.png +0 -0
- brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
- brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
- brain/quartz_overrides/quartz/static/reload.js +168 -0
- brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
- brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
- brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
- brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
- brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
- brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
- brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
- brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
- brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
- brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
- brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
- brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
- brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
- brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
- brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
- brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
- brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
- brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
- brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
- brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
- brain/quartz_overrides/quartz/styles/custom.scss +89 -0
- brain/quartz_overrides/quartz/styles/graph.scss +505 -0
- brain/quartz_overrides/quartz/util/ctx.ts +92 -0
- brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
- brain/quartz_overrides/quartz/util/path.ts +358 -0
- brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
- brain/quartz_overrides/quartz.config.ts +270 -0
- brain/quartz_overrides/quartz.layout.ts +314 -0
- brain/queries.py +1188 -0
- brain/rank_fusion.py +8 -0
- brain/resurface.py +210 -0
- brain/review/__init__.py +26 -0
- brain/review/emit.py +27 -0
- brain/review/queries.py +436 -0
- brain/review/render.py +196 -0
- brain/review/scans.py +355 -0
- brain/review/weekly.py +413 -0
- brain/search.py +704 -0
- brain/set_similarity.py +15 -0
- brain/setup.py +1205 -0
- brain/tags.py +56 -0
- brain/templates/Caddyfile.j2 +9 -0
- brain/templates/__init__.py +1 -0
- brain/templates/bin/__init__.py +1 -0
- brain/templates/bin/_brain-brief-fg.sh +25 -0
- brain/templates/bin/_brain-build-fg.sh +53 -0
- brain/templates/bin/_brain-watcher-fg.sh +65 -0
- brain/templates/bin/brain-down.sh +89 -0
- brain/templates/bin/brain-status.sh +83 -0
- brain/templates/bin/brain-up.sh +221 -0
- brain/templates/docker/age/Dockerfile +79 -0
- brain/templates/docker-compose.stock.yml.j2 +26 -0
- brain/templates/docker-compose.yml.j2 +34 -0
- brain/templates/env.example +190 -0
- brain/templates/launchd/__init__.py +1 -0
- brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
- brain/templates/launchd/com.brain.build.plist.j2 +46 -0
- brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
- brain/templates/skill/SKILL.md +63 -0
- brain/templates/skill/__init__.py +1 -0
- brain/timeline.py +834 -0
- brain/todo.py +124 -0
- brain/uninstall.py +185 -0
- brain/vault/__init__.py +115 -0
- brain/vault/_atomic.py +25 -0
- brain/vault/daily_index.py +228 -0
- brain/vault/derived_links/__init__.py +50 -0
- brain/vault/derived_links/directory.py +683 -0
- brain/vault/derived_links/fence.py +408 -0
- brain/vault/derived_links/gws.py +64 -0
- brain/vault/derived_links/participants.py +143 -0
- brain/vault/derived_links/pass_runner.py +362 -0
- brain/vault/derived_links/rules.py +137 -0
- brain/vault/export.py +683 -0
- brain/vault/frontmatter.py +165 -0
- brain/vault/graph.py +620 -0
- brain/vault/graph_format.py +388 -0
- brain/vault/link_rewrite.py +235 -0
- brain/vault/links.py +260 -0
- brain/vault/note_builder.py +211 -0
- brain/vault/paths.py +55 -0
- brain/vault/quartz_overlay.py +236 -0
- brain/vault/rename.py +591 -0
- brain/vault/resolver.py +304 -0
- brain/vault/slug.py +127 -0
- brain/vault/sync.py +1513 -0
- brain/vault/sync_summaries.py +264 -0
- brain/vault/templates.py +145 -0
- brain/vault/watch.py +1052 -0
- brain/wiki/__init__.py +6 -0
- brain/wiki/_github_slugger.py +76 -0
- brain/wiki/_person_name.py +314 -0
- brain/wiki/build_homepage.py +541 -0
- brain/wiki/build_partial.py +273 -0
- brain/wiki/build_people.py +934 -0
- brain/wiki/build_related.py +758 -0
- brain/wiki/build_swap.py +585 -0
- brain/wiki/build_watcher.py +975 -0
- brain/wiki/edit_classifier.py +215 -0
- brain/wiki/errors.py +10 -0
- brain/wiki/fastpath_manifest.py +475 -0
- brain/wiki/fastpath_state.py +174 -0
- brain/wiki/install.py +296 -0
- brain/wiki/slug.py +111 -0
- secondbrain_py-0.2.1.dist-info/METADATA +195 -0
- secondbrain_py-0.2.1.dist-info/RECORD +273 -0
- secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
- secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
- secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
- secondbrain_py-0.2.1.dist-info/top_level.txt +1 -0
brain/graph_rag/fuse.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Fuse retrieval: graph (local) ⊕ vector/FTS (hybrid) via RRF (spec §17d Q1).
|
|
2
|
+
|
|
3
|
+
The fourth retrieval path alongside **local** (:mod:`brain.graph_rag.retrieve`),
|
|
4
|
+
**themes-with-X** (:mod:`brain.graph_rag.themes`), and **global**
|
|
5
|
+
(:mod:`brain.graph_rag.global_`). Where those resolve a single ranked unit
|
|
6
|
+
(entity-centric docs / theme groups / communities), **fuse** combines two
|
|
7
|
+
independent **document** rankings into one (wave G4-c):
|
|
8
|
+
|
|
9
|
+
* **Graph leg** — the ``local`` (entity-centric) document list
|
|
10
|
+
(:func:`brain.graph_rag.retrieve._retrieve_local`). ``themes`` / ``global`` are
|
|
11
|
+
deliberately NOT fused: their ranked units (theme groups / communities) and
|
|
12
|
+
scoping semantics differ, so they don't fuse cleanly into a doc list (spec
|
|
13
|
+
§17d Q1).
|
|
14
|
+
* **Hybrid leg** — the existing vector + FTS document ranking
|
|
15
|
+
(:func:`brain.search.hybrid_search`). Its ranking is **consumed unchanged**
|
|
16
|
+
(the spec §4 D7 invariant — fuse never modifies hybrid search).
|
|
17
|
+
|
|
18
|
+
The fusion (spec §17d Q1): RRF over each leg's **document-id rank** via
|
|
19
|
+
:func:`brain.rank_fusion.rrf_contribution` (``k=60``, the same constant as
|
|
20
|
+
:mod:`brain.search` / global / ``build_related``) — **not** a score-blend.
|
|
21
|
+
``score = Σ 1/(60 + rank + 1)`` accumulated per ``document_id`` across both legs;
|
|
22
|
+
ties broken by ``document_id`` (deterministic). The fused
|
|
23
|
+
:class:`~brain.search.SearchResult`s land in ``GraphContext.docs`` (wire-stable —
|
|
24
|
+
no new field, no change to ``SearchResult``); ``GraphContext.mode`` and
|
|
25
|
+
``GraphExplanation.mode`` are both stamped ``"fuse"``; and per-doc leg provenance
|
|
26
|
+
(which leg(s) ranked each returned doc + their 0-indexed ranks) is recorded in
|
|
27
|
+
``GraphExplanation.matched_filters["fuse_doc_provenance"]``.
|
|
28
|
+
|
|
29
|
+
Fallbacks (never-raise; spec §17d Q1, mirroring the §17b dec 7 / §17c Q9
|
|
30
|
+
best-effort-optional-leg discipline):
|
|
31
|
+
|
|
32
|
+
* The **graph leg** is the primary leg: a ``GraphBackendError`` from the
|
|
33
|
+
traversal is **not** swallowed (the backend's complete-or-loud-failure
|
|
34
|
+
contract, identical to ``mode=local``); an empty graph result simply yields no
|
|
35
|
+
graph docs → **hybrid-only**.
|
|
36
|
+
* The **hybrid leg** is additive/best-effort: a missing ``embedder`` (the
|
|
37
|
+
caller's pre-warmed instance — perf-T4 G5) / a failing query embedding → a
|
|
38
|
+
WARN + the hybrid leg runs **FTS-only**; the hybrid leg failing even
|
|
39
|
+
FTS-only (a DB / tsquery error) → a WARN + fuse degrades to **graph-only**.
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import logging
|
|
44
|
+
import uuid
|
|
45
|
+
from dataclasses import replace
|
|
46
|
+
from typing import TYPE_CHECKING, Any
|
|
47
|
+
|
|
48
|
+
import psycopg
|
|
49
|
+
|
|
50
|
+
from ..rank_fusion import rrf_contribution
|
|
51
|
+
from .router import FUSE_MODE
|
|
52
|
+
from .schema import GraphContext, GraphExplanation
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from ..config import Config
|
|
56
|
+
from ..ingest import Embedder
|
|
57
|
+
from ..search import SearchResult
|
|
58
|
+
from .backends.base import GraphBackend
|
|
59
|
+
|
|
60
|
+
_logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
# RRF k constant — matches :data:`brain.search.RRF_K` and the global path's
|
|
63
|
+
# ``_RRF_K`` so every fused ranker shares one fusion constant via the shared
|
|
64
|
+
# :func:`brain.rank_fusion.rrf_contribution` helper (spec §17d Q1).
|
|
65
|
+
_RRF_K = 60
|
|
66
|
+
|
|
67
|
+
# Fuse is gated to the default tenant until documents/chunks are tenantized
|
|
68
|
+
# (spec §17d decision 6 / G4-review finding P1-1). ``documents`` / ``chunks``
|
|
69
|
+
# carry NO ``tenant_id`` column, so fuse's hybrid leg (corpus-wide
|
|
70
|
+
# :func:`brain.search.hybrid_search`) would surface another tenant's documents —
|
|
71
|
+
# a cross-tenant leak. ``local`` / ``global`` are inherently tenant-scoped (their
|
|
72
|
+
# doc-id sets come solely from tenant-predicated ``graph_entity_mentions``) and
|
|
73
|
+
# need no gate. Tracked follow-up: tenantize documents/chunks, then remove this.
|
|
74
|
+
_DEFAULT_FUSE_TENANT = "default"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _NullEmbedder:
|
|
78
|
+
"""Null Embedder for fuse's FTS-only hybrid fallback (spec §17d Q1).
|
|
79
|
+
|
|
80
|
+
When no real embedder can be constructed (``embedder_factory`` absent or
|
|
81
|
+
failing), fuse still runs the hybrid leg **FTS-only**.
|
|
82
|
+
:func:`brain.search.hybrid_search` requires an ``embedder`` argument even in
|
|
83
|
+
``fts_only`` mode, but with ``fts_only=True`` + ``snippet_context_tokens=0``
|
|
84
|
+
it never calls ``embed`` / ``count_tokens``. This null object satisfies the
|
|
85
|
+
:class:`brain.ingest.Embedder` Protocol structurally so the FTS-only call is
|
|
86
|
+
runnable; its methods are unreachable on that path and raise so any future
|
|
87
|
+
accidental use is loud rather than silently wrong.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
dim = 0
|
|
91
|
+
|
|
92
|
+
def embed(
|
|
93
|
+
self, texts: list[str], *, input_type: str = "document"
|
|
94
|
+
) -> list[list[float]]:
|
|
95
|
+
raise NotImplementedError("fuse FTS-only hybrid leg must not embed")
|
|
96
|
+
|
|
97
|
+
def count_tokens(self, text: str) -> int:
|
|
98
|
+
raise NotImplementedError("fuse FTS-only hybrid leg must not count tokens")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_NULL_EMBEDDER = _NullEmbedder()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _retrieve_fuse(
|
|
105
|
+
conn: psycopg.Connection[Any],
|
|
106
|
+
cfg: Config,
|
|
107
|
+
query: str,
|
|
108
|
+
*,
|
|
109
|
+
backend: GraphBackend,
|
|
110
|
+
tenant: str,
|
|
111
|
+
depth: int,
|
|
112
|
+
frontier_cap: int,
|
|
113
|
+
min_edge_weight: float,
|
|
114
|
+
limit: int,
|
|
115
|
+
embedder: Embedder | None = None,
|
|
116
|
+
session_id: str | None = None,
|
|
117
|
+
) -> GraphContext:
|
|
118
|
+
"""Run fuse (graph ⊕ hybrid) retrieval + assemble its ``GraphContext``.
|
|
119
|
+
|
|
120
|
+
Runs the graph leg (``_retrieve_local``) and the hybrid leg
|
|
121
|
+
(:func:`brain.search.hybrid_search`), RRF-merges the two document-id rankings
|
|
122
|
+
(:func:`_fuse_doc_rankings`), takes the top ``limit``, and shapes the fused
|
|
123
|
+
:class:`~brain.search.SearchResult`s into ``GraphContext.docs`` (carrying each
|
|
124
|
+
doc's per-leg provenance in ``GraphExplanation.matched_filters``). The graph
|
|
125
|
+
leg's entities ride along on ``GraphContext.entities`` for context. Caps
|
|
126
|
+
(``depth`` / ``frontier_cap`` / ``min_edge_weight``) feed the graph leg; the
|
|
127
|
+
hybrid leg reads its tuning (``vector_sim_floor`` / ``recency_halflife_days``
|
|
128
|
+
/ ``snippet_context_tokens``) from ``cfg``. ``session_id`` is generated when
|
|
129
|
+
omitted (a fresh ``uuid4`` hex).
|
|
130
|
+
|
|
131
|
+
``embedder`` is the caller's PRE-WARMED :class:`brain.ingest.Embedder`
|
|
132
|
+
instance (perf-T4 G5) — the SAME instance that would feed any other leg
|
|
133
|
+
(e.g. global) within this call, so multi-leg retrievers never re-construct
|
|
134
|
+
or double-embed. ``None`` triggers FTS-only hybrid (never-raise).
|
|
135
|
+
|
|
136
|
+
Never-raise: an empty graph leg → hybrid-only; a missing/failed embedder →
|
|
137
|
+
FTS-only hybrid; a fully-dead hybrid leg → graph-only (spec §17d Q1). A
|
|
138
|
+
``GraphBackendError`` from the graph traversal is propagated (the backend's
|
|
139
|
+
complete-or-loud-failure contract, identical to ``mode=local``).
|
|
140
|
+
|
|
141
|
+
Tenant gate (spec §17d decision 6 / G4-review P1-1): a non-default ``tenant``
|
|
142
|
+
raises :class:`ValueError` BEFORE either leg runs — the hybrid leg is
|
|
143
|
+
corpus-wide (documents/chunks are not tenantized) so a non-default fuse would
|
|
144
|
+
leak cross-tenant documents. The ``ValueError`` maps cleanly through the
|
|
145
|
+
existing graphrag surfaces (CLI → ``typer.BadParameter`` exit 2; MCP →
|
|
146
|
+
``INVALID_PARAMS``). ``local`` / ``global`` stay available (inherently scoped).
|
|
147
|
+
"""
|
|
148
|
+
# Tenant gate (spec §17d decision 6 / G4-review P1-1): refuse fuse for any
|
|
149
|
+
# non-default tenant BEFORE either leg runs (not empty, not graph-only, not
|
|
150
|
+
# hybrid-filtered) — the hybrid leg queries documents/chunks corpus-wide
|
|
151
|
+
# (they carry no tenant_id), so a non-default fuse would surface another
|
|
152
|
+
# tenant's documents. local/global remain available (inherently scoped).
|
|
153
|
+
if tenant != _DEFAULT_FUSE_TENANT:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
"mode='fuse' is only available for tenant 'default' until "
|
|
156
|
+
"documents/chunks are tenantized; use mode='local' or mode='global' "
|
|
157
|
+
"for graph-scoped retrieval"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Lazy import to avoid a retrieve ↔ fuse module-load cycle (retrieve.py
|
|
161
|
+
# imports ``_retrieve_fuse`` at top; this resolves once retrieve is loaded).
|
|
162
|
+
from .retrieve import _retrieve_local
|
|
163
|
+
|
|
164
|
+
resolved_session = uuid.uuid4().hex if session_id is None else session_id
|
|
165
|
+
|
|
166
|
+
# GRAPH leg (primary): local entity-centric docs.
|
|
167
|
+
graph_ctx = _retrieve_local(
|
|
168
|
+
conn,
|
|
169
|
+
query,
|
|
170
|
+
backend=backend,
|
|
171
|
+
tenant_id=tenant,
|
|
172
|
+
depth=depth,
|
|
173
|
+
frontier_cap=frontier_cap,
|
|
174
|
+
min_edge_weight=min_edge_weight,
|
|
175
|
+
limit=limit,
|
|
176
|
+
session_id=resolved_session,
|
|
177
|
+
)
|
|
178
|
+
graph_docs = graph_ctx.docs
|
|
179
|
+
|
|
180
|
+
# HYBRID leg (additive, best-effort): FTS + vector via hybrid_search.
|
|
181
|
+
hybrid_docs, hybrid_vector_arm = _run_hybrid_leg(
|
|
182
|
+
conn, cfg, query, limit=limit, embedder=embedder
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# RRF-merge the two document-id rankings.
|
|
186
|
+
graph_ids = [doc.document_id for doc in graph_docs]
|
|
187
|
+
hybrid_ids = [doc.document_id for doc in hybrid_docs]
|
|
188
|
+
fused, provenance = _fuse_doc_rankings(graph_ids, hybrid_ids)
|
|
189
|
+
top = fused[:limit] if limit > 0 else []
|
|
190
|
+
docs = _build_fused_docs(top, graph_docs, hybrid_docs)
|
|
191
|
+
fuse_doc_provenance = {doc.document_id: provenance[doc.document_id] for doc in docs}
|
|
192
|
+
|
|
193
|
+
seed_entity_ids = (
|
|
194
|
+
list(graph_ctx.explanation.seed_entity_ids)
|
|
195
|
+
if graph_ctx.explanation is not None
|
|
196
|
+
else []
|
|
197
|
+
)
|
|
198
|
+
explanation = GraphExplanation(
|
|
199
|
+
mode=FUSE_MODE,
|
|
200
|
+
tenant_id=tenant,
|
|
201
|
+
seed_entity_ids=seed_entity_ids,
|
|
202
|
+
person_keys=[],
|
|
203
|
+
depth=depth,
|
|
204
|
+
frontier_cap=frontier_cap,
|
|
205
|
+
min_edge_weight=min_edge_weight,
|
|
206
|
+
nodes_visited=len(graph_ctx.entities),
|
|
207
|
+
edges_considered=0,
|
|
208
|
+
generic_df_cap=None,
|
|
209
|
+
matched_filters={
|
|
210
|
+
"query": query,
|
|
211
|
+
"graph_doc_count": len(graph_docs),
|
|
212
|
+
"hybrid_doc_count": len(hybrid_docs),
|
|
213
|
+
"hybrid_vector_arm_used": hybrid_vector_arm,
|
|
214
|
+
"fused_doc_count": len(docs),
|
|
215
|
+
"limit": limit,
|
|
216
|
+
"fuse_doc_provenance": fuse_doc_provenance,
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
return GraphContext(
|
|
220
|
+
session_id=resolved_session,
|
|
221
|
+
mode=FUSE_MODE,
|
|
222
|
+
query=query,
|
|
223
|
+
tenant_id=tenant,
|
|
224
|
+
person=None,
|
|
225
|
+
themes=[],
|
|
226
|
+
communities=[],
|
|
227
|
+
entities=graph_ctx.entities,
|
|
228
|
+
docs=docs,
|
|
229
|
+
explanation=explanation,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# --------------------------------------------------------------------------- #
|
|
234
|
+
# RRF merge (pure) + fused-doc shaping
|
|
235
|
+
# --------------------------------------------------------------------------- #
|
|
236
|
+
def _fuse_doc_rankings(
|
|
237
|
+
graph_ids: list[str], hybrid_ids: list[str]
|
|
238
|
+
) -> tuple[list[tuple[str, float]], dict[str, dict[str, Any]]]:
|
|
239
|
+
"""Pure RRF merge of the two document-id rankings (spec §17d Q1).
|
|
240
|
+
|
|
241
|
+
Each leg contributes ``rrf_contribution(rank, k=60)`` per ``document_id``
|
|
242
|
+
(0-indexed rank in that leg's order); contributions accumulate across legs.
|
|
243
|
+
Returns ``(fused, provenance)``:
|
|
244
|
+
|
|
245
|
+
* ``fused`` — ``[(document_id, score), …]`` sorted by score DESC then
|
|
246
|
+
``document_id`` ASC (the deterministic tie-break).
|
|
247
|
+
* ``provenance`` — ``document_id → {"graph_rank", "hybrid_rank",
|
|
248
|
+
"fused_score"}`` with 0-indexed ranks (``None`` when the doc is absent from
|
|
249
|
+
that leg).
|
|
250
|
+
|
|
251
|
+
Pure (no DB / no ``SearchResult``) so the fusion is unit-testable in
|
|
252
|
+
isolation. ``_retrieve_fuse`` passes the per-leg ``document_id`` orders.
|
|
253
|
+
"""
|
|
254
|
+
graph_rank = {doc_id: rank for rank, doc_id in enumerate(graph_ids)}
|
|
255
|
+
hybrid_rank = {doc_id: rank for rank, doc_id in enumerate(hybrid_ids)}
|
|
256
|
+
|
|
257
|
+
scores: dict[str, float] = {}
|
|
258
|
+
for rank, doc_id in enumerate(graph_ids):
|
|
259
|
+
scores[doc_id] = scores.get(doc_id, 0.0) + rrf_contribution(rank, k=_RRF_K)
|
|
260
|
+
for rank, doc_id in enumerate(hybrid_ids):
|
|
261
|
+
scores[doc_id] = scores.get(doc_id, 0.0) + rrf_contribution(rank, k=_RRF_K)
|
|
262
|
+
|
|
263
|
+
fused = sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
264
|
+
provenance = {
|
|
265
|
+
doc_id: {
|
|
266
|
+
"graph_rank": graph_rank.get(doc_id),
|
|
267
|
+
"hybrid_rank": hybrid_rank.get(doc_id),
|
|
268
|
+
"fused_score": score,
|
|
269
|
+
}
|
|
270
|
+
for doc_id, score in fused
|
|
271
|
+
}
|
|
272
|
+
return fused, provenance
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _build_fused_docs(
|
|
276
|
+
ranked: list[tuple[str, float]],
|
|
277
|
+
graph_docs: list[SearchResult],
|
|
278
|
+
hybrid_docs: list[SearchResult],
|
|
279
|
+
) -> list[SearchResult]:
|
|
280
|
+
"""Shape the fused ranking into ``SearchResult``s (no dataclass change).
|
|
281
|
+
|
|
282
|
+
Reuses each leg's :class:`~brain.search.SearchResult` as the carrier —
|
|
283
|
+
preferring the hybrid leg's (its snippet is query-relevant), falling back to
|
|
284
|
+
the graph leg's — and overrides only ``score`` with the fused RRF score
|
|
285
|
+
(immutably, via :func:`dataclasses.replace`). ``explain`` is cleared (the
|
|
286
|
+
fuse provenance lives in ``GraphExplanation.matched_filters`` instead). Order
|
|
287
|
+
preserves the fused ranking.
|
|
288
|
+
"""
|
|
289
|
+
hybrid_by_id = {doc.document_id: doc for doc in hybrid_docs}
|
|
290
|
+
graph_by_id = {doc.document_id: doc for doc in graph_docs}
|
|
291
|
+
out: list[SearchResult] = []
|
|
292
|
+
for doc_id, score in ranked:
|
|
293
|
+
carrier = hybrid_by_id.get(doc_id) or graph_by_id.get(doc_id)
|
|
294
|
+
if carrier is None: # defensive: every fused id came from a leg
|
|
295
|
+
continue
|
|
296
|
+
out.append(replace(carrier, score=score, explain=None))
|
|
297
|
+
return out
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --------------------------------------------------------------------------- #
|
|
301
|
+
# Hybrid leg (best-effort; FTS-only / graph-only fallbacks)
|
|
302
|
+
# --------------------------------------------------------------------------- #
|
|
303
|
+
def _run_hybrid_leg(
|
|
304
|
+
conn: psycopg.Connection[Any],
|
|
305
|
+
cfg: Config,
|
|
306
|
+
query: str,
|
|
307
|
+
*,
|
|
308
|
+
limit: int,
|
|
309
|
+
embedder: Embedder | None,
|
|
310
|
+
) -> tuple[list[SearchResult], bool]:
|
|
311
|
+
"""Run the FTS + vector hybrid leg for fuse (spec §17d Q1; never-raise).
|
|
312
|
+
|
|
313
|
+
Returns ``(docs, vector_arm_used)``:
|
|
314
|
+
|
|
315
|
+
* caller's pre-warmed embedder present + query embeds + cosine SQL runs →
|
|
316
|
+
full hybrid (``vector_arm_used=True``).
|
|
317
|
+
* embedder absent / the vector arm fails → **FTS-only** hybrid
|
|
318
|
+
(``vector_arm_used=False``); the hybrid leg's ranking is consumed
|
|
319
|
+
unchanged (spec §4 D7 — hybrid search is never modified).
|
|
320
|
+
* the FTS-only ``hybrid_search`` itself failing (a DB / tsquery error) →
|
|
321
|
+
``([], False)`` + a WARN (fuse degrades to graph-only).
|
|
322
|
+
|
|
323
|
+
The caller is responsible for constructing the embedder ONCE per call
|
|
324
|
+
(perf-T4 G5) so multi-leg retrievers never re-build or double-embed.
|
|
325
|
+
"""
|
|
326
|
+
# Late import keeps :mod:`brain.graph_rag` import-cheap + free of the ingest
|
|
327
|
+
# import cycle :mod:`brain.search` pulls in (mirrors ``_build_doc_results``).
|
|
328
|
+
from ..search import hybrid_search
|
|
329
|
+
|
|
330
|
+
if embedder is None:
|
|
331
|
+
_logger.warning(
|
|
332
|
+
"fuse: no embedder injected; hybrid leg runs FTS-only"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Vector arm — only when a real embedder was passed in.
|
|
336
|
+
if embedder is not None:
|
|
337
|
+
try:
|
|
338
|
+
docs = hybrid_search(
|
|
339
|
+
conn,
|
|
340
|
+
embedder=embedder,
|
|
341
|
+
query=query,
|
|
342
|
+
limit=limit,
|
|
343
|
+
vector_sim_floor=cfg.vector_sim_floor,
|
|
344
|
+
recency_halflife_days=cfg.recency_halflife_days,
|
|
345
|
+
snippet_context_tokens=cfg.snippet_context_tokens,
|
|
346
|
+
)
|
|
347
|
+
return docs, True
|
|
348
|
+
except Exception as exc: # noqa: BLE001 — never-raise: degrade to FTS-only
|
|
349
|
+
_logger.warning(
|
|
350
|
+
"fuse: hybrid vector leg failed (%s); retrying FTS-only", exc
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# FTS-only arm — no usable embedder OR the vector arm failed above. Use the
|
|
354
|
+
# real embedder when present (its methods are unreachable with fts_only=True +
|
|
355
|
+
# snippet_context_tokens=0), else the null placeholder so the leg still runs.
|
|
356
|
+
fts_embedder = embedder if embedder is not None else _NULL_EMBEDDER
|
|
357
|
+
try:
|
|
358
|
+
docs = hybrid_search(
|
|
359
|
+
conn,
|
|
360
|
+
embedder=fts_embedder,
|
|
361
|
+
query=query,
|
|
362
|
+
limit=limit,
|
|
363
|
+
fts_only=True,
|
|
364
|
+
snippet_context_tokens=0,
|
|
365
|
+
)
|
|
366
|
+
except Exception as exc: # noqa: BLE001 — hybrid fully dead → graph-only
|
|
367
|
+
_logger.warning(
|
|
368
|
+
"fuse: hybrid FTS leg failed (%s); fuse degrades to graph-only", exc
|
|
369
|
+
)
|
|
370
|
+
return [], False
|
|
371
|
+
return docs, False
|