refindery 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- refindery/__init__.py +3 -0
- refindery/__main__.py +6 -0
- refindery/adapters/__init__.py +1 -0
- refindery/adapters/chunking/__init__.py +1 -0
- refindery/adapters/chunking/chonkie_chunker.py +73 -0
- refindery/adapters/clock.py +11 -0
- refindery/adapters/cluster/__init__.py +1 -0
- refindery/adapters/cluster/engine.py +53 -0
- refindery/adapters/cluster/worker.py +208 -0
- refindery/adapters/embedding/__init__.py +1 -0
- refindery/adapters/embedding/catsu_embedder.py +84 -0
- refindery/adapters/embedding/surface_forms.py +53 -0
- refindery/adapters/extraction/__init__.py +1 -0
- refindery/adapters/extraction/http_fetcher.py +56 -0
- refindery/adapters/extraction/pdf_pypdf.py +32 -0
- refindery/adapters/extraction/pulpie_html.py +82 -0
- refindery/adapters/extractors/__init__.py +1 -0
- refindery/adapters/extractors/chain.py +48 -0
- refindery/adapters/extractors/gazetteer.py +66 -0
- refindery/adapters/extractors/gliner_spacy.py +97 -0
- refindery/adapters/extractors/llm.py +86 -0
- refindery/adapters/extractors/spacy_ner.py +69 -0
- refindery/adapters/llm/__init__.py +1 -0
- refindery/adapters/llm/openai_compat.py +70 -0
- refindery/adapters/metadata/__init__.py +1 -0
- refindery/adapters/metadata/migrations/0001_initial.sql +75 -0
- refindery/adapters/metadata/migrations/0002_forget.sql +12 -0
- refindery/adapters/metadata/migrations/0003_entities_clusters.sql +94 -0
- refindery/adapters/metadata/migrations/0004_backfills.sql +13 -0
- refindery/adapters/metadata/migrations/0005_jobs_page_id.sql +7 -0
- refindery/adapters/metadata/migrations/__init__.py +1 -0
- refindery/adapters/metadata/migrator.py +77 -0
- refindery/adapters/metadata/sqlite_store.py +1531 -0
- refindery/adapters/observability/__init__.py +1 -0
- refindery/adapters/observability/duckdb_sink.py +137 -0
- refindery/adapters/observability/logging.py +64 -0
- refindery/adapters/observability/metrics.py +76 -0
- refindery/adapters/observability/otel.py +58 -0
- refindery/adapters/observability/query_log.py +109 -0
- refindery/adapters/observability/query_log_reader.py +102 -0
- refindery/adapters/queue/__init__.py +1 -0
- refindery/adapters/queue/huey_queue.py +243 -0
- refindery/adapters/reranking/__init__.py +1 -0
- refindery/adapters/reranking/api.py +55 -0
- refindery/adapters/vector/__init__.py +1 -0
- refindery/adapters/vector/hybrid.py +44 -0
- refindery/adapters/vector/lancedb_store.py +326 -0
- refindery/adapters/vector/names.py +16 -0
- refindery/adapters/vector/qdrant_store.py +325 -0
- refindery/adapters/vector/sparse.py +45 -0
- refindery/api/__init__.py +1 -0
- refindery/api/app.py +65 -0
- refindery/api/auth.py +88 -0
- refindery/api/deps.py +10 -0
- refindery/api/mcp.py +59 -0
- refindery/api/routes/__init__.py +1 -0
- refindery/api/routes/clusters.py +110 -0
- refindery/api/routes/compare.py +79 -0
- refindery/api/routes/entities.py +99 -0
- refindery/api/routes/forget.py +89 -0
- refindery/api/routes/health.py +42 -0
- refindery/api/routes/jobs.py +74 -0
- refindery/api/routes/models.py +166 -0
- refindery/api/routes/pages.py +185 -0
- refindery/api/routes/search.py +205 -0
- refindery/api/schemas.py +478 -0
- refindery/application/__init__.py +1 -0
- refindery/application/container.py +503 -0
- refindery/application/ports/__init__.py +1 -0
- refindery/application/ports/chunker.py +19 -0
- refindery/application/ports/clock.py +12 -0
- refindery/application/ports/cluster_engine.py +42 -0
- refindery/application/ports/content_extractor.py +60 -0
- refindery/application/ports/embedder.py +37 -0
- refindery/application/ports/entity_extractor.py +17 -0
- refindery/application/ports/job_queue.py +43 -0
- refindery/application/ports/metadata_store.py +466 -0
- refindery/application/ports/query_log.py +72 -0
- refindery/application/ports/query_log_reader.py +66 -0
- refindery/application/ports/reranker.py +41 -0
- refindery/application/ports/surface_embedder.py +23 -0
- refindery/application/ports/vector_store.py +147 -0
- refindery/application/services/__init__.py +1 -0
- refindery/application/services/backfill.py +210 -0
- refindery/application/services/canonicalization.py +205 -0
- refindery/application/services/cluster_triggers.py +54 -0
- refindery/application/services/clustering_run.py +400 -0
- refindery/application/services/compare_service.py +299 -0
- refindery/application/services/entity_ingest.py +74 -0
- refindery/application/services/eval_service.py +360 -0
- refindery/application/services/extraction_router.py +38 -0
- refindery/application/services/feedback_service.py +28 -0
- refindery/application/services/forget_service.py +166 -0
- refindery/application/services/indexed_pages.py +27 -0
- refindery/application/services/indexing.py +245 -0
- refindery/application/services/ingest.py +150 -0
- refindery/application/services/model_registry.py +113 -0
- refindery/application/services/search_service.py +497 -0
- refindery/application/services/similarity_service.py +196 -0
- refindery/application/timing.py +31 -0
- refindery/cli.py +256 -0
- refindery/config.py +302 -0
- refindery/domain/__init__.py +5 -0
- refindery/domain/canonical_url.py +100 -0
- refindery/domain/clustering.py +220 -0
- refindery/domain/content_hash.py +8 -0
- refindery/domain/ctfidf.py +59 -0
- refindery/domain/entities.py +84 -0
- refindery/domain/errors.py +119 -0
- refindery/domain/ids.py +58 -0
- refindery/domain/models.py +250 -0
- refindery/domain/ranking_metrics.py +149 -0
- refindery/domain/retrieval.py +147 -0
- refindery/domain/rollup.py +43 -0
- refindery-0.1.0.dist-info/METADATA +233 -0
- refindery-0.1.0.dist-info/RECORD +118 -0
- refindery-0.1.0.dist-info/WHEEL +4 -0
- refindery-0.1.0.dist-info/entry_points.txt +3 -0
refindery/__init__.py
ADDED
refindery/__main__.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
"""Clustering engine adapters (UMAP/PCA + HDBSCAN/KMeans in a process pool)."""
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""ClusterEngine adapter: runs the worker in a spawn ProcessPoolExecutor."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
5
|
+
from functools import partial
|
|
6
|
+
from multiprocessing import get_context
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import numpy.typing as npt
|
|
10
|
+
|
|
11
|
+
from refindery.adapters.cluster.worker import reduce_and_cluster
|
|
12
|
+
from refindery.application.ports.cluster_engine import ClusterFitResult, ClusterParams
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProcessPoolClusterEngine:
|
|
16
|
+
"""CPU-heavy clustering off the event loop."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, *, max_workers: int = 1) -> None:
|
|
19
|
+
self._executor = ProcessPoolExecutor(
|
|
20
|
+
max_workers=max_workers, mp_context=get_context("spawn")
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
async def fit(
|
|
24
|
+
self, *, vectors: npt.NDArray[np.float32], params: ClusterParams
|
|
25
|
+
) -> ClusterFitResult:
|
|
26
|
+
"""Fit in the pool; only arrays and primitives cross the boundary."""
|
|
27
|
+
loop = asyncio.get_running_loop()
|
|
28
|
+
labels, probabilities, reduce_ms, cluster_ms = await loop.run_in_executor(
|
|
29
|
+
self._executor,
|
|
30
|
+
partial(
|
|
31
|
+
reduce_and_cluster,
|
|
32
|
+
vectors,
|
|
33
|
+
algorithm=params.algorithm,
|
|
34
|
+
reducer=params.reducer,
|
|
35
|
+
n_components=params.n_components,
|
|
36
|
+
n_neighbors=params.n_neighbors,
|
|
37
|
+
min_dist=params.min_dist,
|
|
38
|
+
min_cluster_size=params.min_cluster_size,
|
|
39
|
+
min_samples=params.min_samples,
|
|
40
|
+
leiden_resolution=params.leiden_resolution,
|
|
41
|
+
random_state=params.random_state,
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
return ClusterFitResult(
|
|
45
|
+
labels=labels,
|
|
46
|
+
probabilities=probabilities,
|
|
47
|
+
reduce_ms=reduce_ms,
|
|
48
|
+
cluster_ms=cluster_ms,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def close(self) -> None:
|
|
52
|
+
"""Shut the pool down."""
|
|
53
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Process-pool worker: everything CPU-heavy about clustering.
|
|
2
|
+
|
|
3
|
+
Module-level pure function so the spawn context can pickle the reference.
|
|
4
|
+
Only primitives and numpy arrays cross the boundary; umap/sklearn import
|
|
5
|
+
INSIDE the function so the parent never pays the numba JIT cost. Stage
|
|
6
|
+
timings are returned (spans cannot cross a process boundary).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from importlib import import_module
|
|
12
|
+
from typing import Protocol, cast
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import numpy.typing as npt
|
|
16
|
+
|
|
17
|
+
_LEIDEN_EXTRA_HINT = "Leiden clustering requires `uv sync --extra leiden`"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _GraphFactory(Protocol):
|
|
21
|
+
def __call__(
|
|
22
|
+
self, *, n: int, edges: list[tuple[int, int]], directed: bool
|
|
23
|
+
) -> object:
|
|
24
|
+
"""Build an igraph graph."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _IgraphModule(Protocol):
|
|
29
|
+
Graph: _GraphFactory
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _FindPartition(Protocol):
|
|
33
|
+
def __call__(
|
|
34
|
+
self,
|
|
35
|
+
graph: object,
|
|
36
|
+
partition_type: object,
|
|
37
|
+
*,
|
|
38
|
+
weights: list[float],
|
|
39
|
+
resolution_parameter: float,
|
|
40
|
+
seed: int,
|
|
41
|
+
) -> Sequence[Sequence[int]]:
|
|
42
|
+
"""Run Leiden partitioning."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _LeidenModule(Protocol):
|
|
47
|
+
RBConfigurationVertexPartition: object
|
|
48
|
+
find_partition: _FindPartition
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _optional_leiden_modules() -> tuple[_IgraphModule, _LeidenModule]:
|
|
52
|
+
try:
|
|
53
|
+
igraph = import_module("igraph")
|
|
54
|
+
leidenalg = import_module("leidenalg")
|
|
55
|
+
except ImportError as exc:
|
|
56
|
+
raise RuntimeError(_LEIDEN_EXTRA_HINT) from exc
|
|
57
|
+
return cast("_IgraphModule", igraph), cast("_LeidenModule", leidenalg)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _knn_graph_edges(
|
|
61
|
+
matrix: npt.NDArray[np.float32], *, n_neighbors: int
|
|
62
|
+
) -> tuple[list[tuple[int, int]], list[float]]:
|
|
63
|
+
from sklearn.neighbors import NearestNeighbors # noqa: PLC0415 — worker only
|
|
64
|
+
|
|
65
|
+
k = min(max(n_neighbors, 1), len(matrix) - 1)
|
|
66
|
+
model = NearestNeighbors(n_neighbors=k + 1, metric="cosine")
|
|
67
|
+
distances, indices = model.fit(matrix).kneighbors(matrix)
|
|
68
|
+
|
|
69
|
+
edge_weights: dict[tuple[int, int], float] = {}
|
|
70
|
+
for source, (row_distances, row_indices) in enumerate(
|
|
71
|
+
zip(distances, indices, strict=True)
|
|
72
|
+
):
|
|
73
|
+
for distance, target in zip(row_distances, row_indices, strict=True):
|
|
74
|
+
target_int = int(target)
|
|
75
|
+
if source == target_int:
|
|
76
|
+
continue
|
|
77
|
+
edge = (min(source, target_int), max(source, target_int))
|
|
78
|
+
distance_value = float(distance)
|
|
79
|
+
if not np.isfinite(distance_value):
|
|
80
|
+
continue
|
|
81
|
+
weight = 1.0 / (1.0 + max(distance_value, 0.0))
|
|
82
|
+
edge_weights[edge] = max(edge_weights.get(edge, 0.0), weight)
|
|
83
|
+
edges = sorted(edge_weights)
|
|
84
|
+
return edges, [edge_weights[edge] for edge in edges]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _leiden_cluster(
|
|
88
|
+
matrix: npt.NDArray[np.float32],
|
|
89
|
+
*,
|
|
90
|
+
n_neighbors: int,
|
|
91
|
+
resolution: float,
|
|
92
|
+
random_state: int,
|
|
93
|
+
) -> npt.NDArray[np.int64]:
|
|
94
|
+
if len(matrix) <= 1:
|
|
95
|
+
return np.zeros(len(matrix), dtype=np.int64)
|
|
96
|
+
igraph, leidenalg = _optional_leiden_modules()
|
|
97
|
+
edges, weights = _knn_graph_edges(matrix, n_neighbors=n_neighbors)
|
|
98
|
+
if not edges:
|
|
99
|
+
return np.arange(len(matrix), dtype=np.int64)
|
|
100
|
+
|
|
101
|
+
graph = igraph.Graph(n=len(matrix), edges=edges, directed=False)
|
|
102
|
+
partition = leidenalg.find_partition(
|
|
103
|
+
graph,
|
|
104
|
+
leidenalg.RBConfigurationVertexPartition,
|
|
105
|
+
weights=weights,
|
|
106
|
+
resolution_parameter=resolution,
|
|
107
|
+
seed=random_state,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
labels = np.full(len(matrix), -1, dtype=np.int64)
|
|
111
|
+
for label, members in enumerate(partition):
|
|
112
|
+
labels[members] = label
|
|
113
|
+
unassigned_mask = labels < 0
|
|
114
|
+
n_unassigned = int(np.count_nonzero(unassigned_mask))
|
|
115
|
+
if n_unassigned > 0:
|
|
116
|
+
labels[unassigned_mask] = np.arange(
|
|
117
|
+
len(partition), len(partition) + n_unassigned, dtype=np.int64
|
|
118
|
+
)
|
|
119
|
+
return labels
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def reduce_and_cluster(
|
|
123
|
+
vectors: npt.NDArray[np.float32],
|
|
124
|
+
*,
|
|
125
|
+
algorithm: str,
|
|
126
|
+
reducer: str,
|
|
127
|
+
n_components: int,
|
|
128
|
+
n_neighbors: int,
|
|
129
|
+
min_dist: float,
|
|
130
|
+
min_cluster_size: int,
|
|
131
|
+
min_samples: int,
|
|
132
|
+
random_state: int,
|
|
133
|
+
leiden_resolution: float = 1.0,
|
|
134
|
+
) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.float32], float, float]:
|
|
135
|
+
"""Reduce then cluster; returns (labels, probabilities, reduce_ms, cluster_ms)."""
|
|
136
|
+
if len(vectors) == 0:
|
|
137
|
+
return (
|
|
138
|
+
np.array([], dtype=np.int64),
|
|
139
|
+
np.array([], dtype=np.float32),
|
|
140
|
+
0.0,
|
|
141
|
+
0.0,
|
|
142
|
+
)
|
|
143
|
+
started = time.perf_counter()
|
|
144
|
+
reduced = np.ascontiguousarray(vectors, dtype=np.float32)
|
|
145
|
+
match reducer:
|
|
146
|
+
case "umap" if len(vectors) > n_components + 2:
|
|
147
|
+
from umap import UMAP # noqa: PLC0415 — heavy import, worker only
|
|
148
|
+
|
|
149
|
+
reduced = UMAP(
|
|
150
|
+
n_components=n_components,
|
|
151
|
+
n_neighbors=min(n_neighbors, max(len(vectors) - 1, 2)),
|
|
152
|
+
min_dist=min_dist,
|
|
153
|
+
metric="cosine",
|
|
154
|
+
random_state=random_state,
|
|
155
|
+
).fit_transform(vectors)
|
|
156
|
+
case "umap":
|
|
157
|
+
pass
|
|
158
|
+
case "pca" if len(vectors) > n_components:
|
|
159
|
+
from sklearn.decomposition import PCA # noqa: PLC0415 — worker only
|
|
160
|
+
|
|
161
|
+
reduced = PCA(
|
|
162
|
+
n_components=n_components, random_state=random_state
|
|
163
|
+
).fit_transform(vectors)
|
|
164
|
+
case "pca" | "none":
|
|
165
|
+
pass
|
|
166
|
+
case _:
|
|
167
|
+
msg = f"unknown reducer {reducer!r}"
|
|
168
|
+
raise ValueError(msg)
|
|
169
|
+
reduce_ms = (time.perf_counter() - started) * 1_000.0
|
|
170
|
+
|
|
171
|
+
started = time.perf_counter()
|
|
172
|
+
match algorithm:
|
|
173
|
+
case "kmeans":
|
|
174
|
+
from sklearn.cluster import KMeans # noqa: PLC0415 — worker only
|
|
175
|
+
|
|
176
|
+
k = min(len(vectors), max(1, int(np.sqrt(len(vectors) / 2))))
|
|
177
|
+
model = KMeans(n_clusters=k, random_state=random_state, n_init="auto")
|
|
178
|
+
labels = model.fit_predict(reduced).astype(np.int64)
|
|
179
|
+
probabilities = np.ones(len(vectors), dtype=np.float32)
|
|
180
|
+
case "hdbscan":
|
|
181
|
+
from sklearn.cluster import ( # noqa: PLC0415 — worker only
|
|
182
|
+
HDBSCAN, # pyrefly: ignore[missing-module-attribute]
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
model = HDBSCAN(
|
|
186
|
+
min_cluster_size=min_cluster_size,
|
|
187
|
+
min_samples=min_samples,
|
|
188
|
+
metric="euclidean",
|
|
189
|
+
cluster_selection_method="eom",
|
|
190
|
+
)
|
|
191
|
+
labels = model.fit_predict(reduced).astype(np.int64)
|
|
192
|
+
probabilities = np.asarray(
|
|
193
|
+
getattr(model, "probabilities_", np.ones(len(vectors))),
|
|
194
|
+
dtype=np.float32,
|
|
195
|
+
)
|
|
196
|
+
case "leiden":
|
|
197
|
+
labels = _leiden_cluster(
|
|
198
|
+
reduced,
|
|
199
|
+
n_neighbors=n_neighbors,
|
|
200
|
+
resolution=leiden_resolution,
|
|
201
|
+
random_state=random_state,
|
|
202
|
+
)
|
|
203
|
+
probabilities = np.ones(len(vectors), dtype=np.float32)
|
|
204
|
+
case _:
|
|
205
|
+
msg = f"unknown clustering algorithm {algorithm!r}"
|
|
206
|
+
raise ValueError(msg)
|
|
207
|
+
cluster_ms = (time.perf_counter() - started) * 1_000.0
|
|
208
|
+
return labels, probabilities, reduce_ms, cluster_ms
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Embedding adapters (catsu multi-provider client)."""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Embedder adapter over catsu (Voyage/OpenAI/Cohere/... behind one client).
|
|
2
|
+
|
|
3
|
+
``dim``/``max_input_tokens`` come from the registry/settings — catsu does not
|
|
4
|
+
guarantee exposing them per model — and the returned vector length is checked
|
|
5
|
+
against ``dim`` on every call.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from catsu import Client
|
|
10
|
+
|
|
11
|
+
from refindery.domain.rollup import Vector
|
|
12
|
+
|
|
13
|
+
_PROVIDER_ALIASES = {"voyage": "voyageai"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EmbeddingDimensionMismatchError(RuntimeError):
|
|
17
|
+
"""The provider returned vectors of an unexpected dimension."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, *, model_id: str, expected: int, got: int) -> None:
|
|
20
|
+
super().__init__(
|
|
21
|
+
f"model {model_id!r} returned {got}-d vectors, expected {expected}; "
|
|
22
|
+
f"fix the registered dim"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CatsuEmbedder:
|
|
27
|
+
"""Embedder port implementation for one registered model."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
model_id: str,
|
|
33
|
+
provider: str,
|
|
34
|
+
model_name: str,
|
|
35
|
+
dim: int,
|
|
36
|
+
max_input_tokens: int,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._model_id = model_id
|
|
39
|
+
self._provider = _PROVIDER_ALIASES.get(provider, provider)
|
|
40
|
+
self._model_name = model_name
|
|
41
|
+
self._dim = dim
|
|
42
|
+
self._max_input_tokens = max_input_tokens
|
|
43
|
+
self._client = Client()
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def model_id(self) -> str:
|
|
47
|
+
"""Registry id of the model this embedder serves."""
|
|
48
|
+
return self._model_id
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def dim(self) -> int:
|
|
52
|
+
"""Dimensionality of produced vectors."""
|
|
53
|
+
return self._dim
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def max_input_tokens(self) -> int:
|
|
57
|
+
"""Maximum tokens the model accepts per input."""
|
|
58
|
+
return self._max_input_tokens
|
|
59
|
+
|
|
60
|
+
async def embed_documents(self, texts: list[str]) -> list[Vector]:
|
|
61
|
+
"""Embed document chunks (storage side)."""
|
|
62
|
+
return await self._embed(texts, input_type="document")
|
|
63
|
+
|
|
64
|
+
async def embed_query(self, text: str) -> Vector:
|
|
65
|
+
"""Embed a query (query side)."""
|
|
66
|
+
vectors = await self._embed([text], input_type="query")
|
|
67
|
+
return vectors[0]
|
|
68
|
+
|
|
69
|
+
async def _embed(self, texts: list[str], *, input_type: str) -> list[Vector]:
|
|
70
|
+
response = await self._client.aembed(
|
|
71
|
+
model=self._model_name,
|
|
72
|
+
input=texts,
|
|
73
|
+
provider=self._provider,
|
|
74
|
+
input_type=input_type,
|
|
75
|
+
)
|
|
76
|
+
vectors = [np.asarray(row, dtype=np.float32) for row in response.embeddings]
|
|
77
|
+
for vector in vectors:
|
|
78
|
+
if vector.shape != (self._dim,):
|
|
79
|
+
raise EmbeddingDimensionMismatchError(
|
|
80
|
+
model_id=self._model_id,
|
|
81
|
+
expected=self._dim,
|
|
82
|
+
got=int(vector.shape[0]),
|
|
83
|
+
)
|
|
84
|
+
return vectors
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Surface-form embedders: model2vec static model (default)."""
|
|
2
|
+
|
|
3
|
+
from threading import Lock
|
|
4
|
+
from typing import Protocol, cast
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import numpy.typing as npt
|
|
8
|
+
|
|
9
|
+
from refindery.domain.rollup import Vector, l2_normalize
|
|
10
|
+
|
|
11
|
+
_DEFAULT_MODEL = "minishlab/potion-base-8M"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _StaticModelLike(Protocol):
|
|
15
|
+
def encode(self, sentences: list[str]) -> npt.NDArray[np.float32]:
|
|
16
|
+
"""Encode surface forms."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Model2VecSurfaceEmbedder:
|
|
21
|
+
"""Static-embedding surface-form encoder (no torch, microseconds/call)."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, model_name: str = _DEFAULT_MODEL) -> None:
|
|
24
|
+
self._model_name = model_name
|
|
25
|
+
self._model: _StaticModelLike | None = None
|
|
26
|
+
self._lock = Lock()
|
|
27
|
+
self._id = f"model2vec:{model_name}"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def embedder_id(self) -> str:
|
|
31
|
+
"""Cache key."""
|
|
32
|
+
return self._id
|
|
33
|
+
|
|
34
|
+
def embed(self, forms: list[str]) -> list[Vector]:
|
|
35
|
+
"""Encode and L2-normalize."""
|
|
36
|
+
matrix = self._load_model().encode(forms)
|
|
37
|
+
return [l2_normalize(np.asarray(row, dtype=np.float32)) for row in matrix]
|
|
38
|
+
|
|
39
|
+
def _load_model(self) -> _StaticModelLike:
|
|
40
|
+
if self._model is None:
|
|
41
|
+
with self._lock:
|
|
42
|
+
if self._model is None:
|
|
43
|
+
from model2vec import StaticModel # noqa: PLC0415
|
|
44
|
+
|
|
45
|
+
self._model = cast(
|
|
46
|
+
"_StaticModelLike",
|
|
47
|
+
StaticModel.from_pretrained(self._model_name),
|
|
48
|
+
)
|
|
49
|
+
model = self._model
|
|
50
|
+
if model is None:
|
|
51
|
+
msg = "surface-form model failed to load"
|
|
52
|
+
raise RuntimeError(msg)
|
|
53
|
+
return model
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Content fetching and extraction adapters."""
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""httpx-based fetcher for the fetch_and_index path.
|
|
2
|
+
|
|
3
|
+
The raw response is an external input: it is validated into the pydantic
|
|
4
|
+
``FetchResult`` model (size cap, content-type normalization) before anything
|
|
5
|
+
downstream touches it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from refindery.application.ports.content_extractor import FetchResult
|
|
12
|
+
from refindery.domain.errors import FetchFailedError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HttpFetcher:
|
|
16
|
+
"""Fetcher port implementation."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, *, timeout_s: float = 10.0, max_bytes: int = 10_000_000) -> None:
|
|
19
|
+
self._timeout_s = timeout_s
|
|
20
|
+
self._max_bytes = max_bytes
|
|
21
|
+
|
|
22
|
+
async def fetch(self, url: str) -> FetchResult:
|
|
23
|
+
"""Fetch ``url``; raise FetchFailedError on any failure."""
|
|
24
|
+
try:
|
|
25
|
+
async with (
|
|
26
|
+
httpx.AsyncClient(
|
|
27
|
+
follow_redirects=True,
|
|
28
|
+
timeout=self._timeout_s,
|
|
29
|
+
headers={"User-Agent": "refindery/0.1"},
|
|
30
|
+
) as client,
|
|
31
|
+
client.stream("GET", url) as response,
|
|
32
|
+
):
|
|
33
|
+
response.raise_for_status()
|
|
34
|
+
body = bytearray()
|
|
35
|
+
async for chunk in response.aiter_bytes():
|
|
36
|
+
body.extend(chunk)
|
|
37
|
+
if len(body) > self._max_bytes:
|
|
38
|
+
raise FetchFailedError(
|
|
39
|
+
url=url,
|
|
40
|
+
detail=f"body exceeds {self._max_bytes} bytes",
|
|
41
|
+
)
|
|
42
|
+
result = FetchResult(
|
|
43
|
+
url=url,
|
|
44
|
+
final_url=str(response.url),
|
|
45
|
+
status_code=response.status_code,
|
|
46
|
+
content_type=response.headers.get(
|
|
47
|
+
"content-type", "application/octet-stream"
|
|
48
|
+
),
|
|
49
|
+
charset=response.charset_encoding,
|
|
50
|
+
body=bytes(body),
|
|
51
|
+
)
|
|
52
|
+
except httpx.HTTPError as exc:
|
|
53
|
+
raise FetchFailedError(url=url, detail=repr(exc)) from exc
|
|
54
|
+
except ValidationError as exc:
|
|
55
|
+
raise FetchFailedError(url=url, detail=str(exc)) from exc
|
|
56
|
+
return result
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""PDF text extraction via pypdf (pure Python, BSD)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import io
|
|
5
|
+
|
|
6
|
+
from pypdf import PdfReader
|
|
7
|
+
|
|
8
|
+
from refindery.domain.models import ExtractedContent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PypdfExtractor:
|
|
12
|
+
"""ContentExtractor for application/pdf."""
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def content_types(self) -> frozenset[str]:
|
|
16
|
+
"""Handled content types."""
|
|
17
|
+
return frozenset({"application/pdf"})
|
|
18
|
+
|
|
19
|
+
async def extract(self, *, raw: bytes, charset: str | None) -> ExtractedContent: # noqa: ARG002 — port signature; PDFs carry their own encoding
|
|
20
|
+
"""Extract text from all pages; title from PDF metadata when present."""
|
|
21
|
+
|
|
22
|
+
def _extract() -> ExtractedContent:
|
|
23
|
+
reader = PdfReader(io.BytesIO(raw))
|
|
24
|
+
pages = [page.extract_text() or "" for page in reader.pages]
|
|
25
|
+
title = None
|
|
26
|
+
if reader.metadata is not None and reader.metadata.title:
|
|
27
|
+
title = str(reader.metadata.title)
|
|
28
|
+
return ExtractedContent(
|
|
29
|
+
body_text="\n\n".join(p for p in pages if p.strip()), title=title
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
return await asyncio.to_thread(_extract)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""HTML -> markdown extraction via the pulpie model (transformers).
|
|
2
|
+
|
|
3
|
+
Requires the ``html`` extra (torch, ~2 GB): without it this extractor
|
|
4
|
+
reports itself unavailable and ingest of ``body_html`` / fetched HTML fails
|
|
5
|
+
with a clear install hint. Inference runs in a ProcessPoolExecutor with the
|
|
6
|
+
model loaded once per worker (spawn context; the model object never crosses
|
|
7
|
+
the process boundary).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
13
|
+
from importlib import util as importlib_util
|
|
14
|
+
from multiprocessing import get_context
|
|
15
|
+
|
|
16
|
+
from refindery.domain.errors import ExtractionUnavailableError
|
|
17
|
+
from refindery.domain.models import ExtractedContent
|
|
18
|
+
|
|
19
|
+
MODEL_NAME = "feyninc/pulpie-orange-small"
|
|
20
|
+
_MAX_HTML_CHARS = 500_000
|
|
21
|
+
|
|
22
|
+
_pipeline: Callable[[str], object] | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load_pipeline() -> Callable[[str], object]:
|
|
26
|
+
"""Load the pulpie pipeline once per worker process."""
|
|
27
|
+
global _pipeline # noqa: PLW0603 — per-process model cache
|
|
28
|
+
if _pipeline is None:
|
|
29
|
+
from transformers import pipeline # noqa: PLC0415 — worker-only import
|
|
30
|
+
|
|
31
|
+
_pipeline = pipeline(model=MODEL_NAME) # type: ignore[call-overload] # ty: ignore[no-matching-overload] # pyrefly: ignore
|
|
32
|
+
return _pipeline
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _extract_markdown(html: str) -> str:
|
|
36
|
+
"""Worker-side: run the model over raw HTML, return markdown."""
|
|
37
|
+
pipe = _load_pipeline()
|
|
38
|
+
result = pipe(html[:_MAX_HTML_CHARS])
|
|
39
|
+
if isinstance(result, str):
|
|
40
|
+
return result
|
|
41
|
+
if isinstance(result, list) and result and isinstance(result[0], dict):
|
|
42
|
+
first = dict(result[0])
|
|
43
|
+
for key in ("generated_text", "summary_text"):
|
|
44
|
+
if isinstance(value := first.get(key), str):
|
|
45
|
+
return value
|
|
46
|
+
msg = f"unexpected pulpie pipeline output shape: {type(result)!r}"
|
|
47
|
+
raise RuntimeError(msg)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def torch_available() -> bool:
|
|
51
|
+
"""Whether the html extra (torch) is installed."""
|
|
52
|
+
return importlib_util.find_spec("torch") is not None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PulpieHtmlExtractor:
|
|
56
|
+
"""ContentExtractor for text/html via pulpie."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, *, max_workers: int = 1) -> None:
|
|
59
|
+
if not torch_available():
|
|
60
|
+
raise ExtractionUnavailableError(content_type="text/html", extra="html")
|
|
61
|
+
self._executor = ProcessPoolExecutor(
|
|
62
|
+
max_workers=max_workers, mp_context=get_context("spawn")
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def content_types(self) -> frozenset[str]:
|
|
67
|
+
"""Handled content types."""
|
|
68
|
+
return frozenset({"text/html", "application/xhtml+xml"})
|
|
69
|
+
|
|
70
|
+
async def extract(self, *, raw: bytes, charset: str | None) -> ExtractedContent:
|
|
71
|
+
"""Decode and extract markdown body text from HTML."""
|
|
72
|
+
try:
|
|
73
|
+
html = raw.decode(charset or "utf-8", errors="replace")
|
|
74
|
+
except LookupError:
|
|
75
|
+
html = raw.decode("utf-8", errors="replace")
|
|
76
|
+
loop = asyncio.get_running_loop()
|
|
77
|
+
markdown = await loop.run_in_executor(self._executor, _extract_markdown, html)
|
|
78
|
+
return ExtractedContent(body_text=markdown)
|
|
79
|
+
|
|
80
|
+
def close(self) -> None:
|
|
81
|
+
"""Shut the worker pool down."""
|
|
82
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|