refindery 0.1.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 (117) hide show
  1. refindery-0.1.0/PKG-INFO +233 -0
  2. refindery-0.1.0/README.md +172 -0
  3. refindery-0.1.0/pyproject.toml +153 -0
  4. refindery-0.1.0/src/refindery/__init__.py +3 -0
  5. refindery-0.1.0/src/refindery/__main__.py +6 -0
  6. refindery-0.1.0/src/refindery/adapters/__init__.py +1 -0
  7. refindery-0.1.0/src/refindery/adapters/chunking/__init__.py +1 -0
  8. refindery-0.1.0/src/refindery/adapters/chunking/chonkie_chunker.py +73 -0
  9. refindery-0.1.0/src/refindery/adapters/clock.py +11 -0
  10. refindery-0.1.0/src/refindery/adapters/cluster/__init__.py +1 -0
  11. refindery-0.1.0/src/refindery/adapters/cluster/engine.py +53 -0
  12. refindery-0.1.0/src/refindery/adapters/cluster/worker.py +208 -0
  13. refindery-0.1.0/src/refindery/adapters/embedding/__init__.py +1 -0
  14. refindery-0.1.0/src/refindery/adapters/embedding/catsu_embedder.py +84 -0
  15. refindery-0.1.0/src/refindery/adapters/embedding/surface_forms.py +53 -0
  16. refindery-0.1.0/src/refindery/adapters/extraction/__init__.py +1 -0
  17. refindery-0.1.0/src/refindery/adapters/extraction/http_fetcher.py +56 -0
  18. refindery-0.1.0/src/refindery/adapters/extraction/pdf_pypdf.py +32 -0
  19. refindery-0.1.0/src/refindery/adapters/extraction/pulpie_html.py +82 -0
  20. refindery-0.1.0/src/refindery/adapters/extractors/__init__.py +1 -0
  21. refindery-0.1.0/src/refindery/adapters/extractors/chain.py +48 -0
  22. refindery-0.1.0/src/refindery/adapters/extractors/gazetteer.py +66 -0
  23. refindery-0.1.0/src/refindery/adapters/extractors/gliner_spacy.py +97 -0
  24. refindery-0.1.0/src/refindery/adapters/extractors/llm.py +86 -0
  25. refindery-0.1.0/src/refindery/adapters/extractors/spacy_ner.py +69 -0
  26. refindery-0.1.0/src/refindery/adapters/llm/__init__.py +1 -0
  27. refindery-0.1.0/src/refindery/adapters/llm/openai_compat.py +70 -0
  28. refindery-0.1.0/src/refindery/adapters/metadata/__init__.py +1 -0
  29. refindery-0.1.0/src/refindery/adapters/metadata/migrations/0001_initial.sql +75 -0
  30. refindery-0.1.0/src/refindery/adapters/metadata/migrations/0002_forget.sql +12 -0
  31. refindery-0.1.0/src/refindery/adapters/metadata/migrations/0003_entities_clusters.sql +94 -0
  32. refindery-0.1.0/src/refindery/adapters/metadata/migrations/0004_backfills.sql +13 -0
  33. refindery-0.1.0/src/refindery/adapters/metadata/migrations/0005_jobs_page_id.sql +7 -0
  34. refindery-0.1.0/src/refindery/adapters/metadata/migrations/__init__.py +1 -0
  35. refindery-0.1.0/src/refindery/adapters/metadata/migrator.py +77 -0
  36. refindery-0.1.0/src/refindery/adapters/metadata/sqlite_store.py +1531 -0
  37. refindery-0.1.0/src/refindery/adapters/observability/__init__.py +1 -0
  38. refindery-0.1.0/src/refindery/adapters/observability/duckdb_sink.py +137 -0
  39. refindery-0.1.0/src/refindery/adapters/observability/logging.py +64 -0
  40. refindery-0.1.0/src/refindery/adapters/observability/metrics.py +76 -0
  41. refindery-0.1.0/src/refindery/adapters/observability/otel.py +58 -0
  42. refindery-0.1.0/src/refindery/adapters/observability/query_log.py +109 -0
  43. refindery-0.1.0/src/refindery/adapters/observability/query_log_reader.py +102 -0
  44. refindery-0.1.0/src/refindery/adapters/queue/__init__.py +1 -0
  45. refindery-0.1.0/src/refindery/adapters/queue/huey_queue.py +243 -0
  46. refindery-0.1.0/src/refindery/adapters/reranking/__init__.py +1 -0
  47. refindery-0.1.0/src/refindery/adapters/reranking/api.py +55 -0
  48. refindery-0.1.0/src/refindery/adapters/vector/__init__.py +1 -0
  49. refindery-0.1.0/src/refindery/adapters/vector/hybrid.py +44 -0
  50. refindery-0.1.0/src/refindery/adapters/vector/lancedb_store.py +326 -0
  51. refindery-0.1.0/src/refindery/adapters/vector/names.py +16 -0
  52. refindery-0.1.0/src/refindery/adapters/vector/qdrant_store.py +325 -0
  53. refindery-0.1.0/src/refindery/adapters/vector/sparse.py +45 -0
  54. refindery-0.1.0/src/refindery/api/__init__.py +1 -0
  55. refindery-0.1.0/src/refindery/api/app.py +65 -0
  56. refindery-0.1.0/src/refindery/api/auth.py +88 -0
  57. refindery-0.1.0/src/refindery/api/deps.py +10 -0
  58. refindery-0.1.0/src/refindery/api/mcp.py +59 -0
  59. refindery-0.1.0/src/refindery/api/routes/__init__.py +1 -0
  60. refindery-0.1.0/src/refindery/api/routes/clusters.py +110 -0
  61. refindery-0.1.0/src/refindery/api/routes/compare.py +79 -0
  62. refindery-0.1.0/src/refindery/api/routes/entities.py +99 -0
  63. refindery-0.1.0/src/refindery/api/routes/forget.py +89 -0
  64. refindery-0.1.0/src/refindery/api/routes/health.py +42 -0
  65. refindery-0.1.0/src/refindery/api/routes/jobs.py +74 -0
  66. refindery-0.1.0/src/refindery/api/routes/models.py +166 -0
  67. refindery-0.1.0/src/refindery/api/routes/pages.py +185 -0
  68. refindery-0.1.0/src/refindery/api/routes/search.py +205 -0
  69. refindery-0.1.0/src/refindery/api/schemas.py +478 -0
  70. refindery-0.1.0/src/refindery/application/__init__.py +1 -0
  71. refindery-0.1.0/src/refindery/application/container.py +503 -0
  72. refindery-0.1.0/src/refindery/application/ports/__init__.py +1 -0
  73. refindery-0.1.0/src/refindery/application/ports/chunker.py +19 -0
  74. refindery-0.1.0/src/refindery/application/ports/clock.py +12 -0
  75. refindery-0.1.0/src/refindery/application/ports/cluster_engine.py +42 -0
  76. refindery-0.1.0/src/refindery/application/ports/content_extractor.py +60 -0
  77. refindery-0.1.0/src/refindery/application/ports/embedder.py +37 -0
  78. refindery-0.1.0/src/refindery/application/ports/entity_extractor.py +17 -0
  79. refindery-0.1.0/src/refindery/application/ports/job_queue.py +43 -0
  80. refindery-0.1.0/src/refindery/application/ports/metadata_store.py +466 -0
  81. refindery-0.1.0/src/refindery/application/ports/query_log.py +72 -0
  82. refindery-0.1.0/src/refindery/application/ports/query_log_reader.py +66 -0
  83. refindery-0.1.0/src/refindery/application/ports/reranker.py +41 -0
  84. refindery-0.1.0/src/refindery/application/ports/surface_embedder.py +23 -0
  85. refindery-0.1.0/src/refindery/application/ports/vector_store.py +147 -0
  86. refindery-0.1.0/src/refindery/application/services/__init__.py +1 -0
  87. refindery-0.1.0/src/refindery/application/services/backfill.py +210 -0
  88. refindery-0.1.0/src/refindery/application/services/canonicalization.py +205 -0
  89. refindery-0.1.0/src/refindery/application/services/cluster_triggers.py +54 -0
  90. refindery-0.1.0/src/refindery/application/services/clustering_run.py +400 -0
  91. refindery-0.1.0/src/refindery/application/services/compare_service.py +299 -0
  92. refindery-0.1.0/src/refindery/application/services/entity_ingest.py +74 -0
  93. refindery-0.1.0/src/refindery/application/services/eval_service.py +360 -0
  94. refindery-0.1.0/src/refindery/application/services/extraction_router.py +38 -0
  95. refindery-0.1.0/src/refindery/application/services/feedback_service.py +28 -0
  96. refindery-0.1.0/src/refindery/application/services/forget_service.py +166 -0
  97. refindery-0.1.0/src/refindery/application/services/indexed_pages.py +27 -0
  98. refindery-0.1.0/src/refindery/application/services/indexing.py +245 -0
  99. refindery-0.1.0/src/refindery/application/services/ingest.py +150 -0
  100. refindery-0.1.0/src/refindery/application/services/model_registry.py +113 -0
  101. refindery-0.1.0/src/refindery/application/services/search_service.py +497 -0
  102. refindery-0.1.0/src/refindery/application/services/similarity_service.py +196 -0
  103. refindery-0.1.0/src/refindery/application/timing.py +31 -0
  104. refindery-0.1.0/src/refindery/cli.py +256 -0
  105. refindery-0.1.0/src/refindery/config.py +302 -0
  106. refindery-0.1.0/src/refindery/domain/__init__.py +5 -0
  107. refindery-0.1.0/src/refindery/domain/canonical_url.py +100 -0
  108. refindery-0.1.0/src/refindery/domain/clustering.py +220 -0
  109. refindery-0.1.0/src/refindery/domain/content_hash.py +8 -0
  110. refindery-0.1.0/src/refindery/domain/ctfidf.py +59 -0
  111. refindery-0.1.0/src/refindery/domain/entities.py +84 -0
  112. refindery-0.1.0/src/refindery/domain/errors.py +119 -0
  113. refindery-0.1.0/src/refindery/domain/ids.py +58 -0
  114. refindery-0.1.0/src/refindery/domain/models.py +250 -0
  115. refindery-0.1.0/src/refindery/domain/ranking_metrics.py +149 -0
  116. refindery-0.1.0/src/refindery/domain/retrieval.py +147 -0
  117. refindery-0.1.0/src/refindery/domain/rollup.py +43 -0
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.4
2
+ Name: refindery
3
+ Version: 0.1.0
4
+ Summary: A local, single-machine retrieval engine over the web pages you read.
5
+ Keywords: retrieval,search,rag,embeddings,mcp,browser-history
6
+ Author: Harold Martin
7
+ Author-email: Harold Martin <harold.martin@gmail.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Framework :: FastAPI
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Text Processing :: Indexing
14
+ Requires-Dist: aiosqlite>=0.22.1
15
+ Requires-Dist: catsu>=0.1.8
16
+ Requires-Dist: chonkie>=1.7.0
17
+ Requires-Dist: duckdb>=1.5.4
18
+ Requires-Dist: fastapi>=0.139.0
19
+ Requires-Dist: fastapi-mcp>=0.4.0
20
+ Requires-Dist: fastembed>=0.8.0
21
+ Requires-Dist: httpx>=0.28.1
22
+ Requires-Dist: huey>=3.1.1
23
+ Requires-Dist: inflect>=7.5.0
24
+ Requires-Dist: lancedb>=0.34.0
25
+ Requires-Dist: model2vec>=0.8.2
26
+ Requires-Dist: numpy>=2.2
27
+ Requires-Dist: opentelemetry-api>=1.43.0
28
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.43.0
29
+ Requires-Dist: opentelemetry-instrumentation-fastapi>=0.64b0
30
+ Requires-Dist: opentelemetry-sdk>=1.43.0
31
+ Requires-Dist: prometheus-client>=0.25.0
32
+ Requires-Dist: pyarrow>=24.0.0
33
+ Requires-Dist: pydantic>=2.13.4
34
+ Requires-Dist: pydantic-settings>=2.14.2
35
+ Requires-Dist: pypdf>=6.14.2
36
+ Requires-Dist: qdrant-client>=1.18.0
37
+ Requires-Dist: rerankers>=0.10.0
38
+ Requires-Dist: scikit-learn>=1.9.0
39
+ Requires-Dist: scipy>=1.18.0
40
+ Requires-Dist: spacy>=3.8.3,<4
41
+ Requires-Dist: transformers>=5.13.0
42
+ Requires-Dist: umap-learn>=0.5.12
43
+ Requires-Dist: uuid6>=2025.0.1
44
+ Requires-Dist: uvicorn>=0.51.0
45
+ Requires-Dist: gliner>=0.2.24 ; extra == 'gliner'
46
+ Requires-Dist: gliner-spacy>=0.0.11 ; extra == 'gliner'
47
+ Requires-Dist: onnxruntime>=1.27.0 ; extra == 'gliner'
48
+ Requires-Dist: torch>=2.13.0 ; extra == 'html'
49
+ Requires-Dist: igraph>=1.0.0 ; extra == 'leiden'
50
+ Requires-Dist: leidenalg>=0.12.0 ; extra == 'leiden'
51
+ Requires-Dist: en-core-web-sm ; extra == 'ner'
52
+ Requires-Python: >=3.13
53
+ Project-URL: Homepage, https://github.com/hbmartin/refindery
54
+ Project-URL: Repository, https://github.com/hbmartin/refindery
55
+ Project-URL: Changelog, https://github.com/hbmartin/refindery/releases
56
+ Provides-Extra: gliner
57
+ Provides-Extra: html
58
+ Provides-Extra: leiden
59
+ Provides-Extra: ner
60
+ Description-Content-Type: text/markdown
61
+
62
+ # Refindery
63
+
64
+ A local, single-machine retrieval engine over the web pages you read.
65
+
66
+ Upstream capture systems (browser extensions, history readers) extract
67
+ main-body text and POST it here. Refindery chunks, embeds, indexes, clusters,
68
+ and extracts entities from that text, then serves hybrid retrieval over it via
69
+ a local HTTP API and an MCP server.
70
+
71
+ **This is a retrieval engine, not a Q&A system.** It returns ranked, grounded
72
+ passages with provenance. Synthesis is the caller's job — typically an LLM
73
+ agent (e.g. Claude via MCP) that treats Refindery as a tool. No generation
74
+ appears on the query path.
75
+
76
+ ## Jobs to be done
77
+
78
+ 1. **Refind** — "I read something about X, take me back to it."
79
+ 2. **Synthesize** — "What have I learned about Y?" (agent-mediated; Refindery supplies the passages)
80
+ 3. **Resurface** — "What have I been reading a lot about?" (clusters, similarity)
81
+
82
+ ## Architecture
83
+
84
+ Hexagonal / ports-and-adapters. A single non-blocking asyncio process hosts
85
+ the FastAPI app, the MCP server, and the job queue consumer; CPU-bound work
86
+ runs in a process pool.
87
+
88
+ | Port | Default adapter | Alternatives |
89
+ | ----------------- | ------------------------------ | ---------------------------------- |
90
+ | `VectorStore` | Qdrant (Docker) | LanceDB (in-process, zero daemon) |
91
+ | `MetadataStore` | SQLite (WAL) | Postgres (v2) |
92
+ | `Embedder` | Voyage (via catsu) | Cohere, OpenAI, local |
93
+ | `Reranker` | Cohere / Voyage (via rerankers)| local cross-encoders |
94
+ | `EntityExtractor` | spaCy + gazetteer | GLiNER (extra), LLM |
95
+ | `ClusterEngine` | UMAP + HDBSCAN | KMeans, Leiden (extra) |
96
+
97
+ Observability: OpenTelemetry traces (off by default), structured JSON logs,
98
+ Prometheus `/metrics`, and a DuckDB append-only query log — the substrate for
99
+ offline retrieval evals: `refindery eval score` computes nDCG/MRR/recall from
100
+ logged queries + `/v1/feedback` labels, and `refindery eval replay` diffs two
101
+ configurations (models or rerank on/off) over the same golden set.
102
+
103
+ ## Quickstart
104
+
105
+ ### Minimal profile (no Docker)
106
+
107
+ ```bash
108
+ uv sync --extra ner
109
+ export REFINDERY_AUTH_TOKEN="$(openssl rand -hex 24)"
110
+ export REFINDERY_VECTOR_STORE=lancedb
111
+ export VOYAGE_API_KEY=... # or configure another embedding provider
112
+ python -m refindery
113
+ ```
114
+
115
+ ### Docker profile (Qdrant, the default store)
116
+
117
+ ```bash
118
+ uv sync --extra ner
119
+ docker compose up -d qdrant
120
+ export REFINDERY_AUTH_TOKEN="$(openssl rand -hex 24)"
121
+ python -m refindery
122
+ ```
123
+
124
+ ### Fully containerized
125
+
126
+ The multi-stage `Dockerfile` builds a slim image with the `ner` extra
127
+ (no torch/gliner; add extras to the sync lines if you need them). Data
128
+ lives on the `refindery_data` volume, model caches on `refindery_models`.
129
+
130
+ ```bash
131
+ export REFINDERY_AUTH_TOKEN="$(openssl rand -hex 24)"
132
+ export VOYAGE_API_KEY=...
133
+ docker compose up -d --build
134
+ curl -s http://127.0.0.1:8000/healthz
135
+ ```
136
+
137
+ ### Ingest and search
138
+
139
+ ```bash
140
+ curl -s -X POST http://127.0.0.1:8000/v1/pages \
141
+ -H "Authorization: Bearer $REFINDERY_AUTH_TOKEN" \
142
+ -H "Content-Type: application/json" \
143
+ -d '{"url": "https://example.com/article", "title": "An Article",
144
+ "body_extracted": "Plain text main body content...",
145
+ "fetched_at": "2026-07-08T10:00:00Z", "source": "extension"}'
146
+
147
+ curl -s -X POST http://127.0.0.1:8000/v1/search \
148
+ -H "Authorization: Bearer $REFINDERY_AUTH_TOKEN" \
149
+ -H "Content-Type: application/json" \
150
+ -d '{"query": "main body content"}'
151
+ ```
152
+
153
+ ### MCP
154
+
155
+ The MCP server is served over streamable HTTP at `/mcp` (same bearer token):
156
+
157
+ ```bash
158
+ claude mcp add --transport http refindery http://127.0.0.1:8000/mcp \
159
+ --header "Authorization: Bearer $REFINDERY_AUTH_TOKEN"
160
+ ```
161
+
162
+ Read-only tools are exposed by default (`search`, `get_page`, `similar_to`,
163
+ `list_clusters`, …). Mutating tools (`add_page`, `forget`) are opt-in via
164
+ `REFINDERY_MCP__ENABLE_MUTATING_TOOLS=true`. That flag controls visibility
165
+ only — authorization comes from the token's scopes on every transport.
166
+
167
+ ### Auth tokens and scopes
168
+
169
+ `REFINDERY_AUTH_TOKEN` is a single full-access token. To hand each capture
170
+ source or agent its own revocable token, configure named tokens with `read`
171
+ or `write` scopes (write implies read; both forms can coexist):
172
+
173
+ ```bash
174
+ export REFINDERY_AUTH_TOKENS='[
175
+ {"name": "chrome-capture", "token": "...", "scopes": ["write"]},
176
+ {"name": "agent", "token": "...", "scopes": ["read"]}
177
+ ]'
178
+ ```
179
+
180
+ Read-scoped tokens can search, browse, compare, and record feedback;
181
+ mutating endpoints (`add_page`, `forget`, model management, …) return 403
182
+ without the `write` scope.
183
+
184
+ ## HTTP API
185
+
186
+ ```
187
+ POST /v1/pages ingest (body_extracted XOR body_html; neither → fetch)
188
+ GET /v1/pages/{id} full body_text + metadata
189
+ GET /v1/pages/{id}/status queued|indexing|indexed|failed|dead
190
+ GET /v1/pages/{id}/similar ?mediation=vector|cluster|entity&k=
191
+ GET /v1/pages/{id}/entities
192
+ POST /v1/search hybrid dense+sparse, RRF, rerank, filters
193
+ POST /v1/compare A/B embedding models (Jaccard@k, RBO, Kendall's τ)
194
+ GET /v1/clusters ?include_tombstoned=false
195
+ GET /v1/clusters/{id}
196
+ POST /v1/clusters/recompute
197
+ GET /v1/entities/{id_or_form}
198
+ POST /v1/forget purge + blacklist atomically
199
+ GET /v1/blacklist
200
+ DELETE /v1/blacklist/{id}
201
+ POST /v1/models register embedding model
202
+ POST /v1/models/{id}/backfill dry-run estimate, then confirm
203
+ POST /v1/models/{id}/activate
204
+ DELETE /v1/models/{id}
205
+ POST /v1/feedback { query_id, page_id, relevant }
206
+ GET /healthz /readyz /metrics
207
+ ```
208
+
209
+ Bearer token is always required, even on loopback. The server binds to
210
+ `127.0.0.1` by default.
211
+
212
+ ## Optional extras
213
+
214
+ | Extra | Enables | Pulls in |
215
+ | ----------- | ---------------------------------------------- | ------------------- |
216
+ | `html` | `body_html` / fetched-HTML extraction (pulpie) | torch (~2 GB) |
217
+ | `gliner` | GLiNER zero-shot NER | gliner, onnxruntime |
218
+ | `ner` | spaCy NER model | en_core_web_sm |
219
+ | `leiden` | Leiden clustering | igraph, leidenalg |
220
+
221
+ Entity extraction is required at startup. The default extractor chain needs
222
+ the `ner` extra unless you configure a healthy gazetteer, GLiNER, or LLM
223
+ extractor.
224
+
225
+ See [Architecture](docs/architecture.md) for the ports/adapters map, the
226
+ ingest→index→cluster data flow, and the search pipeline. See
227
+ [Operations](docs/operations.md) for alpha reset commands, query-log
228
+ purging, job lease behavior, vector-store caveats, and accepted risks.
229
+
230
+ ## Development
231
+
232
+ See [CONTRIBUTING.md](CONTRIBUTING.md). TL;DR: `uv sync --all-groups --extra ner`,
233
+ then `uv run ruff format . && uv run ruff check . && uv run pytest && uv run ty check && uv run pyrefly check`.
@@ -0,0 +1,172 @@
1
+ # Refindery
2
+
3
+ A local, single-machine retrieval engine over the web pages you read.
4
+
5
+ Upstream capture systems (browser extensions, history readers) extract
6
+ main-body text and POST it here. Refindery chunks, embeds, indexes, clusters,
7
+ and extracts entities from that text, then serves hybrid retrieval over it via
8
+ a local HTTP API and an MCP server.
9
+
10
+ **This is a retrieval engine, not a Q&A system.** It returns ranked, grounded
11
+ passages with provenance. Synthesis is the caller's job — typically an LLM
12
+ agent (e.g. Claude via MCP) that treats Refindery as a tool. No generation
13
+ appears on the query path.
14
+
15
+ ## Jobs to be done
16
+
17
+ 1. **Refind** — "I read something about X, take me back to it."
18
+ 2. **Synthesize** — "What have I learned about Y?" (agent-mediated; Refindery supplies the passages)
19
+ 3. **Resurface** — "What have I been reading a lot about?" (clusters, similarity)
20
+
21
+ ## Architecture
22
+
23
+ Hexagonal / ports-and-adapters. A single non-blocking asyncio process hosts
24
+ the FastAPI app, the MCP server, and the job queue consumer; CPU-bound work
25
+ runs in a process pool.
26
+
27
+ | Port | Default adapter | Alternatives |
28
+ | ----------------- | ------------------------------ | ---------------------------------- |
29
+ | `VectorStore` | Qdrant (Docker) | LanceDB (in-process, zero daemon) |
30
+ | `MetadataStore` | SQLite (WAL) | Postgres (v2) |
31
+ | `Embedder` | Voyage (via catsu) | Cohere, OpenAI, local |
32
+ | `Reranker` | Cohere / Voyage (via rerankers)| local cross-encoders |
33
+ | `EntityExtractor` | spaCy + gazetteer | GLiNER (extra), LLM |
34
+ | `ClusterEngine` | UMAP + HDBSCAN | KMeans, Leiden (extra) |
35
+
36
+ Observability: OpenTelemetry traces (off by default), structured JSON logs,
37
+ Prometheus `/metrics`, and a DuckDB append-only query log — the substrate for
38
+ offline retrieval evals: `refindery eval score` computes nDCG/MRR/recall from
39
+ logged queries + `/v1/feedback` labels, and `refindery eval replay` diffs two
40
+ configurations (models or rerank on/off) over the same golden set.
41
+
42
+ ## Quickstart
43
+
44
+ ### Minimal profile (no Docker)
45
+
46
+ ```bash
47
+ uv sync --extra ner
48
+ export REFINDERY_AUTH_TOKEN="$(openssl rand -hex 24)"
49
+ export REFINDERY_VECTOR_STORE=lancedb
50
+ export VOYAGE_API_KEY=... # or configure another embedding provider
51
+ python -m refindery
52
+ ```
53
+
54
+ ### Docker profile (Qdrant, the default store)
55
+
56
+ ```bash
57
+ uv sync --extra ner
58
+ docker compose up -d qdrant
59
+ export REFINDERY_AUTH_TOKEN="$(openssl rand -hex 24)"
60
+ python -m refindery
61
+ ```
62
+
63
+ ### Fully containerized
64
+
65
+ The multi-stage `Dockerfile` builds a slim image with the `ner` extra
66
+ (no torch/gliner; add extras to the sync lines if you need them). Data
67
+ lives on the `refindery_data` volume, model caches on `refindery_models`.
68
+
69
+ ```bash
70
+ export REFINDERY_AUTH_TOKEN="$(openssl rand -hex 24)"
71
+ export VOYAGE_API_KEY=...
72
+ docker compose up -d --build
73
+ curl -s http://127.0.0.1:8000/healthz
74
+ ```
75
+
76
+ ### Ingest and search
77
+
78
+ ```bash
79
+ curl -s -X POST http://127.0.0.1:8000/v1/pages \
80
+ -H "Authorization: Bearer $REFINDERY_AUTH_TOKEN" \
81
+ -H "Content-Type: application/json" \
82
+ -d '{"url": "https://example.com/article", "title": "An Article",
83
+ "body_extracted": "Plain text main body content...",
84
+ "fetched_at": "2026-07-08T10:00:00Z", "source": "extension"}'
85
+
86
+ curl -s -X POST http://127.0.0.1:8000/v1/search \
87
+ -H "Authorization: Bearer $REFINDERY_AUTH_TOKEN" \
88
+ -H "Content-Type: application/json" \
89
+ -d '{"query": "main body content"}'
90
+ ```
91
+
92
+ ### MCP
93
+
94
+ The MCP server is served over streamable HTTP at `/mcp` (same bearer token):
95
+
96
+ ```bash
97
+ claude mcp add --transport http refindery http://127.0.0.1:8000/mcp \
98
+ --header "Authorization: Bearer $REFINDERY_AUTH_TOKEN"
99
+ ```
100
+
101
+ Read-only tools are exposed by default (`search`, `get_page`, `similar_to`,
102
+ `list_clusters`, …). Mutating tools (`add_page`, `forget`) are opt-in via
103
+ `REFINDERY_MCP__ENABLE_MUTATING_TOOLS=true`. That flag controls visibility
104
+ only — authorization comes from the token's scopes on every transport.
105
+
106
+ ### Auth tokens and scopes
107
+
108
+ `REFINDERY_AUTH_TOKEN` is a single full-access token. To hand each capture
109
+ source or agent its own revocable token, configure named tokens with `read`
110
+ or `write` scopes (write implies read; both forms can coexist):
111
+
112
+ ```bash
113
+ export REFINDERY_AUTH_TOKENS='[
114
+ {"name": "chrome-capture", "token": "...", "scopes": ["write"]},
115
+ {"name": "agent", "token": "...", "scopes": ["read"]}
116
+ ]'
117
+ ```
118
+
119
+ Read-scoped tokens can search, browse, compare, and record feedback;
120
+ mutating endpoints (`add_page`, `forget`, model management, …) return 403
121
+ without the `write` scope.
122
+
123
+ ## HTTP API
124
+
125
+ ```
126
+ POST /v1/pages ingest (body_extracted XOR body_html; neither → fetch)
127
+ GET /v1/pages/{id} full body_text + metadata
128
+ GET /v1/pages/{id}/status queued|indexing|indexed|failed|dead
129
+ GET /v1/pages/{id}/similar ?mediation=vector|cluster|entity&k=
130
+ GET /v1/pages/{id}/entities
131
+ POST /v1/search hybrid dense+sparse, RRF, rerank, filters
132
+ POST /v1/compare A/B embedding models (Jaccard@k, RBO, Kendall's τ)
133
+ GET /v1/clusters ?include_tombstoned=false
134
+ GET /v1/clusters/{id}
135
+ POST /v1/clusters/recompute
136
+ GET /v1/entities/{id_or_form}
137
+ POST /v1/forget purge + blacklist atomically
138
+ GET /v1/blacklist
139
+ DELETE /v1/blacklist/{id}
140
+ POST /v1/models register embedding model
141
+ POST /v1/models/{id}/backfill dry-run estimate, then confirm
142
+ POST /v1/models/{id}/activate
143
+ DELETE /v1/models/{id}
144
+ POST /v1/feedback { query_id, page_id, relevant }
145
+ GET /healthz /readyz /metrics
146
+ ```
147
+
148
+ Bearer token is always required, even on loopback. The server binds to
149
+ `127.0.0.1` by default.
150
+
151
+ ## Optional extras
152
+
153
+ | Extra | Enables | Pulls in |
154
+ | ----------- | ---------------------------------------------- | ------------------- |
155
+ | `html` | `body_html` / fetched-HTML extraction (pulpie) | torch (~2 GB) |
156
+ | `gliner` | GLiNER zero-shot NER | gliner, onnxruntime |
157
+ | `ner` | spaCy NER model | en_core_web_sm |
158
+ | `leiden` | Leiden clustering | igraph, leidenalg |
159
+
160
+ Entity extraction is required at startup. The default extractor chain needs
161
+ the `ner` extra unless you configure a healthy gazetteer, GLiNER, or LLM
162
+ extractor.
163
+
164
+ See [Architecture](docs/architecture.md) for the ports/adapters map, the
165
+ ingest→index→cluster data flow, and the search pipeline. See
166
+ [Operations](docs/operations.md) for alpha reset commands, query-log
167
+ purging, job lease behavior, vector-store caveats, and accepted risks.
168
+
169
+ ## Development
170
+
171
+ See [CONTRIBUTING.md](CONTRIBUTING.md). TL;DR: `uv sync --all-groups --extra ner`,
172
+ then `uv run ruff format . && uv run ruff check . && uv run pytest && uv run ty check && uv run pyrefly check`.
@@ -0,0 +1,153 @@
1
+ [project]
2
+ name = "refindery"
3
+ version = "0.1.0"
4
+ description = "A local, single-machine retrieval engine over the web pages you read."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ authors = [
8
+ { name = "Harold Martin", email = "harold.martin@gmail.com" },
9
+ ]
10
+ license = "MIT"
11
+ keywords = ["retrieval", "search", "rag", "embeddings", "mcp", "browser-history"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Framework :: FastAPI",
16
+ "Intended Audience :: Developers",
17
+ "Topic :: Text Processing :: Indexing",
18
+ ]
19
+ dependencies = [
20
+ "aiosqlite>=0.22.1",
21
+ "catsu>=0.1.8",
22
+ "chonkie>=1.7.0",
23
+ "duckdb>=1.5.4",
24
+ "fastapi>=0.139.0",
25
+ "fastapi-mcp>=0.4.0",
26
+ "fastembed>=0.8.0",
27
+ "httpx>=0.28.1",
28
+ "huey>=3.1.1",
29
+ "inflect>=7.5.0",
30
+ "lancedb>=0.34.0",
31
+ "model2vec>=0.8.2",
32
+ "numpy>=2.2",
33
+ "opentelemetry-api>=1.43.0",
34
+ "opentelemetry-exporter-otlp-proto-http>=1.43.0",
35
+ "opentelemetry-instrumentation-fastapi>=0.64b0",
36
+ "opentelemetry-sdk>=1.43.0",
37
+ "prometheus-client>=0.25.0",
38
+ "pyarrow>=24.0.0",
39
+ "pydantic>=2.13.4",
40
+ "pydantic-settings>=2.14.2",
41
+ "pypdf>=6.14.2",
42
+ "qdrant-client>=1.18.0",
43
+ "rerankers>=0.10.0",
44
+ "scikit-learn>=1.9.0",
45
+ "scipy>=1.18.0",
46
+ "spacy>=3.8.3,<4",
47
+ "transformers>=5.13.0",
48
+ "umap-learn>=0.5.12",
49
+ "uuid6>=2025.0.1",
50
+ "uvicorn>=0.51.0",
51
+ ]
52
+
53
+ [project.scripts]
54
+ refindery = "refindery.cli:main"
55
+
56
+ [project.urls]
57
+ Homepage = "https://github.com/hbmartin/refindery"
58
+ Repository = "https://github.com/hbmartin/refindery"
59
+ Changelog = "https://github.com/hbmartin/refindery/releases"
60
+
61
+ [project.optional-dependencies]
62
+ html = [
63
+ "torch>=2.13.0",
64
+ ]
65
+ gliner = [
66
+ "gliner>=0.2.24",
67
+ "gliner-spacy>=0.0.11",
68
+ "onnxruntime>=1.27.0",
69
+ ]
70
+ leiden = [
71
+ "igraph>=1.0.0",
72
+ "leidenalg>=0.12.0",
73
+ ]
74
+ ner = [
75
+ "en-core-web-sm",
76
+ ]
77
+
78
+ [dependency-groups]
79
+ dev = [
80
+ "deptry>=0.24.0",
81
+ "lizard>=1.23.0",
82
+ "pyrefly>=1.1.1",
83
+ "pyroma>=5.0",
84
+ "pytest>=9.1.1",
85
+ "pytest-asyncio>=1.0.0",
86
+ "pytest-cov>=7.1.0",
87
+ "pytest-httpx>=0.36.2",
88
+ "ruff>=0.15.20",
89
+ "testcontainers>=4.14.2",
90
+ "ty>=0.0.57",
91
+ ]
92
+
93
+ [tool.uv]
94
+ # numba is not imported directly; the floor keeps the resolver from walking
95
+ # back to ancient sdists instead of downgrading numpy (numba needs numpy<2.5).
96
+ constraint-dependencies = ["numba>=0.61"]
97
+
98
+ [tool.uv.sources]
99
+ en-core-web-sm = { url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl" }
100
+
101
+ [tool.deptry.per_rule_ignores]
102
+ DEP002 = [
103
+ # Optional extras, imported lazily (deptry cannot see conditional imports):
104
+ "torch", "gliner", "gliner-spacy", "onnxruntime", "en-core-web-sm",
105
+ "igraph", "leidenalg",
106
+ # Lazy imports inside functions/adapters:
107
+ "scikit-learn", "umap-learn", "spacy", "model2vec",
108
+ "opentelemetry-exporter-otlp-proto-http",
109
+ "opentelemetry-instrumentation-fastapi",
110
+ ]
111
+
112
+ [tool.pytest.ini_options]
113
+ testpaths = ["tests"]
114
+ asyncio_mode = "auto"
115
+ markers = [
116
+ "qdrant: requires a running Qdrant instance (QDRANT_URL or Docker)",
117
+ "slow: slow tests (model downloads, JIT warmup)",
118
+ "external: hits external APIs; requires credentials",
119
+ ]
120
+ addopts = "-m 'not external'"
121
+
122
+ [tool.coverage.run]
123
+ source = ["src/refindery"]
124
+
125
+ [tool.coverage.report]
126
+ # Floor set just below measured coverage (85% local with qdrant tests
127
+ # skipped) to stop silent rot; applies to any full --cov run, local or CI.
128
+ fail_under = 84
129
+
130
+ [tool.pyrefly]
131
+ search-path = ["src", "."]
132
+
133
+ [tool.ruff]
134
+ # Exclude a variety of commonly ignored directories.
135
+ exclude = [".bzr", ".direnv", ".eggs", ".git", ".git-rewrite", ".hg", ".ipynb_checkpoints", ".mypy_cache", ".nox", ".pants.d", ".pyenv", ".pytest_cache", ".pytype", ".ruff_cache", ".svn", ".tox", ".venv", ".vscode", "__pypackages__", "_build", "buck-out", "build", "dist", "node_modules", "setup.py", "site-packages", "venv"]
136
+
137
+ line-length = 88
138
+ indent-width = 4
139
+ target-version = "py313"
140
+
141
+ [tool.ruff.lint]
142
+ select = ["ALL"]
143
+ ignore = ["COM812", "D105", "D107", "D203", "D212", "D213", "EM101", "PLR2004", "T201"]
144
+
145
+ [tool.ruff.lint.pylint]
146
+ max-args = 10
147
+
148
+ [tool.ruff.lint.per-file-ignores]
149
+ "tests/**/*.py" = ["ANN001", "ANN002", "ANN003", "ANN201", "ARG001", "ARG005", "D101", "D102", "D103", "D104", "DTZ003", "ERA001", "F403", "F405", "FBT002", "G004", "N802", "PLC0415", "PLR2004", "PLW1510", "PT011", "PT013", "PTH110", "PTH118", "PTH119", "PTH122", "PTH123", "PTH207", "S101", "S102", "S311", "S603", "S607", "SIM115"]
150
+
151
+ [build-system]
152
+ requires = ["uv_build>=0.11.28,<0.12.0"]
153
+ build-backend = "uv_build"
@@ -0,0 +1,3 @@
1
+ """Refindery: a local, single-machine retrieval engine over the web pages you read."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Entry point: ``python -m refindery`` dispatches to the CLI (default: serve)."""
2
+
3
+ from refindery.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1 @@
1
+ """Adapters: concrete implementations of the application ports."""
@@ -0,0 +1 @@
1
+ """Canonical chunking adapter (chonkie)."""
@@ -0,0 +1,73 @@
1
+ """Sentence-aware chunking via chonkie with a hard token ceiling.
2
+
3
+ Chunking is canonical and model-independent: one chunking, every embedding
4
+ model embeds the same spans. The cl100k tokenizer is the canonical counter.
5
+ chonkie targets ``chunk_size`` but can exceed it on pathological input, so a
6
+ post-pass re-splits anything above the hard max (which model registration
7
+ guarantees is within every registered model's budget).
8
+ """
9
+
10
+ from chonkie import SentenceChunker, TokenChunker
11
+
12
+ from refindery.domain.ids import PageId, new_chunk_id
13
+ from refindery.domain.models import Chunk
14
+
15
+ _TOKENIZER = "cl100k_base"
16
+
17
+
18
+ class ChonkieChunker:
19
+ """Chunker port implementation over chonkie's SentenceChunker."""
20
+
21
+ def __init__(
22
+ self,
23
+ *,
24
+ target_tokens: int = 448,
25
+ overlap_tokens: int = 64,
26
+ hard_max_tokens: int = 512,
27
+ ) -> None:
28
+ self._hard_max = hard_max_tokens
29
+ self._sentence = SentenceChunker(
30
+ tokenizer=_TOKENIZER,
31
+ chunk_size=target_tokens,
32
+ chunk_overlap=overlap_tokens,
33
+ )
34
+ self._splitter = TokenChunker(
35
+ tokenizer=_TOKENIZER,
36
+ chunk_size=hard_max_tokens,
37
+ chunk_overlap=0,
38
+ )
39
+
40
+ def chunk(self, *, page_id: PageId, text: str) -> list[Chunk]:
41
+ """Split ``text`` into ordered chunks with char offsets and token counts."""
42
+ if not text.strip():
43
+ return []
44
+ spans: list[tuple[str, int, int, int]] = []
45
+ for piece in self._sentence.chunk(text):
46
+ if piece.token_count <= self._hard_max:
47
+ spans.append(
48
+ (piece.text, piece.start_index, piece.end_index, piece.token_count)
49
+ )
50
+ continue
51
+ spans.extend(
52
+ (
53
+ sub.text,
54
+ piece.start_index + sub.start_index,
55
+ piece.start_index + sub.end_index,
56
+ sub.token_count,
57
+ )
58
+ for sub in self._splitter.chunk(piece.text)
59
+ )
60
+ return [
61
+ Chunk(
62
+ id=new_chunk_id(),
63
+ page_id=page_id,
64
+ ordinal=ordinal,
65
+ text=chunk_text,
66
+ token_count=token_count,
67
+ char_start=char_start,
68
+ char_end=char_end,
69
+ )
70
+ for ordinal, (chunk_text, char_start, char_end, token_count) in enumerate(
71
+ spans
72
+ )
73
+ ]
@@ -0,0 +1,11 @@
1
+ """System clock adapter."""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+
6
+ class SystemClock:
7
+ """Wall clock returning tz-aware UTC datetimes."""
8
+
9
+ def now(self) -> datetime:
10
+ """Return the current UTC time."""
11
+ return datetime.now(tz=UTC)
@@ -0,0 +1 @@
1
+ """Clustering engine adapters (UMAP/PCA + HDBSCAN/KMeans in a process pool)."""