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.
- askfaro_embedded_search-0.5.0/.github/workflows/publish.yml +29 -0
- askfaro_embedded_search-0.5.0/.github/workflows/test.yml +38 -0
- askfaro_embedded_search-0.5.0/.gitignore +9 -0
- askfaro_embedded_search-0.5.0/CHANGELOG.md +122 -0
- askfaro_embedded_search-0.5.0/LICENSE +21 -0
- askfaro_embedded_search-0.5.0/PKG-INFO +233 -0
- askfaro_embedded_search-0.5.0/README.md +202 -0
- askfaro_embedded_search-0.5.0/askfaro_embedded_search/__init__.py +44 -0
- askfaro_embedded_search-0.5.0/askfaro_embedded_search/backends/__init__.py +11 -0
- askfaro_embedded_search-0.5.0/askfaro_embedded_search/backends/base.py +79 -0
- askfaro_embedded_search-0.5.0/askfaro_embedded_search/backends/postgres.py +444 -0
- askfaro_embedded_search-0.5.0/askfaro_embedded_search/backends/sqlite.py +415 -0
- askfaro_embedded_search-0.5.0/askfaro_embedded_search/embedder.py +82 -0
- askfaro_embedded_search-0.5.0/askfaro_embedded_search/errors.py +28 -0
- askfaro_embedded_search-0.5.0/askfaro_embedded_search/fusion.py +116 -0
- askfaro_embedded_search-0.5.0/askfaro_embedded_search/index.py +222 -0
- askfaro_embedded_search-0.5.0/askfaro_embedded_search/py.typed +0 -0
- askfaro_embedded_search-0.5.0/askfaro_embedded_search/registry.py +36 -0
- askfaro_embedded_search-0.5.0/askfaro_embedded_search/sync.py +54 -0
- askfaro_embedded_search-0.5.0/askfaro_embedded_search/types.py +96 -0
- askfaro_embedded_search-0.5.0/legacy-shim/README.md +16 -0
- askfaro_embedded_search-0.5.0/legacy-shim/faro_embedded_search/__init__.py +51 -0
- askfaro_embedded_search-0.5.0/legacy-shim/pyproject.toml +26 -0
- askfaro_embedded_search-0.5.0/pyproject.toml +41 -0
- askfaro_embedded_search-0.5.0/tests/conftest.py +32 -0
- askfaro_embedded_search-0.5.0/tests/test_errors.py +47 -0
- askfaro_embedded_search-0.5.0/tests/test_fusion.py +47 -0
- askfaro_embedded_search-0.5.0/tests/test_parity.py +100 -0
- askfaro_embedded_search-0.5.0/tests/test_postgres.py +132 -0
- 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,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
|