askfaro-embedded-search 0.5.0__tar.gz

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 (30) hide show
  1. askfaro_embedded_search-0.5.0/.github/workflows/publish.yml +29 -0
  2. askfaro_embedded_search-0.5.0/.github/workflows/test.yml +38 -0
  3. askfaro_embedded_search-0.5.0/.gitignore +9 -0
  4. askfaro_embedded_search-0.5.0/CHANGELOG.md +122 -0
  5. askfaro_embedded_search-0.5.0/LICENSE +21 -0
  6. askfaro_embedded_search-0.5.0/PKG-INFO +233 -0
  7. askfaro_embedded_search-0.5.0/README.md +202 -0
  8. askfaro_embedded_search-0.5.0/askfaro_embedded_search/__init__.py +44 -0
  9. askfaro_embedded_search-0.5.0/askfaro_embedded_search/backends/__init__.py +11 -0
  10. askfaro_embedded_search-0.5.0/askfaro_embedded_search/backends/base.py +79 -0
  11. askfaro_embedded_search-0.5.0/askfaro_embedded_search/backends/postgres.py +444 -0
  12. askfaro_embedded_search-0.5.0/askfaro_embedded_search/backends/sqlite.py +415 -0
  13. askfaro_embedded_search-0.5.0/askfaro_embedded_search/embedder.py +82 -0
  14. askfaro_embedded_search-0.5.0/askfaro_embedded_search/errors.py +28 -0
  15. askfaro_embedded_search-0.5.0/askfaro_embedded_search/fusion.py +116 -0
  16. askfaro_embedded_search-0.5.0/askfaro_embedded_search/index.py +222 -0
  17. askfaro_embedded_search-0.5.0/askfaro_embedded_search/py.typed +0 -0
  18. askfaro_embedded_search-0.5.0/askfaro_embedded_search/registry.py +36 -0
  19. askfaro_embedded_search-0.5.0/askfaro_embedded_search/sync.py +54 -0
  20. askfaro_embedded_search-0.5.0/askfaro_embedded_search/types.py +96 -0
  21. askfaro_embedded_search-0.5.0/legacy-shim/README.md +16 -0
  22. askfaro_embedded_search-0.5.0/legacy-shim/faro_embedded_search/__init__.py +51 -0
  23. askfaro_embedded_search-0.5.0/legacy-shim/pyproject.toml +26 -0
  24. askfaro_embedded_search-0.5.0/pyproject.toml +41 -0
  25. askfaro_embedded_search-0.5.0/tests/conftest.py +32 -0
  26. askfaro_embedded_search-0.5.0/tests/test_errors.py +47 -0
  27. askfaro_embedded_search-0.5.0/tests/test_fusion.py +47 -0
  28. askfaro_embedded_search-0.5.0/tests/test_parity.py +100 -0
  29. askfaro_embedded_search-0.5.0/tests/test_postgres.py +132 -0
  30. askfaro_embedded_search-0.5.0/tests/test_sqlite.py +211 -0
