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.
Files changed (273) hide show
  1. brain/__init__.py +0 -0
  2. brain/__main__.py +18 -0
  3. brain/_capture_command.py +445 -0
  4. brain/_compose.py +52 -0
  5. brain/activity.py +206 -0
  6. brain/ask.py +631 -0
  7. brain/audio.py +591 -0
  8. brain/backfill/__init__.py +12 -0
  9. brain/backfill/search_extras.py +141 -0
  10. brain/backfill/source_rows.py +101 -0
  11. brain/bin/__init__.py +1 -0
  12. brain/bin/_launcher.py +107 -0
  13. brain/bin/down.py +8 -0
  14. brain/bin/launchd.py +268 -0
  15. brain/bin/monitor.py +570 -0
  16. brain/bin/rebuild.py +8 -0
  17. brain/bin/status.py +8 -0
  18. brain/bin/up.py +8 -0
  19. brain/brief.py +272 -0
  20. brain/capture.py +49 -0
  21. brain/chat.py +293 -0
  22. brain/cli.py +9760 -0
  23. brain/cli_claude.py +81 -0
  24. brain/cli_connect.py +285 -0
  25. brain/cli_demo.py +266 -0
  26. brain/config.py +1949 -0
  27. brain/connect.py +925 -0
  28. brain/db.py +540 -0
  29. brain/demo/__init__.py +452 -0
  30. brain/demo/corpus/manifest.json +403 -0
  31. brain/demo/embedder.py +74 -0
  32. brain/durations.py +84 -0
  33. brain/edit_session.py +156 -0
  34. brain/editor.py +67 -0
  35. brain/elicit/__init__.py +16 -0
  36. brain/elicit/detectors.py +250 -0
  37. brain/elicit/drafter.py +70 -0
  38. brain/elicit/queue.py +220 -0
  39. brain/elicit/schema.py +48 -0
  40. brain/elicit/session.py +445 -0
  41. brain/embedding_targets.py +54 -0
  42. brain/embeddings.py +424 -0
  43. brain/enrichment.py +808 -0
  44. brain/errors.py +357 -0
  45. brain/eval/__init__.py +129 -0
  46. brain/eval/answer_eval.py +281 -0
  47. brain/eval/baseline.py +265 -0
  48. brain/eval/concept_extraction.py +378 -0
  49. brain/eval/corpus.py +152 -0
  50. brain/eval/errors.py +19 -0
  51. brain/eval/graph_baseline.py +226 -0
  52. brain/eval/graph_retrieval.py +202 -0
  53. brain/eval/graph_runner.py +319 -0
  54. brain/eval/metrics.py +101 -0
  55. brain/eval/runner.py +223 -0
  56. brain/format.py +783 -0
  57. brain/gaps.py +390 -0
  58. brain/graph_rag/__init__.py +94 -0
  59. brain/graph_rag/_retrieval_common.py +113 -0
  60. brain/graph_rag/aggregates.py +303 -0
  61. brain/graph_rag/aliases/__init__.py +583 -0
  62. brain/graph_rag/backends/__init__.py +10 -0
  63. brain/graph_rag/backends/_age_helpers.py +473 -0
  64. brain/graph_rag/backends/age.py +782 -0
  65. brain/graph_rag/backends/base.py +272 -0
  66. brain/graph_rag/build.py +344 -0
  67. brain/graph_rag/communities.py +644 -0
  68. brain/graph_rag/communities_summary.py +437 -0
  69. brain/graph_rag/concepts.py +202 -0
  70. brain/graph_rag/cooccur.py +193 -0
  71. brain/graph_rag/cross_type.py +312 -0
  72. brain/graph_rag/extract.py +885 -0
  73. brain/graph_rag/fuse.py +371 -0
  74. brain/graph_rag/global_.py +412 -0
  75. brain/graph_rag/grouping.py +372 -0
  76. brain/graph_rag/person_resolver.py +167 -0
  77. brain/graph_rag/reconcile.py +792 -0
  78. brain/graph_rag/relational.py +353 -0
  79. brain/graph_rag/retrieve.py +526 -0
  80. brain/graph_rag/router.py +288 -0
  81. brain/graph_rag/schema.py +320 -0
  82. brain/graph_rag/sync.py +237 -0
  83. brain/graph_rag/tenancy.py +43 -0
  84. brain/graph_rag/themes.py +501 -0
  85. brain/graph_rag/weighting.py +202 -0
  86. brain/ingest/__init__.py +1926 -0
  87. brain/ingest/chunker.py +249 -0
  88. brain/ingest/docx.py +40 -0
  89. brain/ingest/gmail.py +621 -0
  90. brain/ingest/markdown.py +37 -0
  91. brain/ingest/pdf.py +61 -0
  92. brain/ingest/stdin.py +22 -0
  93. brain/ingest/sub_tokens.py +91 -0
  94. brain/ingest/text.py +16 -0
  95. brain/interactions.py +205 -0
  96. brain/maintenance.py +355 -0
  97. brain/mcp_server.py +3405 -0
  98. brain/migrations/001_init.sql +43 -0
  99. brain/migrations/002_qwen3_embedding.sql +17 -0
  100. brain/migrations/003_vault_model.sql +41 -0
  101. brain/migrations/004_relax_content_hash_uniqueness.sql +18 -0
  102. brain/migrations/005_derived_links.sql +67 -0
  103. brain/migrations/006_dedup_file_by_source_path.sql +25 -0
  104. brain/migrations/007_email_thread_and_draft.sql +15 -0
  105. brain/migrations/008_gmail_thread_unique.sql +11 -0
  106. brain/migrations/009_chunks_weighted_tsv.sql +28 -0
  107. brain/migrations/010_interactions.sql +30 -0
  108. brain/migrations/011_documents_summary.sql +23 -0
  109. brain/migrations/012_graphrag.sql +171 -0
  110. brain/migrations/013_graphrag_communities.sql +125 -0
  111. brain/migrations/014_graphrag_community_summary_hash.sql +33 -0
  112. brain/migrations/015_interactions_graph_targets.sql +89 -0
  113. brain/migrations/016_index_hygiene.sql +61 -0
  114. brain/migrations/017_elicit.sql +30 -0
  115. brain/migrations/018_review_gap_signal_kinds.sql +40 -0
  116. brain/migrations/019_search_queries.sql +35 -0
  117. brain/migrations/020_link_suggestions.sql +40 -0
  118. brain/migrations/021_timeline_doc_date.sql +34 -0
  119. brain/migrations/022_link_suggestions_undirected.sql +84 -0
  120. brain/migrations/023_search_queries_fts_count.sql +28 -0
  121. brain/quartz_overrides/__init__.py +8 -0
  122. brain/quartz_overrides/quartz/bootstrap-cli.mjs +65 -0
  123. brain/quartz_overrides/quartz/build.ts +568 -0
  124. brain/quartz_overrides/quartz/cli/args.js +152 -0
  125. brain/quartz_overrides/quartz/cli/build_partial_handler.js +544 -0
  126. brain/quartz_overrides/quartz/cli/handlers.js +636 -0
  127. brain/quartz_overrides/quartz/components/CommandPalette.tsx +172 -0
  128. brain/quartz_overrides/quartz/components/Explorer.tsx +198 -0
  129. brain/quartz_overrides/quartz/components/Footer.tsx +27 -0
  130. brain/quartz_overrides/quartz/components/Graph.tsx +468 -0
  131. brain/quartz_overrides/quartz/components/PageTitle.tsx +72 -0
  132. brain/quartz_overrides/quartz/components/RelatedDocs.tsx +38 -0
  133. brain/quartz_overrides/quartz/components/Search.tsx +161 -0
  134. brain/quartz_overrides/quartz/components/SummaryLede.tsx +72 -0
  135. brain/quartz_overrides/quartz/components/index.ts +92 -0
  136. brain/quartz_overrides/quartz/components/pages/TagContent.tsx +272 -0
  137. brain/quartz_overrides/quartz/components/scripts/commandPalette.inline.ts +665 -0
  138. brain/quartz_overrides/quartz/components/scripts/explorer.inline.ts +768 -0
  139. brain/quartz_overrides/quartz/components/scripts/graph.inline.ts +2302 -0
  140. brain/quartz_overrides/quartz/components/scripts/relatedDocs.inline.ts +163 -0
  141. brain/quartz_overrides/quartz/components/scripts/search.inline.ts +1011 -0
  142. brain/quartz_overrides/quartz/plugins/emitters/contentIndex.ts +546 -0
  143. brain/quartz_overrides/quartz/plugins/transformers/codeCopy.ts +94 -0
  144. brain/quartz_overrides/quartz/plugins/transformers/derivedFenceMark.ts +302 -0
  145. brain/quartz_overrides/quartz/plugins/transformers/emailThread.ts +148 -0
  146. brain/quartz_overrides/quartz/plugins/transformers/emptyDoorFilter.ts +213 -0
  147. brain/quartz_overrides/quartz/plugins/transformers/index.ts +114 -0
  148. brain/quartz_overrides/quartz/plugins/transformers/linkKindMark.ts +205 -0
  149. brain/quartz_overrides/quartz/plugins/transformers/linkSourceTag.ts +104 -0
  150. brain/quartz_overrides/quartz/plugins/transformers/relativeDate.ts +100 -0
  151. brain/quartz_overrides/quartz/plugins/transformers/reloadSignal.ts +131 -0
  152. brain/quartz_overrides/quartz/processors/parse.ts +371 -0
  153. brain/quartz_overrides/quartz/processors/parser_cache.ts +78 -0
  154. brain/quartz_overrides/quartz/static/brain-logo-dark.png +0 -0
  155. brain/quartz_overrides/quartz/static/brain-logo-light.png +0 -0
  156. brain/quartz_overrides/quartz/static/codeCopy.js +196 -0
  157. brain/quartz_overrides/quartz/static/emailThread.js +334 -0
  158. brain/quartz_overrides/quartz/static/favicon.ico +0 -0
  159. brain/quartz_overrides/quartz/static/icon.png +0 -0
  160. brain/quartz_overrides/quartz/static/linkSourceTag.js +104 -0
  161. brain/quartz_overrides/quartz/static/relativeDate.js +142 -0
  162. brain/quartz_overrides/quartz/static/reload.js +168 -0
  163. brain/quartz_overrides/quartz/styles/brain/_article.scss +252 -0
  164. brain/quartz_overrides/quartz/styles/brain/_atmosphere.scss +113 -0
  165. brain/quartz_overrides/quartz/styles/brain/_callouts.scss +180 -0
  166. brain/quartz_overrides/quartz/styles/brain/_cmdk.scss +7 -0
  167. brain/quartz_overrides/quartz/styles/brain/_code.scss +208 -0
  168. brain/quartz_overrides/quartz/styles/brain/_command_palette.scss +369 -0
  169. brain/quartz_overrides/quartz/styles/brain/_email_thread.scss +228 -0
  170. brain/quartz_overrides/quartz/styles/brain/_explorer.scss +142 -0
  171. brain/quartz_overrides/quartz/styles/brain/_home.scss +182 -0
  172. brain/quartz_overrides/quartz/styles/brain/_links.scss +322 -0
  173. brain/quartz_overrides/quartz/styles/brain/_marginalia.scss +117 -0
  174. brain/quartz_overrides/quartz/styles/brain/_motion.scss +175 -0
  175. brain/quartz_overrides/quartz/styles/brain/_people_hub.scss +100 -0
  176. brain/quartz_overrides/quartz/styles/brain/_related_docs.scss +137 -0
  177. brain/quartz_overrides/quartz/styles/brain/_search.scss +252 -0
  178. brain/quartz_overrides/quartz/styles/brain/_sidebar.scss +468 -0
  179. brain/quartz_overrides/quartz/styles/brain/_summary_lede.scss +56 -0
  180. brain/quartz_overrides/quartz/styles/brain/_surface.scss +43 -0
  181. brain/quartz_overrides/quartz/styles/brain/_tag_content.scss +118 -0
  182. brain/quartz_overrides/quartz/styles/brain/_tokens.scss +197 -0
  183. brain/quartz_overrides/quartz/styles/brain/_typography.scss +92 -0
  184. brain/quartz_overrides/quartz/styles/custom.scss +89 -0
  185. brain/quartz_overrides/quartz/styles/graph.scss +505 -0
  186. brain/quartz_overrides/quartz/util/ctx.ts +92 -0
  187. brain/quartz_overrides/quartz/util/fastpath_manifest.ts +608 -0
  188. brain/quartz_overrides/quartz/util/path.ts +358 -0
  189. brain/quartz_overrides/quartz/util/sourceIcons.ts +55 -0
  190. brain/quartz_overrides/quartz.config.ts +270 -0
  191. brain/quartz_overrides/quartz.layout.ts +314 -0
  192. brain/queries.py +1188 -0
  193. brain/rank_fusion.py +8 -0
  194. brain/resurface.py +210 -0
  195. brain/review/__init__.py +26 -0
  196. brain/review/emit.py +27 -0
  197. brain/review/queries.py +436 -0
  198. brain/review/render.py +196 -0
  199. brain/review/scans.py +355 -0
  200. brain/review/weekly.py +413 -0
  201. brain/search.py +704 -0
  202. brain/set_similarity.py +15 -0
  203. brain/setup.py +1205 -0
  204. brain/tags.py +56 -0
  205. brain/templates/Caddyfile.j2 +9 -0
  206. brain/templates/__init__.py +1 -0
  207. brain/templates/bin/__init__.py +1 -0
  208. brain/templates/bin/_brain-brief-fg.sh +25 -0
  209. brain/templates/bin/_brain-build-fg.sh +53 -0
  210. brain/templates/bin/_brain-watcher-fg.sh +65 -0
  211. brain/templates/bin/brain-down.sh +89 -0
  212. brain/templates/bin/brain-status.sh +83 -0
  213. brain/templates/bin/brain-up.sh +221 -0
  214. brain/templates/docker/age/Dockerfile +79 -0
  215. brain/templates/docker-compose.stock.yml.j2 +26 -0
  216. brain/templates/docker-compose.yml.j2 +34 -0
  217. brain/templates/env.example +190 -0
  218. brain/templates/launchd/__init__.py +1 -0
  219. brain/templates/launchd/com.brain.brief.plist.j2 +45 -0
  220. brain/templates/launchd/com.brain.build.plist.j2 +46 -0
  221. brain/templates/launchd/com.brain.watcher.plist.j2 +46 -0
  222. brain/templates/skill/SKILL.md +63 -0
  223. brain/templates/skill/__init__.py +1 -0
  224. brain/timeline.py +834 -0
  225. brain/todo.py +124 -0
  226. brain/uninstall.py +185 -0
  227. brain/vault/__init__.py +115 -0
  228. brain/vault/_atomic.py +25 -0
  229. brain/vault/daily_index.py +228 -0
  230. brain/vault/derived_links/__init__.py +50 -0
  231. brain/vault/derived_links/directory.py +683 -0
  232. brain/vault/derived_links/fence.py +408 -0
  233. brain/vault/derived_links/gws.py +64 -0
  234. brain/vault/derived_links/participants.py +143 -0
  235. brain/vault/derived_links/pass_runner.py +362 -0
  236. brain/vault/derived_links/rules.py +137 -0
  237. brain/vault/export.py +683 -0
  238. brain/vault/frontmatter.py +165 -0
  239. brain/vault/graph.py +620 -0
  240. brain/vault/graph_format.py +388 -0
  241. brain/vault/link_rewrite.py +235 -0
  242. brain/vault/links.py +260 -0
  243. brain/vault/note_builder.py +211 -0
  244. brain/vault/paths.py +55 -0
  245. brain/vault/quartz_overlay.py +236 -0
  246. brain/vault/rename.py +591 -0
  247. brain/vault/resolver.py +304 -0
  248. brain/vault/slug.py +127 -0
  249. brain/vault/sync.py +1513 -0
  250. brain/vault/sync_summaries.py +264 -0
  251. brain/vault/templates.py +145 -0
  252. brain/vault/watch.py +1052 -0
  253. brain/wiki/__init__.py +6 -0
  254. brain/wiki/_github_slugger.py +76 -0
  255. brain/wiki/_person_name.py +314 -0
  256. brain/wiki/build_homepage.py +541 -0
  257. brain/wiki/build_partial.py +273 -0
  258. brain/wiki/build_people.py +934 -0
  259. brain/wiki/build_related.py +758 -0
  260. brain/wiki/build_swap.py +585 -0
  261. brain/wiki/build_watcher.py +975 -0
  262. brain/wiki/edit_classifier.py +215 -0
  263. brain/wiki/errors.py +10 -0
  264. brain/wiki/fastpath_manifest.py +475 -0
  265. brain/wiki/fastpath_state.py +174 -0
  266. brain/wiki/install.py +296 -0
  267. brain/wiki/slug.py +111 -0
  268. secondbrain_py-0.2.1.dist-info/METADATA +195 -0
  269. secondbrain_py-0.2.1.dist-info/RECORD +273 -0
  270. secondbrain_py-0.2.1.dist-info/WHEEL +5 -0
  271. secondbrain_py-0.2.1.dist-info/entry_points.txt +11 -0
  272. secondbrain_py-0.2.1.dist-info/licenses/LICENSE +21 -0
  273. 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