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/search.py
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
"""Hybrid search: FTS + vector via Reciprocal Rank Fusion.
|
|
2
|
+
|
|
3
|
+
Three Phase-D refinements live here in addition to the original RRF
|
|
4
|
+
combiner (see `docs/plans/2026-05-06-search-ranking-fix.md`):
|
|
5
|
+
|
|
6
|
+
1. **Per-document FTS candidate cap** (revision #1). The FTS leg is
|
|
7
|
+
wrapped in a window-function CTE that keeps the top
|
|
8
|
+
:data:`PER_DOC_CHUNK_CAP` chunks per ``document_id`` before the
|
|
9
|
+
global ``LIMIT 50``, so a single long title-matching doc can no
|
|
10
|
+
longer monopolize the candidate set.
|
|
11
|
+
|
|
12
|
+
2. **Compact-form query expansion** (revision #2).
|
|
13
|
+
:func:`_build_tsquery` ORs the standard tokenization with the
|
|
14
|
+
lowercase-concatenated form when the raw query has 2+ tokens, so
|
|
15
|
+
`Example Group` matches a doc whose only relevant term is the
|
|
16
|
+
single-token `[example-group]`.
|
|
17
|
+
|
|
18
|
+
3. **Vector cosine floor** (revisions #3 + #6). The vector leg
|
|
19
|
+
filters out chunks below ``vector_sim_floor`` (default
|
|
20
|
+
:data:`DEFAULT_VECTOR_SIM_FLOOR`, overridable via
|
|
21
|
+
``BRAIN_VECTOR_SIM_FLOOR`` in :mod:`brain.config`). Tuned
|
|
22
|
+
empirically — see :mod:`brain.config` and
|
|
23
|
+
``tests/test_search_floor_default_excludes_known_bad.py``.
|
|
24
|
+
|
|
25
|
+
The fts_only path bypasses (3) entirely.
|
|
26
|
+
"""
|
|
27
|
+
import re
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from datetime import UTC, datetime
|
|
30
|
+
from functools import lru_cache
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import psycopg
|
|
34
|
+
|
|
35
|
+
from .ingest import Embedder
|
|
36
|
+
from .rank_fusion import rrf_contribution
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class SearchExplanation:
|
|
41
|
+
"""Per-document ranking diagnostic.
|
|
42
|
+
|
|
43
|
+
Attached to :class:`SearchResult` when ``hybrid_search(..., explain=True)``.
|
|
44
|
+
Fields are nullable where the corresponding leg didn't contribute — e.g. a
|
|
45
|
+
doc that only appears in the FTS leg has ``vector_rank=None`` /
|
|
46
|
+
``vector_cosine=None`` / ``vector_rrf_contribution=0.0``.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
fts_rank: int | None # 1-indexed; None if the best chunk didn't appear in FTS
|
|
50
|
+
fts_score: float | None # ts_rank value; None if absent from FTS leg
|
|
51
|
+
fts_rrf_contribution: float # 1/(60+fts_rank) or 0.0
|
|
52
|
+
vector_rank: int | None # 1-indexed; None if absent from vector leg
|
|
53
|
+
vector_cosine: float | None # 1 - (embedding <=> query); None if absent
|
|
54
|
+
vector_rrf_contribution: float # 1/(60+vector_rank) or 0.0
|
|
55
|
+
rrf_score: float # raw RRF sum before recency boost
|
|
56
|
+
recency_age_days: float | None # None if recency disabled or no timestamp
|
|
57
|
+
recency_boost: float # 1.0 when disabled / unaffected
|
|
58
|
+
final_score: float # post-recency; matches SearchResult.score
|
|
59
|
+
best_chunk_id: str # UUID of the highest-scoring chunk for this doc
|
|
60
|
+
best_chunk_index: int # 0-based chunk index within the document
|
|
61
|
+
matched_filters: dict[str, Any] # {"source_kind", "tag", "since_days", "fts_only"}
|
|
62
|
+
reranker_score: float | None = None # Q3-A will populate; today always None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class SearchDiagnostics:
|
|
67
|
+
"""Mutable out-parameter for cheap search-layer metrics.
|
|
68
|
+
|
|
69
|
+
Passed to :func:`hybrid_search` via the ``diagnostics`` kwarg and populated
|
|
70
|
+
in-place, so callers read the FTS-leg hit count WITHOUT changing the
|
|
71
|
+
``list[SearchResult]`` return contract every other caller depends on.
|
|
72
|
+
|
|
73
|
+
``fts_count`` is the number of FTS candidate chunks the lexical leg
|
|
74
|
+
returned for the query. It is taken straight from ``len(fts_rows)`` — work
|
|
75
|
+
the search already does — so reading it costs no extra query. The value is
|
|
76
|
+
capped by the candidate limit + per-doc cap, so a positive count is NOT a
|
|
77
|
+
true total; only the **zero** case is exact (``fts_count == 0`` iff no chunk
|
|
78
|
+
matched the tsquery). That zero is the knowledge-gap signal for
|
|
79
|
+
``brain gaps``: the vector leg always returns nearest neighbours, so a
|
|
80
|
+
lexical miss is otherwise invisible. ``None`` means the search never ran
|
|
81
|
+
(the holder was created but not passed, or an exception preceded the FTS
|
|
82
|
+
leg).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
fts_count: int | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class SearchResult:
|
|
90
|
+
"""A single search hit grouped at document granularity with its best chunk."""
|
|
91
|
+
|
|
92
|
+
document_id: str
|
|
93
|
+
title: str
|
|
94
|
+
source_kind: str | None
|
|
95
|
+
snippet: str
|
|
96
|
+
score: float
|
|
97
|
+
content_type: str
|
|
98
|
+
tags: list[str]
|
|
99
|
+
explain: SearchExplanation | None = None # opt-in; populated only when explain=True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
RRF_K = 60
|
|
103
|
+
CANDIDATE_LIMIT = 50
|
|
104
|
+
SNIPPET_LENGTH = 400
|
|
105
|
+
|
|
106
|
+
# Maximum FTS chunks kept per document before the global candidate cut.
|
|
107
|
+
# K=3 retains overlap signal across body chunks while preventing a long
|
|
108
|
+
# title-matching doc (the live corpus has docs with 304+ chunks) from
|
|
109
|
+
# filling the entire 50-candidate slot. Per plan revision #1.
|
|
110
|
+
PER_DOC_CHUNK_CAP = 3
|
|
111
|
+
|
|
112
|
+
# Token regex for compact-form query expansion. Matches alphanumeric runs
|
|
113
|
+
# starting with a letter so we strip stray punctuation but preserve
|
|
114
|
+
# embedded digits (`v2`, `cto4u` etc.). See :func:`_build_tsquery`.
|
|
115
|
+
_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _build_tsquery(conn: psycopg.Connection, raw_query: str) -> str:
|
|
119
|
+
"""Return a ``to_tsquery``-compatible string for ``raw_query``.
|
|
120
|
+
|
|
121
|
+
When the query has 2+ alphabetic tokens, ORs the standard
|
|
122
|
+
``plainto_tsquery`` form with the lowercase-concatenated compact
|
|
123
|
+
form (e.g. ``Example Group`` → ``(cto & lunch) | ctolunch``). This
|
|
124
|
+
catches docs whose only mention of the term is a single compact
|
|
125
|
+
token like ``[example-group]`` that the English parser stems to
|
|
126
|
+
``ctolunch``.
|
|
127
|
+
|
|
128
|
+
Returns an empty string for empty / pure-punctuation input —
|
|
129
|
+
``to_tsquery('')`` is a valid empty tsquery that matches nothing.
|
|
130
|
+
"""
|
|
131
|
+
tokens = _TOKEN_RE.findall(raw_query)
|
|
132
|
+
standard_row = conn.execute(
|
|
133
|
+
"SELECT plainto_tsquery('english', %s)::text", (raw_query,)
|
|
134
|
+
).fetchone()
|
|
135
|
+
standard = standard_row[0] if standard_row else ""
|
|
136
|
+
if len(tokens) < 2 or not standard:
|
|
137
|
+
return standard
|
|
138
|
+
compact = "".join(tokens).lower()
|
|
139
|
+
compact_row = conn.execute(
|
|
140
|
+
"SELECT plainto_tsquery('english', %s)::text", (compact,)
|
|
141
|
+
).fetchone()
|
|
142
|
+
compact_tsq = compact_row[0] if compact_row else ""
|
|
143
|
+
if not compact_tsq or compact_tsq == standard:
|
|
144
|
+
return standard
|
|
145
|
+
return f"({standard}) | ({compact_tsq})"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Query-embedding LRU cache (perf F1)
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
# In-process cache for query embeddings. The query embed call (e.g. Ollama
|
|
153
|
+
# Arctic) dominates search latency — ~115 ms warm / ~280 ms cold per the
|
|
154
|
+
# retrieval perf audit (2026-05-25). Identical query embeds recur within a
|
|
155
|
+
# single process: ``brain explain`` right after ``brain search``, an MCP
|
|
156
|
+
# multi-turn session. Those should not hit the embedder twice. A fresh CLI
|
|
157
|
+
# invocation starts cold, so the win is purely in-process / MCP. This is a
|
|
158
|
+
# module-level constant, NOT a Config knob (per the task scope).
|
|
159
|
+
_QUERY_EMBED_CACHE_SIZE = 256
|
|
160
|
+
|
|
161
|
+
# identity → embedder, populated on every :func:`_query_embed` call so the
|
|
162
|
+
# ``lru_cache``'d worker can recompute on a miss without taking the embedder
|
|
163
|
+
# (unhashable, per-instance) as a cache-key argument. Bounded by the number of
|
|
164
|
+
# distinct (class, model, dim) embedder identities seen in-process — at most a
|
|
165
|
+
# handful — so it never grows unbounded.
|
|
166
|
+
_embedder_registry: dict[str, Embedder] = {}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _embedder_identity(embedder: Embedder) -> str:
|
|
170
|
+
"""Return a stable cache-key identity for ``embedder``.
|
|
171
|
+
|
|
172
|
+
Combines the concrete class (module + qualname), the backend model name
|
|
173
|
+
when the embedder exposes one (``_model`` on the Ollama/Voyage backends),
|
|
174
|
+
and the output ``dim``. Two embedders that would yield *different* vectors
|
|
175
|
+
for the same text — different model, backend, or dimensionality — MUST map
|
|
176
|
+
to different identities so the query-embed cache never serves a vector
|
|
177
|
+
computed by a different embedder/model.
|
|
178
|
+
"""
|
|
179
|
+
cls = type(embedder)
|
|
180
|
+
model = getattr(embedder, "_model", "")
|
|
181
|
+
return f"{cls.__module__}.{cls.__qualname__}|{model}|{embedder.dim}"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@lru_cache(maxsize=_QUERY_EMBED_CACHE_SIZE)
|
|
185
|
+
def _cached_query_embed(
|
|
186
|
+
identity: str, input_type: str, text: str
|
|
187
|
+
) -> tuple[float, ...]:
|
|
188
|
+
"""LRU-cached single-text embed keyed by ``(identity, input_type, text)``.
|
|
189
|
+
|
|
190
|
+
The embedder is resolved from :data:`_embedder_registry` rather than passed
|
|
191
|
+
as an argument, so every component of the cache key is hashable and the key
|
|
192
|
+
is identity-scoped. Returns an immutable tuple — embeddings are lists
|
|
193
|
+
(unhashable), and caching a mutable list would also let one caller corrupt
|
|
194
|
+
another's vector.
|
|
195
|
+
"""
|
|
196
|
+
embedder = _embedder_registry[identity]
|
|
197
|
+
return tuple(embedder.embed([text], input_type=input_type)[0])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _query_embed(
|
|
201
|
+
embedder: Embedder, text: str, *, input_type: str = "query"
|
|
202
|
+
) -> list[float]:
|
|
203
|
+
"""Return the embedding for ``text`` via the in-process LRU cache.
|
|
204
|
+
|
|
205
|
+
Registers ``embedder`` under its identity (so a cache miss can recompute),
|
|
206
|
+
then returns a fresh ``list`` copy of the cached tuple — callers hand it to
|
|
207
|
+
psycopg as a ``::vector`` parameter and must not mutate the shared cache
|
|
208
|
+
entry. Behaviourally identical to ``embedder.embed([text],
|
|
209
|
+
input_type=input_type)[0]`` apart from the caching.
|
|
210
|
+
"""
|
|
211
|
+
identity = _embedder_identity(embedder)
|
|
212
|
+
_embedder_registry[identity] = embedder
|
|
213
|
+
return list(_cached_query_embed(identity, input_type, text))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _ensure_utc(dt: datetime) -> datetime:
|
|
217
|
+
"""Stamp a naive datetime as UTC so ``timestamptz`` comparisons don't shift.
|
|
218
|
+
|
|
219
|
+
``--after 2026-01-01`` reaches the search layer as a *naive* midnight. Bound
|
|
220
|
+
directly against a ``timestamptz`` column, Postgres interprets a naive
|
|
221
|
+
literal in the **session** ``TimeZone``, shifting the boundary by the
|
|
222
|
+
session's UTC offset — a doc sent at ``2026-01-01T03:00:00Z`` would fall
|
|
223
|
+
*outside* ``--after 2026-01-01`` under an ``America/New_York`` session.
|
|
224
|
+
Stamping UTC makes the boundary session-TZ-independent. Already-aware
|
|
225
|
+
datetimes pass through unchanged. Mirrors the recency-boost idiom below
|
|
226
|
+
(``recency_ts.replace(tzinfo=UTC)``).
|
|
227
|
+
"""
|
|
228
|
+
return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def hybrid_search(
|
|
232
|
+
conn: psycopg.Connection,
|
|
233
|
+
*,
|
|
234
|
+
embedder: Embedder,
|
|
235
|
+
query: str,
|
|
236
|
+
limit: int = 5,
|
|
237
|
+
source_kind: str | None = None,
|
|
238
|
+
tag: str | None = None,
|
|
239
|
+
since_days: int | None = None,
|
|
240
|
+
fts_only: bool = False,
|
|
241
|
+
vector_sim_floor: float = 0.0,
|
|
242
|
+
recency_halflife_days: float | None = None,
|
|
243
|
+
snippet_context_tokens: int = 0,
|
|
244
|
+
explain: bool = False,
|
|
245
|
+
diagnostics: SearchDiagnostics | None = None,
|
|
246
|
+
# — Q1-C metadata filters —
|
|
247
|
+
person_keys: list[str] | None = None,
|
|
248
|
+
person_display_name: str | None = None,
|
|
249
|
+
after: datetime | None = None,
|
|
250
|
+
before: datetime | None = None,
|
|
251
|
+
content_type: str | None = None,
|
|
252
|
+
thread_id: str | None = None,
|
|
253
|
+
draft: bool | None = None,
|
|
254
|
+
without_tag: str | None = None,
|
|
255
|
+
) -> list[SearchResult]:
|
|
256
|
+
"""Combine FTS and vector ranks via Reciprocal Rank Fusion.
|
|
257
|
+
|
|
258
|
+
Each chunk receives ``1 / (K + rank)`` from each ranker it appears in
|
|
259
|
+
(K=60). Per-document scores are the max across that document's chunks,
|
|
260
|
+
and the highest-scoring chunk per document becomes the returned snippet.
|
|
261
|
+
|
|
262
|
+
When ``fts_only`` is True, the vector leg (and the Ollama embed call) is
|
|
263
|
+
skipped — useful when the embedding service is unavailable. The cosine
|
|
264
|
+
floor (``vector_sim_floor``) only applies to the vector leg; FTS
|
|
265
|
+
candidates are not filtered by it.
|
|
266
|
+
|
|
267
|
+
``vector_sim_floor`` filters chunks whose ``1 - cosine_distance`` is
|
|
268
|
+
below the floor. Default ``0.0`` keeps backwards compatibility for
|
|
269
|
+
direct callers; the CLI plumbs ``cfg.vector_sim_floor`` through.
|
|
270
|
+
|
|
271
|
+
``recency_halflife_days`` applies an exponential-decay boost after RRF:
|
|
272
|
+
``score *= 0.5 ** (age_days / halflife_days)`` where ``age_days`` comes
|
|
273
|
+
from ``coalesce(sent_at, ingested_at)``. ``None`` (default) disables
|
|
274
|
+
the boost. Future-dated rows get ``boost = 1.0`` (clamped, not boosted).
|
|
275
|
+
|
|
276
|
+
``snippet_context_tokens`` expands the best-matching chunk's snippet by
|
|
277
|
+
pulling neighboring chunks (``chunk_index ± W``) from the same document
|
|
278
|
+
and stitching them together up to the token budget. ``0`` (default)
|
|
279
|
+
returns the single-chunk snippet unchanged.
|
|
280
|
+
|
|
281
|
+
``diagnostics`` (optional :class:`SearchDiagnostics`) is populated in place
|
|
282
|
+
with the FTS-leg hit count (``fts_count``). ``None`` (default) skips it.
|
|
283
|
+
See :class:`SearchDiagnostics` for why this is an out-parameter rather than
|
|
284
|
+
a return-value change.
|
|
285
|
+
|
|
286
|
+
Q1-C metadata filters (all optional, default ``None`` = no filter):
|
|
287
|
+
|
|
288
|
+
- ``person_keys`` — case-insensitive overlap against
|
|
289
|
+
``documents.participants``. Caller is responsible for resolving the
|
|
290
|
+
``--person <name>`` argument via
|
|
291
|
+
:func:`brain.queries.resolve_person_to_keys` before calling
|
|
292
|
+
``hybrid_search`` (the resolver may raise
|
|
293
|
+
:class:`brain.errors.PersonNotFound` / :class:`PersonAmbiguous`
|
|
294
|
+
which the CLI / MCP layer maps to its framework's error type).
|
|
295
|
+
``person_display_name`` rides along into ``matched_filters`` for
|
|
296
|
+
explain readability — it does not affect the SQL. Gmail stores
|
|
297
|
+
participants in case-preserved form (``"Alice Doe <alice@x.com>"``)
|
|
298
|
+
while the resolver returns lowercased keys, so the SQL lowercases
|
|
299
|
+
each stored entry via ``unnest`` before comparing — at the cost of
|
|
300
|
+
bypassing the GIN index on ``participants``, which is acceptable
|
|
301
|
+
for a personal-corpus scale.
|
|
302
|
+
- ``after`` / ``before`` — date-range predicate on
|
|
303
|
+
``coalesce(sent_at, ingested_at)``. Inclusive lower bound,
|
|
304
|
+
exclusive upper bound (so ``after=X, before=X`` returns nothing).
|
|
305
|
+
- ``content_type`` — exact match on ``documents.content_type``
|
|
306
|
+
(``email``, ``email_thread``, ``note``, ``transcript``, …). NOT
|
|
307
|
+
``documents.kind`` (which is the vault/ingested tier enum).
|
|
308
|
+
- ``thread_id`` — exact match on ``documents.thread_id`` (Gmail
|
|
309
|
+
thread id; indexed via migration 007).
|
|
310
|
+
- ``draft`` — three-state filter on ``documents.draft``: ``True``
|
|
311
|
+
→ drafts only, ``False`` → published only, ``None`` → both
|
|
312
|
+
(default, matches pre-Q1-C behavior).
|
|
313
|
+
- ``without_tag`` — exclude docs whose ``tags`` array contains the
|
|
314
|
+
given tag. Combines with ``tag`` (AND) so callers can express
|
|
315
|
+
"tagged X but not Y".
|
|
316
|
+
"""
|
|
317
|
+
# Auto-degrade to FTS-only when the active embedder produces no vectors
|
|
318
|
+
# (the FTS-only ``NullEmbedder`` under ``BRAIN_EMBEDDER=none``). Duck-typed
|
|
319
|
+
# via ``getattr`` so the real backends (Arctic / Qwen3 / Voyage) — which
|
|
320
|
+
# never declare the flag — are unaffected, and EVERY caller (CLI, MCP,
|
|
321
|
+
# library) degrades here rather than each re-implementing the check. This
|
|
322
|
+
# also flows into ``matched_filters["fts_only"]`` below so ``explain`` shows
|
|
323
|
+
# the effective mode.
|
|
324
|
+
fts_only = fts_only or not getattr(embedder, "produces_embeddings", True)
|
|
325
|
+
where_clauses = ["TRUE"]
|
|
326
|
+
where_params: list[Any] = []
|
|
327
|
+
if source_kind:
|
|
328
|
+
where_clauses.append("d.source_id IN (SELECT id FROM sources WHERE kind=%s)")
|
|
329
|
+
where_params.append(source_kind)
|
|
330
|
+
if tag:
|
|
331
|
+
where_clauses.append("%s = ANY(d.tags)")
|
|
332
|
+
where_params.append(tag)
|
|
333
|
+
if since_days:
|
|
334
|
+
where_clauses.append("d.ingested_at >= NOW() - make_interval(days => %s)")
|
|
335
|
+
where_params.append(since_days)
|
|
336
|
+
if person_keys:
|
|
337
|
+
# Case-insensitive overlap. ``documents.participants`` is written
|
|
338
|
+
# by ingest extractors in source-preserved case (Gmail emits
|
|
339
|
+
# ``"Alice Doe <alice@x.com>"``); the resolver's keys are
|
|
340
|
+
# lowercased + expanded. A plain ``&&`` overlap would miss every
|
|
341
|
+
# mixed-case stored value, so we unnest the array and lower each
|
|
342
|
+
# element before comparing. Empty ``keys`` is "no filter" — the
|
|
343
|
+
# resolver itself raises PersonNotFound on no match, so an empty
|
|
344
|
+
# list here can only be a caller's explicit "no person filter"
|
|
345
|
+
# intent.
|
|
346
|
+
where_clauses.append(
|
|
347
|
+
"EXISTS (SELECT 1 FROM unnest(d.participants) AS _p "
|
|
348
|
+
"WHERE lower(_p) = ANY(%s::text[]))"
|
|
349
|
+
)
|
|
350
|
+
where_params.append(person_keys)
|
|
351
|
+
if after is not None:
|
|
352
|
+
where_clauses.append("coalesce(d.sent_at, d.ingested_at) >= %s")
|
|
353
|
+
where_params.append(_ensure_utc(after))
|
|
354
|
+
if before is not None:
|
|
355
|
+
where_clauses.append("coalesce(d.sent_at, d.ingested_at) < %s")
|
|
356
|
+
where_params.append(_ensure_utc(before))
|
|
357
|
+
if content_type is not None:
|
|
358
|
+
where_clauses.append("d.content_type = %s")
|
|
359
|
+
where_params.append(content_type)
|
|
360
|
+
if thread_id is not None:
|
|
361
|
+
where_clauses.append("d.thread_id = %s")
|
|
362
|
+
where_params.append(thread_id)
|
|
363
|
+
if draft is not None:
|
|
364
|
+
where_clauses.append("d.draft = %s")
|
|
365
|
+
where_params.append(draft)
|
|
366
|
+
if without_tag is not None:
|
|
367
|
+
where_clauses.append("NOT (%s = ANY(d.tags))")
|
|
368
|
+
where_params.append(without_tag)
|
|
369
|
+
where_sql = " AND ".join(where_clauses)
|
|
370
|
+
|
|
371
|
+
# No-filter fast path (perf F5 + F2). ``where_clauses`` always starts with
|
|
372
|
+
# the literal ``"TRUE"``; every metadata filter appends a clause *and* a
|
|
373
|
+
# param. So ``where_sql == "TRUE"`` (the common unfiltered CLI search)
|
|
374
|
+
# means the ``documents`` JOIN supplies no column the FTS/vector legs
|
|
375
|
+
# actually read — title/tags/source_kind/recency all come from the separate
|
|
376
|
+
# ``doc_rows`` fetch below, and the inner JOIN on the ``document_id`` FK
|
|
377
|
+
# can neither drop nor duplicate chunk rows. We therefore (F5) omit the
|
|
378
|
+
# JOIN and (F2) force psycopg to prepare the now-static SQL so an
|
|
379
|
+
# in-process / MCP repeated search reuses the plan (~15 ms planning saved).
|
|
380
|
+
# The filtered path keeps the JOIN and leaves ``prepare=None`` (psycopg's
|
|
381
|
+
# auto-prepare heuristic) since each distinct filter combo is a different
|
|
382
|
+
# statement; a one-shot CLI invocation prepares-then-executes once, a
|
|
383
|
+
# negligible no-op risk.
|
|
384
|
+
has_filters = where_sql != "TRUE"
|
|
385
|
+
prepare_flag: bool | None = None if has_filters else True
|
|
386
|
+
join_clause = "JOIN documents d ON d.id = c.document_id" if has_filters else ""
|
|
387
|
+
fts_filter = f" AND {where_sql}" if has_filters else ""
|
|
388
|
+
|
|
389
|
+
tsquery = _build_tsquery(conn, query)
|
|
390
|
+
|
|
391
|
+
# Two-level CTE (perf F3): the inner ``base`` computes ``ts_rank`` exactly
|
|
392
|
+
# once per row as ``score``; ``ranked`` reuses that alias for both the
|
|
393
|
+
# per-document window cap and the final ordering. The previous single-CTE
|
|
394
|
+
# form computed ``ts_rank`` twice (score column + window ORDER BY) and bound
|
|
395
|
+
# ``to_tsquery`` three times. The ``@@`` predicate is deliberately kept as a
|
|
396
|
+
# direct inline ``to_tsquery(...)`` expression (not hoisted into a CTE) so
|
|
397
|
+
# the GIN ``chunks_tsv_idx`` Bitmap Index Scan plan is provably unchanged.
|
|
398
|
+
# The per-doc cap keeps the top PER_DOC_CHUNK_CAP chunks per ``document_id``
|
|
399
|
+
# before the global LIMIT so one long doc can't fill the candidate slot.
|
|
400
|
+
fts_sql = f"""
|
|
401
|
+
WITH base AS (
|
|
402
|
+
SELECT c.id, c.document_id, c.chunk_index, c.content,
|
|
403
|
+
ts_rank(c.tsv, to_tsquery('english', %s)) AS score
|
|
404
|
+
FROM chunks c
|
|
405
|
+
{join_clause}
|
|
406
|
+
WHERE c.tsv @@ to_tsquery('english', %s){fts_filter}
|
|
407
|
+
),
|
|
408
|
+
ranked AS (
|
|
409
|
+
SELECT id, document_id, chunk_index, content, score,
|
|
410
|
+
ROW_NUMBER() OVER (
|
|
411
|
+
PARTITION BY document_id ORDER BY score DESC
|
|
412
|
+
) AS rn
|
|
413
|
+
FROM base
|
|
414
|
+
)
|
|
415
|
+
SELECT id, document_id, chunk_index, content, score
|
|
416
|
+
FROM ranked
|
|
417
|
+
WHERE rn <= {PER_DOC_CHUNK_CAP}
|
|
418
|
+
ORDER BY score DESC
|
|
419
|
+
LIMIT {CANDIDATE_LIMIT}
|
|
420
|
+
"""
|
|
421
|
+
fts_rows = conn.execute(
|
|
422
|
+
fts_sql, [tsquery, tsquery, *where_params], prepare=prepare_flag
|
|
423
|
+
).fetchall()
|
|
424
|
+
|
|
425
|
+
# Surface the lexical-leg hit count to an opt-in caller (no extra query —
|
|
426
|
+
# ``fts_rows`` is already materialized). ``fts_count == 0`` means the corpus
|
|
427
|
+
# has no lexical trace of the query, which is the knowledge-gap signal that
|
|
428
|
+
# the vector leg (always returns nearest neighbours) would otherwise mask.
|
|
429
|
+
if diagnostics is not None:
|
|
430
|
+
diagnostics.fts_count = len(fts_rows)
|
|
431
|
+
|
|
432
|
+
vec_rows: list[Any] = []
|
|
433
|
+
if not fts_only:
|
|
434
|
+
q_emb = _query_embed(embedder, query)
|
|
435
|
+
floor_pred = "1 - (c.embedding <=> %s::vector) >= %s"
|
|
436
|
+
vec_params: list[Any]
|
|
437
|
+
if has_filters:
|
|
438
|
+
vec_where = f"WHERE {where_sql} AND {floor_pred}"
|
|
439
|
+
vec_params = [q_emb, *where_params, q_emb, vector_sim_floor, q_emb]
|
|
440
|
+
else:
|
|
441
|
+
vec_where = f"WHERE {floor_pred}"
|
|
442
|
+
vec_params = [q_emb, q_emb, vector_sim_floor, q_emb]
|
|
443
|
+
vec_sql = f"""
|
|
444
|
+
SELECT c.id, c.document_id, c.chunk_index, c.content,
|
|
445
|
+
1 - (c.embedding <=> %s::vector) AS score
|
|
446
|
+
FROM chunks c
|
|
447
|
+
{join_clause}
|
|
448
|
+
{vec_where}
|
|
449
|
+
ORDER BY c.embedding <=> %s::vector
|
|
450
|
+
LIMIT {CANDIDATE_LIMIT}
|
|
451
|
+
"""
|
|
452
|
+
vec_rows = conn.execute(
|
|
453
|
+
vec_sql, vec_params, prepare=prepare_flag
|
|
454
|
+
).fetchall()
|
|
455
|
+
|
|
456
|
+
# Per-chunk rank tables (built only when explain=True; zero overhead otherwise).
|
|
457
|
+
fts_rank_by_chunk: dict[str, int] = {}
|
|
458
|
+
fts_score_by_chunk: dict[str, float] = {}
|
|
459
|
+
vec_rank_by_chunk: dict[str, int] = {}
|
|
460
|
+
vec_cosine_by_chunk: dict[str, float] = {}
|
|
461
|
+
if explain:
|
|
462
|
+
fts_rank_by_chunk = {str(row[0]): i + 1 for i, row in enumerate(fts_rows)}
|
|
463
|
+
fts_score_by_chunk = {str(row[0]): float(row[4]) for row in fts_rows}
|
|
464
|
+
vec_rank_by_chunk = {str(row[0]): i + 1 for i, row in enumerate(vec_rows)}
|
|
465
|
+
vec_cosine_by_chunk = {str(row[0]): float(row[4]) for row in vec_rows}
|
|
466
|
+
|
|
467
|
+
rrf: dict[str, float] = {}
|
|
468
|
+
# Per-chunk RRF leg contributions (explain only).
|
|
469
|
+
rrf_fts: dict[str, float] = {}
|
|
470
|
+
rrf_vec: dict[str, float] = {}
|
|
471
|
+
# chunk_id → (document_id, chunk_index, content)
|
|
472
|
+
chunk_meta: dict[str, tuple[str, int, str]] = {}
|
|
473
|
+
for rank, row in enumerate(fts_rows):
|
|
474
|
+
cid = str(row[0])
|
|
475
|
+
contrib = rrf_contribution(rank, k=RRF_K)
|
|
476
|
+
rrf[cid] = rrf.get(cid, 0.0) + contrib
|
|
477
|
+
if explain:
|
|
478
|
+
rrf_fts[cid] = contrib
|
|
479
|
+
chunk_meta[cid] = (str(row[1]), int(row[2]), row[3])
|
|
480
|
+
for rank, row in enumerate(vec_rows):
|
|
481
|
+
cid = str(row[0])
|
|
482
|
+
contrib = rrf_contribution(rank, k=RRF_K)
|
|
483
|
+
rrf[cid] = rrf.get(cid, 0.0) + contrib
|
|
484
|
+
if explain:
|
|
485
|
+
rrf_vec[cid] = contrib
|
|
486
|
+
chunk_meta[cid] = (str(row[1]), int(row[2]), row[3])
|
|
487
|
+
|
|
488
|
+
# document_id → (best_rrf_score, best_chunk_index, snippet_content, best_chunk_id)
|
|
489
|
+
by_doc: dict[str, tuple[float, int, str, str]] = {}
|
|
490
|
+
for cid, rrf_val in rrf.items():
|
|
491
|
+
doc_id, chunk_idx, content = chunk_meta[cid]
|
|
492
|
+
prev = by_doc.get(doc_id)
|
|
493
|
+
if prev is None or rrf_val > prev[0]:
|
|
494
|
+
by_doc[doc_id] = (rrf_val, chunk_idx, content, cid)
|
|
495
|
+
|
|
496
|
+
if not by_doc:
|
|
497
|
+
return []
|
|
498
|
+
|
|
499
|
+
doc_ids = list(by_doc.keys())
|
|
500
|
+
doc_rows = conn.execute(
|
|
501
|
+
"""
|
|
502
|
+
SELECT d.id, d.title, d.content_type, d.tags, s.kind,
|
|
503
|
+
coalesce(d.sent_at, d.ingested_at) AS recency_ts
|
|
504
|
+
FROM documents d
|
|
505
|
+
LEFT JOIN sources s ON s.id = d.source_id
|
|
506
|
+
WHERE d.id = ANY(%s)
|
|
507
|
+
""",
|
|
508
|
+
(doc_ids,),
|
|
509
|
+
).fetchall()
|
|
510
|
+
docs = {str(r[0]): r for r in doc_rows}
|
|
511
|
+
|
|
512
|
+
now = datetime.now(tz=UTC)
|
|
513
|
+
results: list[SearchResult] = []
|
|
514
|
+
for doc_id, (rrf_score, best_chunk_idx, snippet_content, best_cid) in by_doc.items():
|
|
515
|
+
meta = docs.get(doc_id)
|
|
516
|
+
if meta is None:
|
|
517
|
+
# The document was deleted (e.g. `brain rm`) between the
|
|
518
|
+
# chunk-ranking queries and the per-document metadata fetch above;
|
|
519
|
+
# its chunk rows can still linger in ``by_doc``. Skip the now-
|
|
520
|
+
# orphaned doc instead of KeyError-ing the whole search. Task 2.2.
|
|
521
|
+
continue
|
|
522
|
+
|
|
523
|
+
score = rrf_score
|
|
524
|
+
recency_age_days: float | None = None
|
|
525
|
+
recency_boost_factor = 1.0
|
|
526
|
+
|
|
527
|
+
# Recency boost: multiplicative decay over coalesce(sent_at, ingested_at).
|
|
528
|
+
if recency_halflife_days is not None:
|
|
529
|
+
recency_ts = meta[5]
|
|
530
|
+
if recency_ts is not None:
|
|
531
|
+
# Make the timestamp tz-aware if the DB returned a naive value.
|
|
532
|
+
if recency_ts.tzinfo is None:
|
|
533
|
+
recency_ts = recency_ts.replace(tzinfo=UTC)
|
|
534
|
+
recency_age_days = max(0.0, (now - recency_ts).total_seconds() / 86400.0)
|
|
535
|
+
recency_boost_factor = 0.5 ** (recency_age_days / recency_halflife_days)
|
|
536
|
+
score = rrf_score * recency_boost_factor
|
|
537
|
+
|
|
538
|
+
# Snippet context expansion: pull neighboring chunks from the same doc.
|
|
539
|
+
if snippet_context_tokens > 0:
|
|
540
|
+
snippet_content = _expand_snippet_with_neighbors(
|
|
541
|
+
conn,
|
|
542
|
+
document_id=doc_id,
|
|
543
|
+
best_chunk_index=best_chunk_idx,
|
|
544
|
+
best_content=snippet_content,
|
|
545
|
+
embedder=embedder,
|
|
546
|
+
budget_tokens=snippet_context_tokens,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
# Human table shows 120-char preview; JSON/MCP gets the full stitched
|
|
550
|
+
# snippet (up to 4 × SNIPPET_LENGTH chars as a hard outer cap to guard
|
|
551
|
+
# against a degenerate token-counter blowing out the MCP payload).
|
|
552
|
+
if snippet_context_tokens > 0:
|
|
553
|
+
snippet = snippet_content
|
|
554
|
+
else:
|
|
555
|
+
snippet = snippet_content[:SNIPPET_LENGTH]
|
|
556
|
+
# Hard cap: 4 × SNIPPET_LENGTH prevents degenerate oversized payloads.
|
|
557
|
+
if len(snippet) > 4 * SNIPPET_LENGTH:
|
|
558
|
+
snippet = snippet[: 4 * SNIPPET_LENGTH]
|
|
559
|
+
|
|
560
|
+
# Build the optional ranking diagnostic payload.
|
|
561
|
+
explain_obj: SearchExplanation | None = None
|
|
562
|
+
if explain:
|
|
563
|
+
explain_obj = SearchExplanation(
|
|
564
|
+
fts_rank=fts_rank_by_chunk.get(best_cid),
|
|
565
|
+
fts_score=fts_score_by_chunk.get(best_cid),
|
|
566
|
+
fts_rrf_contribution=rrf_fts.get(best_cid, 0.0),
|
|
567
|
+
vector_rank=vec_rank_by_chunk.get(best_cid),
|
|
568
|
+
vector_cosine=vec_cosine_by_chunk.get(best_cid),
|
|
569
|
+
vector_rrf_contribution=rrf_vec.get(best_cid, 0.0),
|
|
570
|
+
rrf_score=rrf_score,
|
|
571
|
+
recency_age_days=recency_age_days,
|
|
572
|
+
recency_boost=recency_boost_factor,
|
|
573
|
+
final_score=score,
|
|
574
|
+
best_chunk_id=best_cid,
|
|
575
|
+
best_chunk_index=best_chunk_idx,
|
|
576
|
+
matched_filters={
|
|
577
|
+
"source_kind": source_kind,
|
|
578
|
+
"tag": tag,
|
|
579
|
+
"since_days": since_days,
|
|
580
|
+
"fts_only": fts_only,
|
|
581
|
+
# Q1-C additions — datetimes serialize as ISO strings
|
|
582
|
+
# so the dict round-trips through JSON without a custom
|
|
583
|
+
# encoder. ``None`` values stay in the dict; the
|
|
584
|
+
# explain formatter skips them at render time.
|
|
585
|
+
"person_keys": list(person_keys) if person_keys else None,
|
|
586
|
+
"person_display_name": person_display_name,
|
|
587
|
+
"after": after.isoformat() if after is not None else None,
|
|
588
|
+
"before": before.isoformat() if before is not None else None,
|
|
589
|
+
"content_type": content_type,
|
|
590
|
+
"thread_id": thread_id,
|
|
591
|
+
"draft": draft,
|
|
592
|
+
"without_tag": without_tag,
|
|
593
|
+
},
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
results.append(
|
|
597
|
+
SearchResult(
|
|
598
|
+
document_id=doc_id,
|
|
599
|
+
title=meta[1],
|
|
600
|
+
content_type=meta[2],
|
|
601
|
+
tags=list(meta[3] or []),
|
|
602
|
+
source_kind=meta[4],
|
|
603
|
+
snippet=snippet,
|
|
604
|
+
score=score,
|
|
605
|
+
explain=explain_obj,
|
|
606
|
+
)
|
|
607
|
+
)
|
|
608
|
+
results.sort(key=lambda r: r.score, reverse=True)
|
|
609
|
+
# Defensive floor. A non-positive ``limit`` reaching this far is a caller
|
|
610
|
+
# bug — the CLI (Typer ``min=1``) and MCP (INVALID_PARAMS) boundaries reject
|
|
611
|
+
# it first. Clamp to 1 so a stray negative never silently slices the tail
|
|
612
|
+
# off the ranked list (``results[:-3]`` would drop the 3 lowest-ranked docs
|
|
613
|
+
# and quietly return wrong data). Task 2.10.
|
|
614
|
+
effective_limit = max(1, limit)
|
|
615
|
+
return results[:effective_limit]
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
# ---------------------------------------------------------------------------
|
|
619
|
+
# Snippet-context expansion helper
|
|
620
|
+
# ---------------------------------------------------------------------------
|
|
621
|
+
|
|
622
|
+
# Maximum number of neighbors on each side to fetch per finalist.
|
|
623
|
+
_NEIGHBOR_WINDOW = 2
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _expand_snippet_with_neighbors(
|
|
627
|
+
conn: psycopg.Connection,
|
|
628
|
+
*,
|
|
629
|
+
document_id: str,
|
|
630
|
+
best_chunk_index: int,
|
|
631
|
+
best_content: str,
|
|
632
|
+
embedder: Embedder,
|
|
633
|
+
budget_tokens: int,
|
|
634
|
+
) -> str:
|
|
635
|
+
"""Expand a snippet by stitching neighboring chunks around the best match.
|
|
636
|
+
|
|
637
|
+
Fetches up to :data:`_NEIGHBOR_WINDOW` chunks on each side of
|
|
638
|
+
``best_chunk_index`` within the same ``document_id``. Walks outward
|
|
639
|
+
from the matched chunk, prepending the preceding neighbor and appending
|
|
640
|
+
the following neighbor alternately, stopping when adding the next whole
|
|
641
|
+
neighbor would exceed ``base_tokens + budget_tokens``. A neighbor is
|
|
642
|
+
either included in full or not at all (no mid-chunk slicing).
|
|
643
|
+
|
|
644
|
+
Returns the stitched string. The caller applies any final display
|
|
645
|
+
truncation (e.g. 120-char table preview). A hard outer cap of
|
|
646
|
+
``4 × SNIPPET_LENGTH`` chars guards against a degenerate token-counter.
|
|
647
|
+
"""
|
|
648
|
+
lo = max(0, best_chunk_index - _NEIGHBOR_WINDOW)
|
|
649
|
+
hi = best_chunk_index + _NEIGHBOR_WINDOW
|
|
650
|
+
neighbor_rows = conn.execute(
|
|
651
|
+
"""
|
|
652
|
+
SELECT chunk_index, content
|
|
653
|
+
FROM chunks
|
|
654
|
+
WHERE document_id = %s
|
|
655
|
+
AND chunk_index BETWEEN %s AND %s
|
|
656
|
+
ORDER BY chunk_index
|
|
657
|
+
""",
|
|
658
|
+
(document_id, lo, hi),
|
|
659
|
+
).fetchall()
|
|
660
|
+
|
|
661
|
+
# Index the fetched rows by chunk_index for O(1) lookup.
|
|
662
|
+
by_idx: dict[int, str] = {int(r[0]): r[1] for r in neighbor_rows}
|
|
663
|
+
|
|
664
|
+
# The matched chunk is always included in full.
|
|
665
|
+
matched = by_idx.get(best_chunk_index, best_content)
|
|
666
|
+
|
|
667
|
+
before: list[str] = [] # chunks with index < best, in ascending order
|
|
668
|
+
after: list[str] = [] # chunks with index > best, in ascending order
|
|
669
|
+
budget_used = 0
|
|
670
|
+
|
|
671
|
+
# Walk outward alternately, consuming the token budget.
|
|
672
|
+
prev_idx = best_chunk_index - 1
|
|
673
|
+
next_idx = best_chunk_index + 1
|
|
674
|
+
while budget_used < budget_tokens:
|
|
675
|
+
added = False
|
|
676
|
+
if prev_idx >= lo and prev_idx in by_idx:
|
|
677
|
+
chunk = by_idx[prev_idx]
|
|
678
|
+
cost = embedder.count_tokens(chunk)
|
|
679
|
+
if budget_used + cost <= budget_tokens:
|
|
680
|
+
before.insert(0, chunk)
|
|
681
|
+
budget_used += cost
|
|
682
|
+
prev_idx -= 1
|
|
683
|
+
added = True
|
|
684
|
+
else:
|
|
685
|
+
prev_idx = -1 # stop prepending — budget exhausted
|
|
686
|
+
if next_idx <= hi and next_idx in by_idx:
|
|
687
|
+
chunk = by_idx[next_idx]
|
|
688
|
+
cost = embedder.count_tokens(chunk)
|
|
689
|
+
if budget_used + cost <= budget_tokens:
|
|
690
|
+
after.append(chunk)
|
|
691
|
+
budget_used += cost
|
|
692
|
+
next_idx += 1
|
|
693
|
+
added = True
|
|
694
|
+
else:
|
|
695
|
+
next_idx = hi + 1 # stop appending — budget exhausted
|
|
696
|
+
if not added:
|
|
697
|
+
break # no more neighbors in range or budget fully spent
|
|
698
|
+
|
|
699
|
+
parts = before + [matched] + after
|
|
700
|
+
stitched = "\n\n".join(parts)
|
|
701
|
+
|
|
702
|
+
# Hard outer cap.
|
|
703
|
+
cap = 4 * SNIPPET_LENGTH
|
|
704
|
+
return stitched[:cap] if len(stitched) > cap else stitched
|