@@ -0,0 +1,29 @@
1
+ name: publish
2
+
3
+ # Publishes to PyPI when a GitHub Release is published. Authenticates via
4
+ # PyPI Trusted Publishing (OIDC) — no API token is stored anywhere.
5
+ #
6
+ # One-time PyPI setup (project → Publishing → Add a trusted publisher):
7
+ # Owner: poolside-ventures
8
+ # Repository: askfaro-embedded-search
9
+ # Workflow name: publish.yml
10
+ # Environment: (leave blank)
11
+
12
+ on:
13
+ release:
14
+ types: [published]
15
+ workflow_dispatch:
16
+
17
+ jobs:
18
+ publish:
19
+ runs-on: ubuntu-latest
20
+ permissions:
21
+ id-token: write # required for Trusted Publishing
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+ - name: Install uv
25
+ uses: astral-sh/setup-uv@v5
26
+ - name: Build
27
+ run: uv build
28
+ - name: Publish to PyPI
29
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,38 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.11", "3.12", "3.13"]
14
+ # Postgres with pgvector so the Postgres backend + the cross-backend parity
15
+ # test run in CI on every push — not just where a local DSN happens to exist.
16
+ services:
17
+ postgres:
18
+ image: pgvector/pgvector:pg16
19
+ env:
20
+ POSTGRES_PASSWORD: postgres
21
+ ports:
22
+ - 5432:5432
23
+ options: >-
24
+ --health-cmd pg_isready
25
+ --health-interval 10s
26
+ --health-timeout 5s
27
+ --health-retries 5
28
+ env:
29
+ FARO_EMBEDDED_SEARCH_TEST_DSN: postgresql+asyncpg://postgres:postgres@localhost:5432/postgres
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - name: Install uv
33
+ uses: astral-sh/setup-uv@v5
34
+ - name: Run tests
35
+ run: |
36
+ uv venv --python ${{ matrix.python-version }}
37
+ uv pip install -e ".[dev,postgres,numpy]"
38
+ uv run pytest -q
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ *.db
8
+ *.db-wal
9
+ *.db-shm
@@ -0,0 +1,122 @@
1
+ # Changelog
2
+
3
+ All notable changes to `askfaro-embedded-search` are documented here. The format
4
+ follows [Keep a Changelog](https://keepachangelog.com/), and the project aims
5
+ to follow [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Changed
10
+ - Embedding failures (at both index time and query time) are now logged at
11
+ `WARNING` on the `askfaro_embedded_search` logger with the exception, instead of
12
+ being swallowed silently. Behavior is otherwise unchanged (still non-fatal,
13
+ availability over completeness), but a misconfigured embedder (bad key, wrong
14
+ endpoint) now surfaces instead of quietly degrading search to lexical-only.
15
+
16
+ ### Docs
17
+ - Document partition semantics (optional, defaults to `None`, exact-match filter,
18
+ and the two quiet-failure modes that follow) and the new embedding-failure
19
+ logging in the README.
20
+
21
+ ## [0.5.0] - 2026-06-17
22
+
23
+ ### Changed
24
+ - **Package renamed** from `faro-embedded-search` to `askfaro-embedded-search`,
25
+ and the import name from `faro_embedded_search` to `askfaro_embedded_search`,
26
+ to match the AskFaro brand. Update imports and run
27
+ `pip install askfaro-embedded-search`. The old name ships one final `0.4.1`
28
+ release that re-exports this package and warns on import. No functional change.
29
+ Public class names (`FaroSearchError`, etc.) are unchanged, and the default
30
+ Postgres table name (`faro_embedded_search_index`) is preserved for backward
31
+ compatibility.
32
+
33
+ ## [0.4.0] - 2026-06-10
34
+
35
+ ### Added
36
+ - **Clear setup errors** (`FaroSearchError` and subclasses) for the common
37
+ adopter mistakes, replacing cryptic underlying failures:
38
+ - `MissingDependencyError` when `PostgresBackend` / `OpenAICompatibleEmbedder`
39
+ are used without their install extra — names the package and the exact
40
+ `pip install` command. (Also subclasses `ImportError` for compatibility.)
41
+ - `ConfigurationError` when the pgvector extension isn't available, when an
42
+ embedding's dimension doesn't match its space's column, when a `SearchIndex`
43
+ has an embedder for a space the backend doesn't define, or when SQLite lacks
44
+ FTS5 — each with the concrete fix.
45
+ - A **Troubleshooting** table in the README mapping symptom → cause → fix.
46
+
47
+ [0.4.0]: https://github.com/poolside-ventures/askfaro-embedded-search/releases/tag/v0.4.0
48
+
49
+ ## [0.3.0] - 2026-06-10
50
+
51
+ ### Added
52
+ - **Multiple named embedding spaces.** An index can hold a vector from more
53
+ than one model per row — e.g. a `server` space (a hosted model) and a
54
+ `local` space (an on-device model) — each its own column/index, queried
55
+ independently. Vectors from different models are never compared.
56
+ - `SearchIndex(backend, embedders={"server": e1, "local": e2}, default_space="server")`
57
+ - Backends take a `spaces` config (`PostgresBackend(..., spaces={"server": 1536, "local": 384})`,
58
+ `SQLiteBackend(..., spaces=("local",))`).
59
+ - `IndexDoc.embed_spaces` lets an object type opt out of a space — e.g.
60
+ emails embed server-side only, so on-device they're keyword-searchable but
61
+ not semantic until you query the server.
62
+ - `SearchIndex.search(..., space=...)` selects which space to query.
63
+ - `export_shard(..., spaces=("local",))` ships a device shard carrying only
64
+ the on-device space (smaller — no server vectors).
65
+
66
+ ### Changed
67
+ - The internal sync change-row format renamed `embedding` →
68
+ `embeddings: {space: vector}`. The single-embedder API is unchanged
69
+ (`SearchIndex(backend, embedder)` → one `"default"` space).
70
+ - The default space's column is `embedding_default` (was `embedding` in 0.2.0).
71
+ As this is pre-1.0 with no migration path yet, re-create/backfill a 0.2.0
72
+ index rather than upgrading it in place.
73
+
74
+ [0.3.0]: https://github.com/poolside-ventures/askfaro-embedded-search/releases/tag/v0.3.0
75
+
76
+ ## [0.2.0] - 2026-06-10
77
+
78
+ ### Fixed
79
+ - **Cross-backend stemming parity.** SQLite FTS5 now uses the `porter`
80
+ tokenizer, so morphological variants match the same way Postgres
81
+ `to_tsvector('english')` stems them (e.g. "groceries" matches "grocery").
82
+ Previously the SQLite (device) backend did not stem, so it could rank the
83
+ same corpus differently from the Postgres (server) backend — a violation of
84
+ the library's identical-semantics guarantee.
85
+
86
+ ### Added
87
+ - **Generic attribute filter.** `IndexDoc.attrs` (a JSON dict) plus an
88
+ `attrs=` argument to `SearchIndex.search` filters results by equality /
89
+ containment (e.g. `attrs={"category": "finance"}`). Backed by a JSONB GIN
90
+ index on Postgres and `json_extract` on SQLite. Removes the need to overload
91
+ `partition` for non-isolation filters.
92
+ - `py.typed` marker so downstream type checkers see the library's annotations.
93
+ - Idempotent in-place schema upgrades: `create_schema()` (Postgres) and
94
+ `SQLiteBackend(...)` add the new `attrs` column to indexes created by 0.1.0.
95
+
96
+ [0.2.0]: https://github.com/poolside-ventures/askfaro-embedded-search/releases/tag/v0.2.0
97
+
98
+ ## [0.1.0] - 2026-06-10
99
+
100
+ Initial release.
101
+
102
+ ### Added
103
+ - `SearchIndex` facade: incremental per-row `upsert` / `delete` (no rebuild
104
+ step exists) and hybrid `search` with partition, object-type, and node-kind
105
+ filters.
106
+ - Rank-based **Reciprocal Rank Fusion** (RRF, K=60) in the core, so every
107
+ backend ranks identically for the same corpus and query regardless of its
108
+ native lexical/vector score scales.
109
+ - **Postgres backend** (`[postgres]` extra): pgvector HNSW + weighted
110
+ `tsvector` GIN, idempotent `create_schema()`.
111
+ - **SQLite backend** (stdlib only): FTS5 (BM25) + exact cosine scan
112
+ (numpy-accelerated via the `[numpy]` extra). Its schema is the documented
113
+ on-device shard interchange format.
114
+ - Node-kind tiering (`leaf` / `summary` / `cluster`) in one flat pool with
115
+ per-object collapsing — an incremental-safe alternative to batch RAPTOR trees.
116
+ - Per-object-type indexer registry for heterogeneous objects.
117
+ - Pluggable embedders, including an `OpenAICompatibleEmbedder` (OpenAI,
118
+ LiteLLM proxies, self-hosted). Embedding failure is non-fatal.
119
+ - Cursor-based shard replication (`replicate`, `export_shard`) with
120
+ tombstone deletes, for the index-server-side / retrieve-on-device pattern.
121
+
122
+ [0.1.0]: https://github.com/poolside-ventures/askfaro-embedded-search/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Faro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.4
2
+ Name: askfaro-embedded-search
3
+ Version: 0.5.0
4
+ Summary: Incremental hybrid retrieval (lexical + vector, RRF-fused) with identical semantics on server Postgres and on-device SQLite
5
+ Project-URL: Homepage, https://github.com/poolside-ventures/askfaro-embedded-search
6
+ Project-URL: Repository, https://github.com/poolside-ventures/askfaro-embedded-search
7
+ Project-URL: Issues, https://github.com/poolside-ventures/askfaro-embedded-search/issues
8
+ Project-URL: Changelog, https://github.com/poolside-ventures/askfaro-embedded-search/blob/main/CHANGELOG.md
9
+ Author: Faro
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: hybrid-search,on-device,pgvector,rag,retrieval,search,sqlite
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.11
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
22
+ Requires-Dist: pytest>=8; extra == 'dev'
23
+ Provides-Extra: http
24
+ Requires-Dist: httpx>=0.27; extra == 'http'
25
+ Provides-Extra: numpy
26
+ Requires-Dist: numpy>=1.26; extra == 'numpy'
27
+ Provides-Extra: postgres
28
+ Requires-Dist: asyncpg>=0.30.0; extra == 'postgres'
29
+ Requires-Dist: sqlalchemy[asyncio]>=2.0.36; extra == 'postgres'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # askfaro-embedded-search
33
+
34
+ **Incremental hybrid retrieval with identical semantics on the server and on-device.**
35
+
36
+ `askfaro-embedded-search` is a small, dependency-light library for searching a *continuously growing* collection of heterogeneous objects — notes, contacts, emails, tasks, tools, anything — without ever rebuilding an index. It is built for the pattern modern assistant apps need:
37
+
38
+ > **Index server-side, retrieve on-device.** Embeddings are computed once, centrally. Each user's slice of the index is replicated into a local SQLite shard, and the device answers queries locally — fast, offline, and private — with *exactly* the same ranking the server would produce.
39
+
40
+ Built and dogfooded by Faro; also the embedded retrieval engine of Scope.
41
+
42
+ ## Why another retrieval library?
43
+
44
+ Most RAG tooling assumes a batch world: ingest a corpus, build a tree/graph/index, query it. Real applications have **object-level CRUD** — a contact edited, a task created, an email deleted — and small/on-device context windows that punish irrelevant results. `askfaro-embedded-search` makes two opinionated choices:
45
+
46
+ 1. **Incremental-first.** One upsert per object write. Postgres (pgvector HNSW + tsvector GIN) and SQLite (FTS5 + vector blobs) both take per-row inserts natively, so there is no "rebuild" anywhere in the design. Hierarchical enrichment (summary and cluster nodes) lives in the *same flat pool* as extra rows — never on the write path.
47
+ 2. **Rank-based fusion.** Lexical and semantic retrievers run in parallel and are fused with Reciprocal Rank Fusion (RRF, K=60). Because RRF consumes *ranks*, not raw scores, the incomparable scoring scales of `ts_rank_cd` (Postgres) and `bm25()` (SQLite FTS5) don't matter: the same corpus and query rank identically on both backends. That is what makes "same engine, server and device" honest rather than aspirational.
48
+
49
+ ## Quick start
50
+
51
+ ```python
52
+ from askfaro_embedded_search import IndexDoc, SearchIndex, OpenAICompatibleEmbedder
53
+ from askfaro_embedded_search.backends.sqlite import SQLiteBackend
54
+
55
+ index = SearchIndex(
56
+ SQLiteBackend("search.db"),
57
+ OpenAICompatibleEmbedder("https://api.openai.com/v1", api_key, "text-embedding-3-small"),
58
+ )
59
+
60
+ await index.upsert(IndexDoc(
61
+ object_type="note", object_id="n1",
62
+ title="Quantum entanglement notes",
63
+ body="spooky action at a distance",
64
+ partition="user-42", # isolation + shard key
65
+ ))
66
+
67
+ results = await index.search("entanglement", partition="user-42", k=5)
68
+ # SearchResult(object_id='n1', match_type='hybrid', score=..., ...)
69
+ ```
70
+
71
+ Deleting and updating are first-class — deletes are tombstones so they propagate through shard sync:
72
+
73
+ ```python
74
+ await index.delete("note", "n1")
75
+ ```
76
+
77
+ ### Filtering
78
+
79
+ Filter by partition (the isolation/shard key), object type, node kind, or arbitrary structured attributes:
80
+
81
+ ```python
82
+ await index.upsert(IndexDoc(
83
+ object_type="tool", object_id="stripe/refund",
84
+ title="Refund a charge", body="...",
85
+ attrs={"category": "finance", "status": "active"}, # structured filter fields
86
+ ))
87
+
88
+ # Only finance tools:
89
+ await index.search("refund a customer", object_types=["tool"], attrs={"category": "finance"})
90
+ ```
91
+
92
+ `attrs` is matched by containment (all given keys must equal), backed by a JSONB GIN index on Postgres and `json_extract` on SQLite — so it filters identically on server and device.
93
+
94
+ #### Partitions
95
+
96
+ `partition` is the per-tenant isolation key (and the unit of on-device shard replication). It is **optional and defaults to `None`**, and a query's `partition` filter is an **exact match**: `search(..., partition="user-42")` returns only rows upserted with `partition="user-42"`. Two consequences worth knowing up front, because both fail quietly rather than with an error:
97
+
98
+ - A row indexed **without** a partition (`None`) will **not** appear in a query that filters by a partition, and vice-versa. Pick one convention per corpus — either always set a partition, or never — and don't mix.
99
+ - `search()` with no `partition` does **not** scope to a tenant; it searches across every partition. Pass the partition on every query in a multi-tenant index.
100
+
101
+ ### Server-side: Postgres
102
+
103
+ ```python
104
+ from askfaro_embedded_search.backends.postgres import PostgresBackend # pip install askfaro-embedded-search[postgres]
105
+
106
+ backend = PostgresBackend("postgresql+asyncpg://...", table="faro_embedded_search_index", dim=1536)
107
+ await backend.create_schema() # idempotent; or transcribe the DDL into your migrations
108
+ index = SearchIndex(backend, embedder)
109
+ ```
110
+
111
+ ### On-device: replicate a shard, query locally
112
+
113
+ ```python
114
+ from askfaro_embedded_search import export_shard, replicate
115
+
116
+ # Full export of one user's partition into a SQLite file:
117
+ shard = await export_shard(server_backend, "user-42.db", partition="user-42")
118
+
119
+ # Later, incremental delta sync (inserts, updates, AND deletes):
120
+ cursor = await replicate(server_backend, shard, partition="user-42", cursor=cursor)
121
+ ```
122
+
123
+ The shard is a plain SQLite file whose schema **is** the interchange format — any runtime that can read SQLite (a future Swift/Kotlin reader, for instance) can retrieve against it. Embeddings travel with the shard; the device never needs to re-embed the corpus. Query embedding on-device can use a local model, a cached vector, or one tiny server round-trip for the query alone.
124
+
125
+ ## Tiering without batch trees
126
+
127
+ RAPTOR-style hierarchies buy small-context windows a lot — but a recursive tree can't be rebuilt on every insert. `askfaro-embedded-search` keeps the index flat and gets the benefit through **node kinds**:
128
+
129
+ - `leaf` — the object itself (default).
130
+ - `summary` — an optional one-line abstract of an object, indexed alongside it. O(1) per object, generated on your write path or a background pass.
131
+ - `cluster` — optional theme summaries produced by a periodic background sweep over existing embeddings.
132
+
133
+ All kinds live in the same pool and are retrieved by the same top-k ("collapsed" retrieval); by default multiple hits on one object collapse into its best row, with `matched_node_kinds` telling you which handles matched:
134
+
135
+ ```python
136
+ await index.upsert_many([
137
+ IndexDoc(object_type="note", object_id="n9", title="Meeting notes", body=transcript),
138
+ IndexDoc(object_type="note", object_id="n9", node_kind="summary",
139
+ title="Summary: hiring sync", body="decided to open two roles"),
140
+ ])
141
+ ```
142
+
143
+ ## Multiple embedding spaces (dual-model / on-device)
144
+
145
+ A row can carry vectors from more than one model — typically a high-quality **server** model and a smaller **on-device** model — each as its own independently-queried space. Vectors from different models are never compared, so there's no mismatch.
146
+
147
+ ```python
148
+ index = SearchIndex(
149
+ backend, # configured with both spaces
150
+ embedders={"server": openai, "local": on_device_minilm},
151
+ default_space="server",
152
+ )
153
+
154
+ # A note participates in both spaces (default); an email is server-only:
155
+ await index.upsert_many([
156
+ IndexDoc(object_type="note", object_id="n1", title="...", body="..."),
157
+ IndexDoc(object_type="email", object_id="e1", title="...", body="...",
158
+ embed_spaces=["server"]), # no on-device vector
159
+ ])
160
+
161
+ # On the server, query the server space; on-device, query the local space:
162
+ await index.search("quarterly plan", space="server") # web/server
163
+ await index.search("quarterly plan", space="local") # device shard
164
+ ```
165
+
166
+ The device shard carries only the on-device space (smaller — no server vectors):
167
+
168
+ ```python
169
+ shard = await export_shard(server_backend, "user-42.db",
170
+ partition="user-42", spaces=("local",))
171
+ ```
172
+
173
+ Because a server-only object (the email above) has no local vector, on-device it's **keyword-searchable but not semantic** — its text still syncs, so FTS finds it offline; semantic search on it requires querying the server. This falls out of the per-space null-vector handling, no special-casing. Configure backends with matching spaces: `PostgresBackend(..., spaces={"server": 1536, "local": 384})`, `SQLiteBackend(..., spaces=("local",))`.
174
+
175
+ ## Heterogeneous objects
176
+
177
+ Per-type behavior lives in code, not schema, via a tiny registry:
178
+
179
+ ```python
180
+ from askfaro_embedded_search import register, docs_for
181
+
182
+ @register("contact")
183
+ def index_contact(c) -> IndexDoc:
184
+ return IndexDoc(object_type="contact", object_id=str(c.id),
185
+ title=c.name, body=f"{c.role} at {c.company}",
186
+ payload={"avatar": c.avatar_url})
187
+
188
+ await index.upsert_many(docs_for("contact", some_contact))
189
+ ```
190
+
191
+ `payload` is carried into results (and shards), so result lists render without joining back to your application database.
192
+
193
+ ## Design notes
194
+
195
+ - **Embedding failure is non-fatal, but never silent.** A row written without a vector still serves lexical queries and gains semantic retrieval after a backfill — availability over completeness. Every embedding failure (at index time *and* query time) is logged at `WARNING` on the `askfaro_embedded_search` logger with the exception, so a misconfigured embedder (bad key, wrong endpoint) surfaces instead of silently degrading search to keyword-only. Wire that logger up in your app to catch it.
196
+ - **Exact semantic scan on SQLite.** Per-user shards are small (tens of thousands of rows); an exact cosine scan (numpy-accelerated when present) costs no index maintenance and returns exact results. ANN acceleration (e.g. sqlite-vec) can be added without changing the file format.
197
+ - **Diversity, not padding.** An optional per-group cap (`diversity_key`) drops near-duplicate siblings instead of deferring them.
198
+ - **Stemming parity.** Postgres uses the `english` text-search config; SQLite FTS5 uses the `porter` tokenizer. Both stem morphological variants ("groceries" → "grocery") so the server and a device shard rank the same corpus identically.
199
+ - **No framework.** Plain SQL on both backends, zero required dependencies in the core.
200
+
201
+ ## Requirements
202
+
203
+ - **Python 3.11+.** The core (SQLite backend) needs only the standard library — SQLite's FTS5 and JSON1 extensions ship with CPython's bundled SQLite.
204
+ - **Postgres backend** (`[postgres]` extra) needs a database with the **pgvector** extension available. `create_schema()` runs `CREATE EXTENSION IF NOT EXISTS vector` and the rest of the DDL in one idempotent call (or transcribe it into your own migrations). It is safe to re-run; it also adds new columns in place when you upgrade the library.
205
+
206
+ ## Troubleshooting
207
+
208
+ The library raises clear, actionable errors for the common setup mistakes (all subclasses of `FaroSearchError`):
209
+
210
+ | Symptom | Cause | Fix |
211
+ |---|---|---|
212
+ | `MissingDependencyError: PostgresBackend requires the 'postgres' extra` | Installed the core only | `pip install "askfaro-embedded-search[postgres]"` |
213
+ | `MissingDependencyError: OpenAICompatibleEmbedder requires the 'http' extra` | Missing httpx | `pip install "askfaro-embedded-search[http]"` |
214
+ | `ConfigurationError: pgvector extension isn't available…` | Postgres server has no `vector` extension | Enable/install [pgvector](https://github.com/pgvector/pgvector), or use `SQLiteBackend` (no extension needed) |
215
+ | `ConfigurationError: Embedding for space 'x' has dimension N, but the index is configured for M` | Embedder output dim ≠ the space's column dim | Match the dims: configure the backend with `spaces={'x': N}` (then re-create the schema), or use an embedder that emits M-dim vectors |
216
+ | `ConfigurationError: …embedders for space(s) [...] that the backend doesn't define` | `SearchIndex` has an embedder for a space the backend wasn't configured with | Give the backend matching spaces: `PostgresBackend(..., spaces={...})` / `SQLiteBackend(..., spaces=(...))` |
217
+ | `ConfigurationError: This SQLite build lacks the FTS5 extension` | Rare CPython build without FTS5 | Use a Python whose bundled SQLite has FTS5 |
218
+ | Postgres error: relation `…_index` does not exist | Forgot to set up the schema | Call `await backend.create_schema()` once (or run the equivalent migration) before indexing |
219
+
220
+ Other expected behaviours (not errors): searching a space with **no embedder** returns lexical-only results, and a doc indexed without a vector still serves keyword search — both by design (availability over completeness).
221
+
222
+ ## Installation
223
+
224
+ ```bash
225
+ pip install askfaro-embedded-search # core (SQLite backend, stdlib only)
226
+ pip install askfaro-embedded-search[postgres] # + Postgres/pgvector backend
227
+ pip install askfaro-embedded-search[http] # + OpenAI-compatible embedder
228
+ pip install askfaro-embedded-search[numpy] # + fast cosine on SQLite
229
+ ```
230
+
231
+ ## License
232
+
233
+ MIT
@@ -0,0 +1,202 @@
1
+ # askfaro-embedded-search
2
+
3
+ **Incremental hybrid retrieval with identical semantics on the server and on-device.**
4
+
5
+ `askfaro-embedded-search` is a small, dependency-light library for searching a *continuously growing* collection of heterogeneous objects — notes, contacts, emails, tasks, tools, anything — without ever rebuilding an index. It is built for the pattern modern assistant apps need:
6
+
7
+ > **Index server-side, retrieve on-device.** Embeddings are computed once, centrally. Each user's slice of the index is replicated into a local SQLite shard, and the device answers queries locally — fast, offline, and private — with *exactly* the same ranking the server would produce.
8
+
9
+ Built and dogfooded by Faro; also the embedded retrieval engine of Scope.
10
+
11
+ ## Why another retrieval library?
12
+
13
+ Most RAG tooling assumes a batch world: ingest a corpus, build a tree/graph/index, query it. Real applications have **object-level CRUD** — a contact edited, a task created, an email deleted — and small/on-device context windows that punish irrelevant results. `askfaro-embedded-search` makes two opinionated choices:
14
+
15
+ 1. **Incremental-first.** One upsert per object write. Postgres (pgvector HNSW + tsvector GIN) and SQLite (FTS5 + vector blobs) both take per-row inserts natively, so there is no "rebuild" anywhere in the design. Hierarchical enrichment (summary and cluster nodes) lives in the *same flat pool* as extra rows — never on the write path.
16
+ 2. **Rank-based fusion.** Lexical and semantic retrievers run in parallel and are fused with Reciprocal Rank Fusion (RRF, K=60). Because RRF consumes *ranks*, not raw scores, the incomparable scoring scales of `ts_rank_cd` (Postgres) and `bm25()` (SQLite FTS5) don't matter: the same corpus and query rank identically on both backends. That is what makes "same engine, server and device" honest rather than aspirational.
17
+
18
+ ## Quick start
19
+
20
+ ```python
21
+ from askfaro_embedded_search import IndexDoc, SearchIndex, OpenAICompatibleEmbedder
22
+ from askfaro_embedded_search.backends.sqlite import SQLiteBackend
23
+
24
+ index = SearchIndex(
25
+ SQLiteBackend("search.db"),
26
+ OpenAICompatibleEmbedder("https://api.openai.com/v1", api_key, "text-embedding-3-small"),
27
+ )
28
+
29
+ await index.upsert(IndexDoc(
30
+ object_type="note", object_id="n1",
31
+ title="Quantum entanglement notes",
32
+ body="spooky action at a distance",
33
+ partition="user-42", # isolation + shard key
34
+ ))
35
+
36
+ results = await index.search("entanglement", partition="user-42", k=5)
37
+ # SearchResult(object_id='n1', match_type='hybrid', score=..., ...)
38
+ ```
39
+
40
+ Deleting and updating are first-class — deletes are tombstones so they propagate through shard sync:
41
+
42
+ ```python
43
+ await index.delete("note", "n1")
44
+ ```
45
+
46
+ ### Filtering
47
+
48
+ Filter by partition (the isolation/shard key), object type, node kind, or arbitrary structured attributes:
49
+
50
+ ```python
51
+ await index.upsert(IndexDoc(
52
+ object_type="tool", object_id="stripe/refund",
53
+ title="Refund a charge", body="...",
54
+ attrs={"category": "finance", "status": "active"}, # structured filter fields
55
+ ))
56
+
57
+ # Only finance tools:
58
+ await index.search("refund a customer", object_types=["tool"], attrs={"category": "finance"})
59
+ ```
60
+
61
+ `attrs` is matched by containment (all given keys must equal), backed by a JSONB GIN index on Postgres and `json_extract` on SQLite — so it filters identically on server and device.
62
+
63
+ #### Partitions
64
+
65
+ `partition` is the per-tenant isolation key (and the unit of on-device shard replication). It is **optional and defaults to `None`**, and a query's `partition` filter is an **exact match**: `search(..., partition="user-42")` returns only rows upserted with `partition="user-42"`. Two consequences worth knowing up front, because both fail quietly rather than with an error:
66
+
67
+ - A row indexed **without** a partition (`None`) will **not** appear in a query that filters by a partition, and vice-versa. Pick one convention per corpus — either always set a partition, or never — and don't mix.
68
+ - `search()` with no `partition` does **not** scope to a tenant; it searches across every partition. Pass the partition on every query in a multi-tenant index.
69
+
70
+ ### Server-side: Postgres
71
+
72
+ ```python
73
+ from askfaro_embedded_search.backends.postgres import PostgresBackend # pip install askfaro-embedded-search[postgres]
74
+
75
+ backend = PostgresBackend("postgresql+asyncpg://...", table="faro_embedded_search_index", dim=1536)
76
+ await backend.create_schema() # idempotent; or transcribe the DDL into your migrations
77
+ index = SearchIndex(backend, embedder)
78
+ ```
79
+
80
+ ### On-device: replicate a shard, query locally
81
+
82
+ ```python
83
+ from askfaro_embedded_search import export_shard, replicate
84
+
85
+ # Full export of one user's partition into a SQLite file:
86
+ shard = await export_shard(server_backend, "user-42.db", partition="user-42")
87
+
88
+ # Later, incremental delta sync (inserts, updates, AND deletes):
89
+ cursor = await replicate(server_backend, shard, partition="user-42", cursor=cursor)
90
+ ```
91
+
92
+ The shard is a plain SQLite file whose schema **is** the interchange format — any runtime that can read SQLite (a future Swift/Kotlin reader, for instance) can retrieve against it. Embeddings travel with the shard; the device never needs to re-embed the corpus. Query embedding on-device can use a local model, a cached vector, or one tiny server round-trip for the query alone.
93
+
94
+ ## Tiering without batch trees
95
+
96
+ RAPTOR-style hierarchies buy small-context windows a lot — but a recursive tree can't be rebuilt on every insert. `askfaro-embedded-search` keeps the index flat and gets the benefit through **node kinds**:
97
+
98
+ - `leaf` — the object itself (default).
99
+ - `summary` — an optional one-line abstract of an object, indexed alongside it. O(1) per object, generated on your write path or a background pass.
100
+ - `cluster` — optional theme summaries produced by a periodic background sweep over existing embeddings.
101
+
102
+ All kinds live in the same pool and are retrieved by the same top-k ("collapsed" retrieval); by default multiple hits on one object collapse into its best row, with `matched_node_kinds` telling you which handles matched:
103
+
104
+ ```python
105
+ await index.upsert_many([
106
+ IndexDoc(object_type="note", object_id="n9", title="Meeting notes", body=transcript),
107
+ IndexDoc(object_type="note", object_id="n9", node_kind="summary",
108
+ title="Summary: hiring sync", body="decided to open two roles"),
109
+ ])
110
+ ```
111
+
112
+ ## Multiple embedding spaces (dual-model / on-device)
113
+
114
+ A row can carry vectors from more than one model — typically a high-quality **server** model and a smaller **on-device** model — each as its own independently-queried space. Vectors from different models are never compared, so there's no mismatch.
115
+
116
+ ```python
117
+ index = SearchIndex(
118
+ backend, # configured with both spaces
119
+ embedders={"server": openai, "local": on_device_minilm},
120
+ default_space="server",
121
+ )
122
+
123
+ # A note participates in both spaces (default); an email is server-only:
124
+ await index.upsert_many([
125
+ IndexDoc(object_type="note", object_id="n1", title="...", body="..."),
126
+ IndexDoc(object_type="email", object_id="e1", title="...", body="...",
127
+ embed_spaces=["server"]), # no on-device vector
128
+ ])
129
+
130
+ # On the server, query the server space; on-device, query the local space:
131
+ await index.search("quarterly plan", space="server") # web/server
132
+ await index.search("quarterly plan", space="local") # device shard
133
+ ```
134
+
135
+ The device shard carries only the on-device space (smaller — no server vectors):
136
+
137
+ ```python
138
+ shard = await export_shard(server_backend, "user-42.db",
139
+ partition="user-42", spaces=("local",))
140
+ ```
141
+
142
+ Because a server-only object (the email above) has no local vector, on-device it's **keyword-searchable but not semantic** — its text still syncs, so FTS finds it offline; semantic search on it requires querying the server. This falls out of the per-space null-vector handling, no special-casing. Configure backends with matching spaces: `PostgresBackend(..., spaces={"server": 1536, "local": 384})`, `SQLiteBackend(..., spaces=("local",))`.
143
+
144
+ ## Heterogeneous objects
145
+
146
+ Per-type behavior lives in code, not schema, via a tiny registry:
147
+
148
+ ```python
149
+ from askfaro_embedded_search import register, docs_for
150
+
151
+ @register("contact")
152
+ def index_contact(c) -> IndexDoc:
153
+ return IndexDoc(object_type="contact", object_id=str(c.id),
154
+ title=c.name, body=f"{c.role} at {c.company}",
155
+ payload={"avatar": c.avatar_url})
156
+
157
+ await index.upsert_many(docs_for("contact", some_contact))
158
+ ```
159
+
160
+ `payload` is carried into results (and shards), so result lists render without joining back to your application database.
161
+
162
+ ## Design notes
163
+
164
+ - **Embedding failure is non-fatal, but never silent.** A row written without a vector still serves lexical queries and gains semantic retrieval after a backfill — availability over completeness. Every embedding failure (at index time *and* query time) is logged at `WARNING` on the `askfaro_embedded_search` logger with the exception, so a misconfigured embedder (bad key, wrong endpoint) surfaces instead of silently degrading search to keyword-only. Wire that logger up in your app to catch it.
165
+ - **Exact semantic scan on SQLite.** Per-user shards are small (tens of thousands of rows); an exact cosine scan (numpy-accelerated when present) costs no index maintenance and returns exact results. ANN acceleration (e.g. sqlite-vec) can be added without changing the file format.
166
+ - **Diversity, not padding.** An optional per-group cap (`diversity_key`) drops near-duplicate siblings instead of deferring them.
167
+ - **Stemming parity.** Postgres uses the `english` text-search config; SQLite FTS5 uses the `porter` tokenizer. Both stem morphological variants ("groceries" → "grocery") so the server and a device shard rank the same corpus identically.
168
+ - **No framework.** Plain SQL on both backends, zero required dependencies in the core.
169
+
170
+ ## Requirements
171
+
172
+ - **Python 3.11+.** The core (SQLite backend) needs only the standard library — SQLite's FTS5 and JSON1 extensions ship with CPython's bundled SQLite.
173
+ - **Postgres backend** (`[postgres]` extra) needs a database with the **pgvector** extension available. `create_schema()` runs `CREATE EXTENSION IF NOT EXISTS vector` and the rest of the DDL in one idempotent call (or transcribe it into your own migrations). It is safe to re-run; it also adds new columns in place when you upgrade the library.
174
+
175
+ ## Troubleshooting
176
+
177
+ The library raises clear, actionable errors for the common setup mistakes (all subclasses of `FaroSearchError`):
178
+
179
+ | Symptom | Cause | Fix |
180
+ |---|---|---|
181
+ | `MissingDependencyError: PostgresBackend requires the 'postgres' extra` | Installed the core only | `pip install "askfaro-embedded-search[postgres]"` |
182
+ | `MissingDependencyError: OpenAICompatibleEmbedder requires the 'http' extra` | Missing httpx | `pip install "askfaro-embedded-search[http]"` |
183
+ | `ConfigurationError: pgvector extension isn't available…` | Postgres server has no `vector` extension | Enable/install [pgvector](https://github.com/pgvector/pgvector), or use `SQLiteBackend` (no extension needed) |
184
+ | `ConfigurationError: Embedding for space 'x' has dimension N, but the index is configured for M` | Embedder output dim ≠ the space's column dim | Match the dims: configure the backend with `spaces={'x': N}` (then re-create the schema), or use an embedder that emits M-dim vectors |
185
+ | `ConfigurationError: …embedders for space(s) [...] that the backend doesn't define` | `SearchIndex` has an embedder for a space the backend wasn't configured with | Give the backend matching spaces: `PostgresBackend(..., spaces={...})` / `SQLiteBackend(..., spaces=(...))` |
186
+ | `ConfigurationError: This SQLite build lacks the FTS5 extension` | Rare CPython build without FTS5 | Use a Python whose bundled SQLite has FTS5 |
187
+ | Postgres error: relation `…_index` does not exist | Forgot to set up the schema | Call `await backend.create_schema()` once (or run the equivalent migration) before indexing |
188
+
189
+ Other expected behaviours (not errors): searching a space with **no embedder** returns lexical-only results, and a doc indexed without a vector still serves keyword search — both by design (availability over completeness).
190
+
191
+ ## Installation
192
+
193
+ ```bash
194
+ pip install askfaro-embedded-search # core (SQLite backend, stdlib only)
195
+ pip install askfaro-embedded-search[postgres] # + Postgres/pgvector backend
196
+ pip install askfaro-embedded-search[http] # + OpenAI-compatible embedder
197
+ pip install askfaro-embedded-search[numpy] # + fast cosine on SQLite
198
+ ```
199
+
200
+ ## License
201
+
202
+ MIT