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/embeddings.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""Embedder backends — Arctic, Voyage, Qwen3 — selected at setup time.
|
|
2
|
+
|
|
3
|
+
Three implementations satisfy the :class:`brain.ingest.Embedder` Protocol:
|
|
4
|
+
|
|
5
|
+
- :class:`ArcticEmbedder` — Snowflake Arctic Embed v2 over local Ollama (default).
|
|
6
|
+
Native 1024-dim, free, indexable under pgvector's HNSW cap.
|
|
7
|
+
- :class:`VoyageEmbedder` — Voyage AI SDK. 1024-dim, paid SaaS.
|
|
8
|
+
- :class:`Qwen3Embedder` — Qwen3-Embedding-8B over local Ollama. 4096-dim,
|
|
9
|
+
free, but exceeds pgvector's HNSW cap so search uses sequential scan.
|
|
10
|
+
- :class:`NullEmbedder` — FTS-only backend (``BRAIN_EMBEDDER=none``). Produces
|
|
11
|
+
no vectors; for users with no Ollama. Ingest + lexical search + doctor work;
|
|
12
|
+
the vector leg of hybrid search is skipped.
|
|
13
|
+
|
|
14
|
+
Token counting is via tiktoken (cl100k_base) — offline and good enough for
|
|
15
|
+
chunker budgeting. Each backend's :meth:`embed` accepts ``input_type`` to
|
|
16
|
+
dispatch query vs document prompt formatting; the formatting is per-backend
|
|
17
|
+
because each model is trained with a different convention (see comments).
|
|
18
|
+
"""
|
|
19
|
+
from typing import Any, NoReturn
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
import tiktoken
|
|
23
|
+
|
|
24
|
+
from .config import Config, ConfigError, keep_alive_wire_value
|
|
25
|
+
from .errors import EmbedError
|
|
26
|
+
from .ingest import Embedder
|
|
27
|
+
|
|
28
|
+
# Shared Ollama transport defaults — both Ollama-backed embedders use these.
|
|
29
|
+
_DEFAULT_OLLAMA_BATCH = 32
|
|
30
|
+
_DEFAULT_OLLAMA_TIMEOUT_S = 60.0
|
|
31
|
+
|
|
32
|
+
# Module-level keep_alive fallback — used when an embedder is constructed
|
|
33
|
+
# without an explicit ``keep_alive`` kwarg (e.g. in tests or legacy call
|
|
34
|
+
# sites). Production always threads the value from ``Config.ollama_keep_alive``
|
|
35
|
+
# (set at construction time, not re-read per request).
|
|
36
|
+
_DEFAULT_OLLAMA_KEEP_ALIVE = "30m"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Wire-boundary sentinel coercion now lives in ``brain.config`` (shared with
|
|
40
|
+
# the chat path in ``brain.chat``); kept under the historical private name so
|
|
41
|
+
# existing call sites and tests stay valid.
|
|
42
|
+
_keep_alive_payload = keep_alive_wire_value
|
|
43
|
+
|
|
44
|
+
# Arctic Embed v2 query prefix per Snowflake's HF model card guidance:
|
|
45
|
+
# https://huggingface.co/Snowflake/snowflake-arctic-embed-l-v2.0
|
|
46
|
+
# "Use the query prefix below (just on the query)" → 'query: '. Documents
|
|
47
|
+
# get no prefix.
|
|
48
|
+
_ARCTIC_QUERY_PREFIX = "query: "
|
|
49
|
+
_ARCTIC_DEFAULT_MODEL = "snowflake-arctic-embed2"
|
|
50
|
+
|
|
51
|
+
# Qwen3-Embedding query mode prepends an Instruct prompt that primes the model
|
|
52
|
+
# for retrieval over a domain-specific corpus. Documents skip the prefix.
|
|
53
|
+
_QWEN3_QUERY_TASK = (
|
|
54
|
+
"Given a search query, retrieve relevant passages from a personal knowledge "
|
|
55
|
+
"base of career documents, transcripts, and emails"
|
|
56
|
+
)
|
|
57
|
+
_QWEN3_DEFAULT_MODEL = "qwen3-embedding:8b"
|
|
58
|
+
|
|
59
|
+
# Voyage SDK model. Per the plan we pin the named-current production model
|
|
60
|
+
# (voyage-3.5). voyage-4 also works against the same SDK signature; bump here
|
|
61
|
+
# if the user wants the newer generation.
|
|
62
|
+
_VOYAGE_MODEL = "voyage-3.5"
|
|
63
|
+
_VOYAGE_DEFAULT_BATCH = 128
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# The FTS-only backend surfaces this exact message everywhere it must explain
|
|
67
|
+
# that semantic search is off. Kept as a module constant so the exception class
|
|
68
|
+
# and any future reference stay in lockstep (DRY).
|
|
69
|
+
_EMBED_DISABLED_MESSAGE = (
|
|
70
|
+
"semantic search is disabled (BRAIN_EMBEDDER=none) — install Ollama, set "
|
|
71
|
+
"BRAIN_EMBEDDER=arctic, then run 'brain init' and 'brain reembed' to enable it"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class EmbedDisabledError(EmbedError):
|
|
76
|
+
"""Raised when an embed is attempted under the FTS-only ``none`` backend.
|
|
77
|
+
|
|
78
|
+
A sibling of :class:`OllamaEmbedError` / :class:`VoyageEmbedError` — it is an
|
|
79
|
+
:class:`~brain.errors.EmbedError`, so every ``except EmbedError`` handler
|
|
80
|
+
(the MCP server's ``_wrap_embed_error``, ``brain eval``'s per-query
|
|
81
|
+
tolerance) catches it uniformly. Distinct from the transport-failure
|
|
82
|
+
siblings because nothing *failed*: the ``NullEmbedder`` never produces
|
|
83
|
+
vectors by design, so any code path that reaches an actual embed call under
|
|
84
|
+
the ``none`` backend (e.g. ``brain ask`` / ``graphrag --mode fuse``) gets a
|
|
85
|
+
clear "install Ollama to enable it" message rather than a crash. The
|
|
86
|
+
ingest / search / doctor paths never reach it — they degrade earlier via the
|
|
87
|
+
duck-typed ``produces_embeddings`` flag.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class OllamaEmbedError(EmbedError):
|
|
92
|
+
"""Raised when an Ollama-backed embed call fails (network / 4xx / 5xx / shape)."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class VoyageEmbedError(EmbedError):
|
|
96
|
+
"""Raised when a Voyage SDK embed call fails (transport / API / rate-limit / shape).
|
|
97
|
+
|
|
98
|
+
The Voyage sibling of :class:`OllamaEmbedError`: :meth:`VoyageEmbedder.embed`
|
|
99
|
+
wraps any ``voyageai.error.VoyageError`` (its rate-limit / connection /
|
|
100
|
+
timeout / API subclasses) in this so callers get the same typed embed error
|
|
101
|
+
the Ollama backends raise instead of a leaked SDK exception. The originating
|
|
102
|
+
SDK error is preserved as ``__cause__`` (``raise ... from e``).
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _OllamaEmbedderBase:
|
|
107
|
+
"""Shared HTTP transport, batching, and tokenizer for Ollama-hosted models.
|
|
108
|
+
|
|
109
|
+
Subclasses declare ``dim`` (native vector size) and override
|
|
110
|
+
:meth:`_format_query` to apply the model-specific query-side prompt.
|
|
111
|
+
Document-side text is sent verbatim by default.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
dim: int # subclasses set this as a class attribute
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
host: str,
|
|
120
|
+
model: str,
|
|
121
|
+
client: httpx.Client | None = None,
|
|
122
|
+
batch_size: int = _DEFAULT_OLLAMA_BATCH,
|
|
123
|
+
timeout: float = _DEFAULT_OLLAMA_TIMEOUT_S,
|
|
124
|
+
keep_alive: str = _DEFAULT_OLLAMA_KEEP_ALIVE,
|
|
125
|
+
) -> None:
|
|
126
|
+
self._host = host
|
|
127
|
+
self._model = model
|
|
128
|
+
self._batch_size = batch_size
|
|
129
|
+
self._keep_alive = keep_alive
|
|
130
|
+
self._tokenizer = tiktoken.get_encoding("cl100k_base")
|
|
131
|
+
if client is not None:
|
|
132
|
+
self._client = client
|
|
133
|
+
else:
|
|
134
|
+
self._client = httpx.Client(
|
|
135
|
+
base_url=host, timeout=httpx.Timeout(timeout)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _format_query(self, text: str) -> str:
|
|
139
|
+
"""Subclass hook: apply the model-specific query-side prompt."""
|
|
140
|
+
raise NotImplementedError
|
|
141
|
+
|
|
142
|
+
def embed(
|
|
143
|
+
self, texts: list[str], *, input_type: str = "document"
|
|
144
|
+
) -> list[list[float]]:
|
|
145
|
+
"""Embed ``texts`` in batches of ``batch_size`` and return all vectors in order.
|
|
146
|
+
|
|
147
|
+
With ``input_type="query"`` each text is wrapped with the subclass's
|
|
148
|
+
:meth:`_format_query`; ``"document"`` (the default) sends the raw
|
|
149
|
+
text. An empty input returns an empty list with no HTTP I/O.
|
|
150
|
+
Raises :class:`OllamaEmbedError` on any HTTP / decode / shape error.
|
|
151
|
+
"""
|
|
152
|
+
if not texts:
|
|
153
|
+
return []
|
|
154
|
+
prepared = (
|
|
155
|
+
[self._format_query(t) for t in texts]
|
|
156
|
+
if input_type == "query"
|
|
157
|
+
else list(texts)
|
|
158
|
+
)
|
|
159
|
+
out: list[list[float]] = []
|
|
160
|
+
for start in range(0, len(prepared), self._batch_size):
|
|
161
|
+
batch = prepared[start : start + self._batch_size]
|
|
162
|
+
out.extend(self._embed_batch(batch))
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
def _embed_batch(self, batch: list[str]) -> list[list[float]]:
|
|
166
|
+
"""Send one /api/embed request and return its vectors."""
|
|
167
|
+
try:
|
|
168
|
+
response = self._client.post(
|
|
169
|
+
"/api/embed",
|
|
170
|
+
json={
|
|
171
|
+
"model": self._model,
|
|
172
|
+
"input": batch,
|
|
173
|
+
"keep_alive": _keep_alive_payload(self._keep_alive),
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
response.raise_for_status()
|
|
177
|
+
payload = response.json()
|
|
178
|
+
except httpx.HTTPStatusError as e:
|
|
179
|
+
body = e.response.text if e.response is not None else "<no body>"
|
|
180
|
+
raise OllamaEmbedError(
|
|
181
|
+
f"Ollama returned HTTP {e.response.status_code}: {body}"
|
|
182
|
+
) from e
|
|
183
|
+
except httpx.HTTPError as e:
|
|
184
|
+
raise OllamaEmbedError(f"Ollama request failed: {e}") from e
|
|
185
|
+
except ValueError as e:
|
|
186
|
+
# json.JSONDecodeError is a ValueError — a 200 OK with non-JSON
|
|
187
|
+
# body would otherwise leak as a raw decode error to callers
|
|
188
|
+
# that contract for OllamaEmbedError.
|
|
189
|
+
raise OllamaEmbedError(f"Ollama returned non-JSON response: {e}") from e
|
|
190
|
+
embeddings = payload.get("embeddings")
|
|
191
|
+
if not isinstance(embeddings, list):
|
|
192
|
+
raise OllamaEmbedError(
|
|
193
|
+
f"Ollama response missing 'embeddings' list: {payload!r}"
|
|
194
|
+
)
|
|
195
|
+
if len(embeddings) != len(batch):
|
|
196
|
+
raise OllamaEmbedError(
|
|
197
|
+
f"Ollama returned {len(embeddings)} embeddings for {len(batch)} inputs"
|
|
198
|
+
)
|
|
199
|
+
return [list(v) for v in embeddings]
|
|
200
|
+
|
|
201
|
+
def count_tokens(self, text: str) -> int:
|
|
202
|
+
"""Return the number of tokens in ``text`` per the local tiktoken tokenizer."""
|
|
203
|
+
return len(self._tokenizer.encode(text))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class Qwen3Embedder(_OllamaEmbedderBase):
|
|
207
|
+
"""Ollama-hosted Qwen3-Embedding-8B (4096 native dims).
|
|
208
|
+
|
|
209
|
+
Query mode prepends an ``Instruct: ... \\nQuery:`` prompt per the model
|
|
210
|
+
card. pgvector's HNSW caps out at 2000 dims for ``vector``, so the chunks
|
|
211
|
+
column for this backend stays index-free; search uses sequential scan
|
|
212
|
+
(acceptable at personal-corpus scale).
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
dim: int = 4096
|
|
216
|
+
|
|
217
|
+
def __init__(
|
|
218
|
+
self,
|
|
219
|
+
*,
|
|
220
|
+
host: str,
|
|
221
|
+
model: str = _QWEN3_DEFAULT_MODEL,
|
|
222
|
+
client: httpx.Client | None = None,
|
|
223
|
+
batch_size: int = _DEFAULT_OLLAMA_BATCH,
|
|
224
|
+
timeout: float = _DEFAULT_OLLAMA_TIMEOUT_S,
|
|
225
|
+
keep_alive: str = _DEFAULT_OLLAMA_KEEP_ALIVE,
|
|
226
|
+
) -> None:
|
|
227
|
+
super().__init__(
|
|
228
|
+
host=host,
|
|
229
|
+
model=model,
|
|
230
|
+
client=client,
|
|
231
|
+
batch_size=batch_size,
|
|
232
|
+
timeout=timeout,
|
|
233
|
+
keep_alive=keep_alive,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def _format_query(self, text: str) -> str:
|
|
237
|
+
return f"Instruct: {_QWEN3_QUERY_TASK}\nQuery:{text}"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class ArcticEmbedder(_OllamaEmbedderBase):
|
|
241
|
+
"""Ollama-hosted Snowflake Arctic Embed v2 (1024 native dims).
|
|
242
|
+
|
|
243
|
+
Query mode prepends ``"query: "`` per Snowflake's published guidance
|
|
244
|
+
(https://huggingface.co/Snowflake/snowflake-arctic-embed-l-v2.0):
|
|
245
|
+
"use the query prefix below (just on the query)". Documents get no
|
|
246
|
+
prefix. 1024 dims fits under pgvector's HNSW cap, so the chunks column
|
|
247
|
+
for this backend gets a cosine HNSW index at finalize time.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
dim: int = 1024
|
|
251
|
+
|
|
252
|
+
def __init__(
|
|
253
|
+
self,
|
|
254
|
+
*,
|
|
255
|
+
host: str,
|
|
256
|
+
model: str = _ARCTIC_DEFAULT_MODEL,
|
|
257
|
+
client: httpx.Client | None = None,
|
|
258
|
+
batch_size: int = _DEFAULT_OLLAMA_BATCH,
|
|
259
|
+
timeout: float = _DEFAULT_OLLAMA_TIMEOUT_S,
|
|
260
|
+
keep_alive: str = _DEFAULT_OLLAMA_KEEP_ALIVE,
|
|
261
|
+
) -> None:
|
|
262
|
+
super().__init__(
|
|
263
|
+
host=host,
|
|
264
|
+
model=model,
|
|
265
|
+
client=client,
|
|
266
|
+
batch_size=batch_size,
|
|
267
|
+
timeout=timeout,
|
|
268
|
+
keep_alive=keep_alive,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def _format_query(self, text: str) -> str:
|
|
272
|
+
return f"{_ARCTIC_QUERY_PREFIX}{text}"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class VoyageEmbedder:
|
|
276
|
+
"""Wraps the Voyage AI SDK with batching and offline token counting.
|
|
277
|
+
|
|
278
|
+
Voyage's SDK natively understands ``input_type="query"|"document"`` so we
|
|
279
|
+
pass it through unchanged; no manual prefix dance.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
dim: int = 1024
|
|
283
|
+
|
|
284
|
+
def __init__(
|
|
285
|
+
self,
|
|
286
|
+
*,
|
|
287
|
+
api_key: str,
|
|
288
|
+
client: Any | None = None,
|
|
289
|
+
batch_size: int = _VOYAGE_DEFAULT_BATCH,
|
|
290
|
+
timeout: float = _DEFAULT_OLLAMA_TIMEOUT_S,
|
|
291
|
+
) -> None:
|
|
292
|
+
self._batch_size = batch_size
|
|
293
|
+
self._tokenizer = tiktoken.get_encoding("cl100k_base")
|
|
294
|
+
if client is not None:
|
|
295
|
+
self._client = client
|
|
296
|
+
else: # pragma: no cover - exercised only against real Voyage service
|
|
297
|
+
import voyageai
|
|
298
|
+
|
|
299
|
+
# The SDK accepts ``timeout=`` on the client constructor; pass it
|
|
300
|
+
# explicitly per CLAUDE.md "every external HTTP/DB client MUST
|
|
301
|
+
# have explicit timeouts".
|
|
302
|
+
self._client = voyageai.Client( # type: ignore[attr-defined]
|
|
303
|
+
api_key=api_key, timeout=timeout
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def embed(
|
|
307
|
+
self, texts: list[str], *, input_type: str = "document"
|
|
308
|
+
) -> list[list[float]]:
|
|
309
|
+
"""Embed ``texts`` in batches of ``batch_size`` and return all vectors in order.
|
|
310
|
+
|
|
311
|
+
An empty input returns an empty list with no SDK round-trip (matching the
|
|
312
|
+
Ollama base). Any Voyage SDK failure — rate limit, transport error,
|
|
313
|
+
timeout, or a malformed/API error, all subclasses of
|
|
314
|
+
``voyageai.error.VoyageError`` — is wrapped in :class:`VoyageEmbedError`
|
|
315
|
+
(an :class:`~brain.errors.EmbedError`) so callers get the same typed
|
|
316
|
+
embed error the Ollama backends raise. The explicit per-request timeout
|
|
317
|
+
set on the SDK client (see :meth:`__init__`) surfaces as a
|
|
318
|
+
``voyageai.error.Timeout``, which is wrapped here too.
|
|
319
|
+
"""
|
|
320
|
+
if not texts:
|
|
321
|
+
return []
|
|
322
|
+
# ``voyageai`` is an optional dependency imported lazily (only the voyage
|
|
323
|
+
# backend constructs this class), so reference its exception base lazily
|
|
324
|
+
# too — a top-level import would break arctic/qwen3-only installs.
|
|
325
|
+
from voyageai.error import VoyageError
|
|
326
|
+
|
|
327
|
+
out: list[list[float]] = []
|
|
328
|
+
for start in range(0, len(texts), self._batch_size):
|
|
329
|
+
batch = texts[start : start + self._batch_size]
|
|
330
|
+
try:
|
|
331
|
+
response = self._client.embed(
|
|
332
|
+
texts=batch, model=_VOYAGE_MODEL, input_type=input_type
|
|
333
|
+
)
|
|
334
|
+
except VoyageError as e:
|
|
335
|
+
raise VoyageEmbedError(f"Voyage embed request failed: {e}") from e
|
|
336
|
+
out.extend(response.embeddings)
|
|
337
|
+
return out
|
|
338
|
+
|
|
339
|
+
def count_tokens(self, text: str) -> int:
|
|
340
|
+
"""Return the number of tokens in ``text`` per the local tiktoken tokenizer."""
|
|
341
|
+
return len(self._tokenizer.encode(text))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class NullEmbedder:
|
|
345
|
+
"""FTS-only backend: satisfies the Protocol but produces no vectors.
|
|
346
|
+
|
|
347
|
+
Selected via ``BRAIN_EMBEDDER=none`` for a user with no Ollama — ingest,
|
|
348
|
+
lexical (FTS) search, and ``brain doctor`` all work; only the vector leg of
|
|
349
|
+
hybrid search is unavailable. Two contract points make the upgrade path
|
|
350
|
+
painless:
|
|
351
|
+
|
|
352
|
+
- ``dim == 1024`` matches the arctic / voyage schema, so switching to a real
|
|
353
|
+
1024-dim backend later is a plain ``brain reembed`` backfill — NO
|
|
354
|
+
destructive column rebuild (``db.ensure_embedding_column`` sees the dims
|
|
355
|
+
already agree).
|
|
356
|
+
- ``produces_embeddings = False`` is a duck-typed flag (NOT part of the
|
|
357
|
+
:class:`brain.ingest.Embedder` Protocol — the real backends never declare
|
|
358
|
+
it). Callers check it via ``getattr(embedder, "produces_embeddings",
|
|
359
|
+
True)`` to degrade gracefully: the ingest pipeline stores NULL embeddings
|
|
360
|
+
and :func:`brain.search.hybrid_search` coerces to ``fts_only``.
|
|
361
|
+
|
|
362
|
+
:meth:`count_tokens` uses the same offline ``cl100k_base`` tokenizer as
|
|
363
|
+
every other backend so the chunker's token budgeting is unchanged.
|
|
364
|
+
:meth:`embed` never runs under the ingest / search / doctor paths (they
|
|
365
|
+
degrade earlier); if any other path calls it, it raises
|
|
366
|
+
:class:`EmbedDisabledError` with an upgrade hint rather than crashing
|
|
367
|
+
opaquely.
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
dim: int = 1024
|
|
371
|
+
produces_embeddings: bool = False
|
|
372
|
+
|
|
373
|
+
def __init__(self) -> None:
|
|
374
|
+
self._tokenizer = tiktoken.get_encoding("cl100k_base")
|
|
375
|
+
|
|
376
|
+
def embed(
|
|
377
|
+
self, texts: list[str], *, input_type: str = "document"
|
|
378
|
+
) -> NoReturn:
|
|
379
|
+
"""Always raise :class:`EmbedDisabledError` — the null backend has no vectors.
|
|
380
|
+
|
|
381
|
+
The keyword-only ``input_type`` default mirrors the
|
|
382
|
+
:class:`brain.ingest.Embedder` Protocol so the signature is substitutable
|
|
383
|
+
for the real backends.
|
|
384
|
+
"""
|
|
385
|
+
raise EmbedDisabledError(_EMBED_DISABLED_MESSAGE)
|
|
386
|
+
|
|
387
|
+
def count_tokens(self, text: str) -> int:
|
|
388
|
+
"""Return the number of tokens in ``text`` per the local tiktoken tokenizer."""
|
|
389
|
+
return len(self._tokenizer.encode(text))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def make_embedder(cfg: Config) -> Embedder:
|
|
393
|
+
"""Return the active embedder based on ``BRAIN_EMBEDDER`` config.
|
|
394
|
+
|
|
395
|
+
Dispatches on ``cfg.embedder`` ∈ ``{"arctic", "voyage", "qwen3", "none"}``.
|
|
396
|
+
``none`` returns the FTS-only :class:`NullEmbedder` (no Ollama / no API key
|
|
397
|
+
required). Raises :class:`ConfigError` when the chosen backend's required
|
|
398
|
+
config is missing (e.g. ``VOYAGE_API_KEY`` for the voyage backend) — earlier
|
|
399
|
+
than the first embed call so ``brain init`` and ``brain doctor`` surface the
|
|
400
|
+
misconfiguration cleanly.
|
|
401
|
+
|
|
402
|
+
Returns the :class:`brain.ingest.Embedder` Protocol; callers should not
|
|
403
|
+
depend on the concrete subclass.
|
|
404
|
+
"""
|
|
405
|
+
if cfg.embedder == "none":
|
|
406
|
+
return NullEmbedder()
|
|
407
|
+
if cfg.embedder == "arctic":
|
|
408
|
+
return ArcticEmbedder(host=cfg.ollama_host, keep_alive=cfg.ollama_keep_alive)
|
|
409
|
+
if cfg.embedder == "qwen3":
|
|
410
|
+
return Qwen3Embedder(
|
|
411
|
+
host=cfg.ollama_host,
|
|
412
|
+
model=cfg.qwen3_model,
|
|
413
|
+
keep_alive=cfg.ollama_keep_alive,
|
|
414
|
+
)
|
|
415
|
+
if cfg.embedder == "voyage":
|
|
416
|
+
if cfg.voyage_api_key is None:
|
|
417
|
+
raise ConfigError(
|
|
418
|
+
"BRAIN_EMBEDDER=voyage requires VOYAGE_API_KEY (see .env.example)"
|
|
419
|
+
)
|
|
420
|
+
return VoyageEmbedder(api_key=cfg.voyage_api_key)
|
|
421
|
+
raise ConfigError(
|
|
422
|
+
f"BRAIN_EMBEDDER must be one of: arctic, voyage, qwen3, none "
|
|
423
|
+
f"(got {cfg.embedder!r})"
|
|
424
|
+
)
|