@thomasfosterau/pulpit 0.0.0 → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +1271 -1
- package/dist/blobs.d.ts +249 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +193 -0
- package/dist/blobs.js.map +1 -0
- package/dist/budgets.d.ts +255 -0
- package/dist/budgets.d.ts.map +1 -0
- package/dist/budgets.js +211 -0
- package/dist/budgets.js.map +1 -0
- package/dist/cache.d.ts +154 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +118 -0
- package/dist/cache.js.map +1 -0
- package/dist/collections.d.ts +242 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +215 -0
- package/dist/collections.js.map +1 -0
- package/dist/comments.d.ts +212 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +192 -0
- package/dist/comments.js.map +1 -0
- package/dist/content.d.ts +231 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +204 -0
- package/dist/content.js.map +1 -0
- package/dist/documents.d.ts +184 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +190 -0
- package/dist/documents.js.map +1 -0
- package/dist/edges.d.ts +645 -0
- package/dist/edges.d.ts.map +1 -0
- package/dist/edges.js +558 -0
- package/dist/edges.js.map +1 -0
- package/dist/filter.d.ts +461 -0
- package/dist/filter.d.ts.map +1 -0
- package/dist/filter.js +387 -0
- package/dist/filter.js.map +1 -0
- package/dist/index.d.ts +133 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +133 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/filter-columns.d.ts +8 -0
- package/dist/internal/filter-columns.d.ts.map +1 -0
- package/dist/internal/filter-columns.js +43 -0
- package/dist/internal/filter-columns.js.map +1 -0
- package/dist/internal/list-order.d.ts +26 -0
- package/dist/internal/list-order.d.ts.map +1 -0
- package/dist/internal/list-order.js +92 -0
- package/dist/internal/list-order.js.map +1 -0
- package/dist/internal/owned-columns.d.ts +93 -0
- package/dist/internal/owned-columns.d.ts.map +1 -0
- package/dist/internal/owned-columns.js +98 -0
- package/dist/internal/owned-columns.js.map +1 -0
- package/dist/internal/plain-rows.d.ts +32 -0
- package/dist/internal/plain-rows.d.ts.map +1 -0
- package/dist/internal/plain-rows.js +32 -0
- package/dist/internal/plain-rows.js.map +1 -0
- package/dist/internal/reference-columns.d.ts +80 -0
- package/dist/internal/reference-columns.d.ts.map +1 -0
- package/dist/internal/reference-columns.js +102 -0
- package/dist/internal/reference-columns.js.map +1 -0
- package/dist/moderation.d.ts +392 -0
- package/dist/moderation.d.ts.map +1 -0
- package/dist/moderation.js +375 -0
- package/dist/moderation.js.map +1 -0
- package/dist/paths.d.ts +204 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +165 -0
- package/dist/paths.js.map +1 -0
- package/dist/proposals.d.ts +523 -0
- package/dist/proposals.d.ts.map +1 -0
- package/dist/proposals.js +505 -0
- package/dist/proposals.js.map +1 -0
- package/dist/publication.d.ts +148 -0
- package/dist/publication.d.ts.map +1 -0
- package/dist/publication.js +183 -0
- package/dist/publication.js.map +1 -0
- package/dist/purge.d.ts +224 -0
- package/dist/purge.d.ts.map +1 -0
- package/dist/purge.js +151 -0
- package/dist/purge.js.map +1 -0
- package/dist/reactions.d.ts +246 -0
- package/dist/reactions.d.ts.map +1 -0
- package/dist/reactions.js +183 -0
- package/dist/reactions.js.map +1 -0
- package/dist/registry.d.ts +316 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +305 -0
- package/dist/registry.js.map +1 -0
- package/dist/search.d.ts +366 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +273 -0
- package/dist/search.js.map +1 -0
- package/dist/social.d.ts +311 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +205 -0
- package/dist/social.js.map +1 -0
- package/dist/subtype.d.ts +1613 -0
- package/dist/subtype.d.ts.map +1 -0
- package/dist/subtype.js +1300 -0
- package/dist/subtype.js.map +1 -0
- package/dist/testing.d.ts +675 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +1022 -0
- package/dist/testing.js.map +1 -0
- package/dist/version-chain.d.ts +702 -0
- package/dist/version-chain.d.ts.map +1 -0
- package/dist/version-chain.js +489 -0
- package/dist/version-chain.js.map +1 -0
- package/dist/working-copy.d.ts +116 -0
- package/dist/working-copy.d.ts.map +1 -0
- package/dist/working-copy.js +95 -0
- package/dist/working-copy.js.map +1 -0
- package/package.json +138 -4
- package/src/blobs.ts +384 -0
- package/src/budgets.ts +338 -0
- package/src/cache.ts +218 -0
- package/src/collections.ts +427 -0
- package/src/comments.ts +392 -0
- package/src/content.ts +238 -0
- package/src/documents.ts +284 -0
- package/src/edges.ts +1185 -0
- package/src/filter.ts +523 -0
- package/src/index.ts +153 -0
- package/src/internal/filter-columns.ts +50 -0
- package/src/internal/list-order.ts +117 -0
- package/src/internal/owned-columns.ts +140 -0
- package/src/internal/plain-rows.ts +54 -0
- package/src/internal/reference-columns.ts +156 -0
- package/src/moderation.ts +728 -0
- package/src/paths.ts +267 -0
- package/src/proposals.ts +1014 -0
- package/src/publication.ts +225 -0
- package/src/purge.ts +313 -0
- package/src/reactions.ts +347 -0
- package/src/registry.ts +524 -0
- package/src/search.ts +540 -0
- package/src/social.ts +469 -0
- package/src/subtype.ts +3095 -0
- package/src/testing.ts +1873 -0
- package/src/version-chain.ts +1093 -0
- package/src/working-copy.ts +192 -0
package/dist/search.d.ts
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **Hybrid search** (pulpit#204) — the parts of the apps' search pipeline
|
|
3
|
+
* that are not SQL: reciprocal rank fusion over ranked branches, a reranker
|
|
4
|
+
* seam, the branch seam a ranked query fills under the leaf's read scope,
|
|
5
|
+
* and the embedding pipeline as seams.
|
|
6
|
+
*
|
|
7
|
+
* The behavioural spec is website's `apps/api/src/operations/search.ts`
|
|
8
|
+
* (full text, trigram, vector and literal-id branches fused with RRF) and
|
|
9
|
+
* gardeneria's `search.ts` and `packages/ai/src/{embeddings,reranker}.ts`,
|
|
10
|
+
* with both apps' `packages/jobs/src/embedding/queue.ts` and
|
|
11
|
+
* `cron/embedding-sweep.ts`.
|
|
12
|
+
*
|
|
13
|
+
* ## What the kernel owns
|
|
14
|
+
*
|
|
15
|
+
* - **Fusion.** {@link fuseRankings} is pure: each branch's rank list
|
|
16
|
+
* becomes a score `weight / (k + rank)` per hit, summed across branches,
|
|
17
|
+
* ordered by score and then by id (code point), and capped. A
|
|
18
|
+
* {@link RerankerSeam} may reorder the fused candidates, never add one.
|
|
19
|
+
* - **Scope on every branch.** A {@link SearchBranch} is a store seam (the
|
|
20
|
+
* ranked query is the application's SQL) handed the leaf's
|
|
21
|
+
* `searchScopeFilter` (`./subtype`), which its SQL must apply, the
|
|
22
|
+
* live-row rule included, so no branch can rank a row `list` hides
|
|
23
|
+
* (`SEC-AUTH-ENUM-001`). {@link hybridSearch} hands every branch the same
|
|
24
|
+
* scope. `./testing`'s `scopeCheckedBranch` wraps a branch and fails
|
|
25
|
+
* {@link SearchScopeLeak} on a hit outside the scope, so an application's
|
|
26
|
+
* suite can prove its own branches. The fused hits are still re-read
|
|
27
|
+
* through `hydrateSearchHits`, so a stale index never leaks a row either.
|
|
28
|
+
* - **Embeddings.** {@link enqueueEmbeddings} queues a document on its first
|
|
29
|
+
* insert (`onDocumentInsert`, inside the `put`'s transaction), and
|
|
30
|
+
* {@link embedPending} / {@link sweepEmbeddings} embed the pending
|
|
31
|
+
* documents in batches (`PERF-DB-001`): one {@link EmbeddingStore} read,
|
|
32
|
+
* one `getMany`, one {@link Embedder} call and one write per batch. The
|
|
33
|
+
* model, the document's text and the vector column are the application's.
|
|
34
|
+
*
|
|
35
|
+
* `/ask`-style retrieval stays application code over these reads.
|
|
36
|
+
*
|
|
37
|
+
* @copyright Thomas Foster
|
|
38
|
+
* @module pulpit/search
|
|
39
|
+
* @since 0.1.0
|
|
40
|
+
*/
|
|
41
|
+
import * as Effect from "effect/Effect";
|
|
42
|
+
import * as Option from "effect/Option";
|
|
43
|
+
import * as Schema from "effect/Schema";
|
|
44
|
+
import { type DocumentHash, type DocumentStore } from "./documents.ts";
|
|
45
|
+
import type { ListFilter } from "./filter.ts";
|
|
46
|
+
/**
|
|
47
|
+
* The reciprocal rank fusion constant both apps use: a hit at rank `r`
|
|
48
|
+
* scores `weight / (RRF_K + r)`.
|
|
49
|
+
*
|
|
50
|
+
* @since 0.1.0
|
|
51
|
+
*/
|
|
52
|
+
export declare const RRF_K = 60;
|
|
53
|
+
/**
|
|
54
|
+
* One hit a branch ranked: the node id. A branch's hits are in rank order,
|
|
55
|
+
* best first.
|
|
56
|
+
*
|
|
57
|
+
* @since 0.1.0
|
|
58
|
+
*/
|
|
59
|
+
export interface RankedHit {
|
|
60
|
+
readonly id: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* One branch's ranked hits, as {@link fuseRankings} reads them.
|
|
64
|
+
*
|
|
65
|
+
* @since 0.1.0
|
|
66
|
+
*/
|
|
67
|
+
export interface BranchRanking {
|
|
68
|
+
/** The branch's name, as {@link FusedHit.ranks} keys it. */
|
|
69
|
+
readonly name: string;
|
|
70
|
+
/**
|
|
71
|
+
* The branch's weight, `1` when omitted. A weight that is not a finite,
|
|
72
|
+
* non-negative number scores nothing (fails closed).
|
|
73
|
+
*/
|
|
74
|
+
readonly weight?: number | undefined;
|
|
75
|
+
/** Best first. A repeated id keeps its first, best rank. */
|
|
76
|
+
readonly hits: ReadonlyArray<RankedHit>;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* One fused hit: its id, its summed score and its 1-based rank in each
|
|
80
|
+
* branch that ranked it.
|
|
81
|
+
*
|
|
82
|
+
* @since 0.1.0
|
|
83
|
+
*/
|
|
84
|
+
export interface FusedHit {
|
|
85
|
+
readonly id: string;
|
|
86
|
+
readonly score: number;
|
|
87
|
+
readonly ranks: {
|
|
88
|
+
readonly [branch: string]: number;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* {@link fuseRankings}'s options.
|
|
93
|
+
*
|
|
94
|
+
* @since 0.1.0
|
|
95
|
+
*/
|
|
96
|
+
export interface FusionOptions {
|
|
97
|
+
/** The RRF constant; {@link RRF_K} when omitted or not a finite, positive number. */
|
|
98
|
+
readonly k?: number | undefined;
|
|
99
|
+
/** How many fused hits to return, clamped to `[1, MAX_PAGE_LIMIT]`; `MAX_PAGE_LIMIT` when omitted. */
|
|
100
|
+
readonly limit?: number | undefined;
|
|
101
|
+
/** How many of each branch's hits count, clamped as `limit` is; `MAX_PAGE_LIMIT` when omitted. */
|
|
102
|
+
readonly perBranch?: number | undefined;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Reciprocal rank fusion. Each branch's first `perBranch` distinct hits
|
|
106
|
+
* score `weight / (k + rank)` (rank 1-based); a hit's score is the sum over
|
|
107
|
+
* the branches that ranked it, and its `ranks` record each such rank. The
|
|
108
|
+
* result is ordered by score, then by id in code-point order, and capped at
|
|
109
|
+
* `limit`. Pure and total: a branch named twice keeps both contributions
|
|
110
|
+
* under the later name's rank.
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* ```ts
|
|
114
|
+
* import { deepStrictEqual } from "node:assert";
|
|
115
|
+
* import { fuseRankings } from "@thomasfosterau/pulpit/search";
|
|
116
|
+
*
|
|
117
|
+
* const fused = fuseRankings([
|
|
118
|
+
* { name: "text", hits: [{ id: "a" }, { id: "b" }] },
|
|
119
|
+
* { name: "vector", hits: [{ id: "b" }, { id: "c" }] },
|
|
120
|
+
* ]);
|
|
121
|
+
* deepStrictEqual(
|
|
122
|
+
* fused.map((hit) => hit.id),
|
|
123
|
+
* ["b", "a", "c"],
|
|
124
|
+
* );
|
|
125
|
+
* deepStrictEqual(fused[0]?.ranks, { text: 2, vector: 1 });
|
|
126
|
+
* ```
|
|
127
|
+
*
|
|
128
|
+
* @since 0.1.0
|
|
129
|
+
*/
|
|
130
|
+
export declare const fuseRankings: (branches: ReadonlyArray<BranchRanking>, options?: FusionOptions) => ReadonlyArray<FusedHit>;
|
|
131
|
+
/**
|
|
132
|
+
* The optional reranker an application fills (gardeneria's
|
|
133
|
+
* `packages/ai/src/reranker.ts`): given the query and the fused candidates,
|
|
134
|
+
* their ids in its preferred order. The kernel only lets it reorder:
|
|
135
|
+
* {@link applyReranking} drops an id it returns that was not a candidate, and
|
|
136
|
+
* keeps a candidate it omits after the ones it ranked, in fused order.
|
|
137
|
+
*
|
|
138
|
+
* @since 0.1.0
|
|
139
|
+
*/
|
|
140
|
+
export interface RerankerSeam<E, R> {
|
|
141
|
+
readonly rerank: (query: string, candidates: ReadonlyArray<FusedHit>) => Effect.Effect<ReadonlyArray<string>, E, R>;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Reorder `candidates` by a reranker's answer: its ids first, in its order
|
|
145
|
+
* (an unknown or repeated id dropped), then every candidate it omitted, in
|
|
146
|
+
* their fused order. Pure: a reranker can reorder, never add or remove a
|
|
147
|
+
* hit.
|
|
148
|
+
*
|
|
149
|
+
* @example
|
|
150
|
+
* ```ts
|
|
151
|
+
* import { deepStrictEqual } from "node:assert";
|
|
152
|
+
* import { applyReranking } from "@thomasfosterau/pulpit/search";
|
|
153
|
+
*
|
|
154
|
+
* const candidates = [
|
|
155
|
+
* { id: "a", score: 3, ranks: {} },
|
|
156
|
+
* { id: "b", score: 2, ranks: {} },
|
|
157
|
+
* { id: "c", score: 1, ranks: {} },
|
|
158
|
+
* ];
|
|
159
|
+
* deepStrictEqual(
|
|
160
|
+
* applyReranking(candidates, ["c", "zzz", "a"]).map((hit) => hit.id),
|
|
161
|
+
* ["c", "a", "b"],
|
|
162
|
+
* );
|
|
163
|
+
* ```
|
|
164
|
+
*
|
|
165
|
+
* @since 0.1.0
|
|
166
|
+
*/
|
|
167
|
+
export declare const applyReranking: (candidates: ReadonlyArray<FusedHit>, order: ReadonlyArray<string>) => ReadonlyArray<FusedHit>;
|
|
168
|
+
/**
|
|
169
|
+
* What a {@link SearchBranch} is asked: the query, the leaf's read scope and
|
|
170
|
+
* how many hits it may return.
|
|
171
|
+
*
|
|
172
|
+
* @since 0.1.0
|
|
173
|
+
*/
|
|
174
|
+
export interface BranchRequest<Following> {
|
|
175
|
+
readonly query: string;
|
|
176
|
+
/**
|
|
177
|
+
* The leaf's `searchScopeFilter` for the viewer (`./subtype`). The branch
|
|
178
|
+
* must apply it in the same query that ranks (rendered as the leaf's
|
|
179
|
+
* `list` renders its filter), so a row outside it is never ranked and its
|
|
180
|
+
* rank never shifts another hit's (`SEC-AUTH-ENUM-001`).
|
|
181
|
+
*/
|
|
182
|
+
readonly scope: ListFilter<Following>;
|
|
183
|
+
/** At most this many hits, best first; never more than `MAX_PAGE_LIMIT`. */
|
|
184
|
+
readonly limit: number;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* One ranked query an application supplies: full text, trigram, a vector
|
|
188
|
+
* nearest-neighbour search or a literal-identifier match, over its own
|
|
189
|
+
* search view. A store seam: it applies the scope it is given and no other
|
|
190
|
+
* policy, and fails only with its `E`. See the package README → Seams.
|
|
191
|
+
*
|
|
192
|
+
* @since 0.1.0
|
|
193
|
+
*/
|
|
194
|
+
export interface SearchBranch<Following, E, R> {
|
|
195
|
+
/** The branch's name, as {@link FusedHit.ranks} keys it. */
|
|
196
|
+
readonly name: string;
|
|
197
|
+
/** Its fusion weight; `1` when omitted (see {@link BranchRanking.weight}). */
|
|
198
|
+
readonly weight?: number | undefined;
|
|
199
|
+
/** The hits in scope, best first, at most `request.limit`. */
|
|
200
|
+
readonly search: (request: BranchRequest<Following>) => Effect.Effect<ReadonlyArray<RankedHit>, E, R>;
|
|
201
|
+
}
|
|
202
|
+
declare const SearchScopeLeak_base: Schema.Class<SearchScopeLeak, Schema.TaggedStruct<"SearchScopeLeak", {
|
|
203
|
+
readonly branch: Schema.String;
|
|
204
|
+
readonly id: Schema.String;
|
|
205
|
+
}>, import("effect/Cause").YieldableError & {
|
|
206
|
+
readonly brand: unique symbol;
|
|
207
|
+
}>;
|
|
208
|
+
/**
|
|
209
|
+
* A branch returned a hit outside the scope it was handed. Raised by
|
|
210
|
+
* `./testing`'s `scopeCheckedBranch`, which an application's suite wraps
|
|
211
|
+
* around its own branches; the kernel's {@link hybridSearch} never checks
|
|
212
|
+
* a production branch's rows itself (it has none to check against).
|
|
213
|
+
*
|
|
214
|
+
* @since 0.1.0
|
|
215
|
+
*/
|
|
216
|
+
export declare class SearchScopeLeak extends SearchScopeLeak_base {
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* {@link hybridSearch}'s configuration: the branches, the optional reranker
|
|
220
|
+
* and the fusion options.
|
|
221
|
+
*
|
|
222
|
+
* @since 0.1.0
|
|
223
|
+
*/
|
|
224
|
+
export interface HybridSearchDefinition<Following, E, R> {
|
|
225
|
+
readonly branches: ReadonlyArray<SearchBranch<Following, E, R>>;
|
|
226
|
+
/** Reorders the fused candidates; omitted, the fused order stands. */
|
|
227
|
+
readonly reranker?: RerankerSeam<E, R> | undefined;
|
|
228
|
+
/** The RRF constant ({@link FusionOptions.k}). */
|
|
229
|
+
readonly k?: number | undefined;
|
|
230
|
+
/** How many hits each branch is asked for ({@link FusionOptions.perBranch}). */
|
|
231
|
+
readonly perBranch?: number | undefined;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Run every branch under the same scope, one after another in the caller's
|
|
235
|
+
* transaction, fuse their rankings ({@link fuseRankings}) and, with a
|
|
236
|
+
* reranker, reorder the fused candidates ({@link applyReranking}). Returns
|
|
237
|
+
* at most `request.limit` hits (clamped to `[1, MAX_PAGE_LIMIT]`), for the
|
|
238
|
+
* caller to re-read through `hydrateSearchHits`. An empty query asks no
|
|
239
|
+
* branch and finds nothing. Fails with a branch's or the reranker's `E`.
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* ```ts
|
|
243
|
+
* import { deepStrictEqual } from "node:assert";
|
|
244
|
+
* import * as Effect from "effect/Effect";
|
|
245
|
+
* import { always } from "@thomasfosterau/pulpit/filter";
|
|
246
|
+
* import { hybridSearch } from "@thomasfosterau/pulpit/search";
|
|
247
|
+
*
|
|
248
|
+
* const search = hybridSearch({
|
|
249
|
+
* branches: [
|
|
250
|
+
* { name: "text", search: () => Effect.succeed([{ id: "a" }, { id: "b" }]) },
|
|
251
|
+
* { name: "vector", weight: 2, search: () => Effect.succeed([{ id: "b" }]) },
|
|
252
|
+
* ],
|
|
253
|
+
* });
|
|
254
|
+
* const hits = await Effect.runPromise(search({ query: "tomato", scope: always, limit: 10 }));
|
|
255
|
+
* deepStrictEqual(
|
|
256
|
+
* hits.map((hit) => hit.id),
|
|
257
|
+
* ["b", "a"],
|
|
258
|
+
* );
|
|
259
|
+
* ```
|
|
260
|
+
*
|
|
261
|
+
* @since 0.1.0
|
|
262
|
+
*/
|
|
263
|
+
export declare const hybridSearch: <Following, E, R>(definition: HybridSearchDefinition<Following, E, R>) => (request: BranchRequest<Following>) => Effect.Effect<ReadonlyArray<FusedHit>, E, R>;
|
|
264
|
+
/**
|
|
265
|
+
* The embedding queue and vector column an application supplies (the apps'
|
|
266
|
+
* `embedding` job table and the document table's nullable `embedding`
|
|
267
|
+
* column). A store seam: no policy, never opens a transaction, fails only
|
|
268
|
+
* with its `E`. See the package README → Seams.
|
|
269
|
+
*
|
|
270
|
+
* @since 0.1.0
|
|
271
|
+
*/
|
|
272
|
+
export interface EmbeddingStore<E, R> {
|
|
273
|
+
/** Queue a document for embedding (an outbox row the caller's transaction writes); idempotent. */
|
|
274
|
+
readonly enqueue: (hash: DocumentHash) => Effect.Effect<void, E, R>;
|
|
275
|
+
/**
|
|
276
|
+
* Up to `limit` documents with no vector yet, oldest first: the queued
|
|
277
|
+
* ones and, for the sweep, any stored document whose vector is still
|
|
278
|
+
* `NULL`. One bounded read (`LIMIT $limit`).
|
|
279
|
+
*/
|
|
280
|
+
readonly pending: (limit: number) => Effect.Effect<ReadonlyArray<DocumentHash>, E, R>;
|
|
281
|
+
/** Write each document's vector and dequeue it, in one statement. */
|
|
282
|
+
readonly write: (vectors: ReadonlyArray<{
|
|
283
|
+
readonly hash: DocumentHash;
|
|
284
|
+
readonly vector: ReadonlyArray<number>;
|
|
285
|
+
}>) => Effect.Effect<void, E, R>;
|
|
286
|
+
/** Dequeue documents that can no longer be embedded (missing, or with no text). */
|
|
287
|
+
readonly discard: (hashes: ReadonlyArray<DocumentHash>) => Effect.Effect<void, E, R>;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The embedding model an application supplies: one call for a batch of
|
|
291
|
+
* texts, one vector per text, in order.
|
|
292
|
+
*
|
|
293
|
+
* @since 0.1.0
|
|
294
|
+
*/
|
|
295
|
+
export interface Embedder<E, R> {
|
|
296
|
+
readonly embed: (texts: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<ReadonlyArray<number>>, E, R>;
|
|
297
|
+
}
|
|
298
|
+
declare const EmbeddingMismatch_base: Schema.Class<EmbeddingMismatch, Schema.TaggedStruct<"EmbeddingMismatch", {
|
|
299
|
+
readonly expected: Schema.Finite;
|
|
300
|
+
readonly received: Schema.Finite;
|
|
301
|
+
}>, import("effect/Cause").YieldableError & {
|
|
302
|
+
readonly brand: unique symbol;
|
|
303
|
+
}>;
|
|
304
|
+
/**
|
|
305
|
+
* The embedder answered with a number of vectors other than the number of
|
|
306
|
+
* texts it was given, so no vector can be matched to its document. Nothing
|
|
307
|
+
* of the batch is written.
|
|
308
|
+
*
|
|
309
|
+
* @since 0.1.0
|
|
310
|
+
*/
|
|
311
|
+
export declare class EmbeddingMismatch extends EmbeddingMismatch_base {
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* The document store with embedding enqueued on a document's first insert:
|
|
315
|
+
* `onDocumentInsert` with `embeddings.enqueue`, so the queue row lands in
|
|
316
|
+
* the `put`'s own transaction and a rolled-back write queues nothing.
|
|
317
|
+
*
|
|
318
|
+
* @since 0.1.0
|
|
319
|
+
*/
|
|
320
|
+
export declare const enqueueEmbeddings: <Document, E, R>(documents: DocumentStore<Document, E, R>, embeddings: Pick<EmbeddingStore<E, R>, "enqueue">) => DocumentStore<Document, E, R>;
|
|
321
|
+
/**
|
|
322
|
+
* What {@link embedPending} and {@link sweepEmbeddings} need: the queue, the
|
|
323
|
+
* documents, the model and the document's text.
|
|
324
|
+
*
|
|
325
|
+
* @since 0.1.0
|
|
326
|
+
*/
|
|
327
|
+
export interface EmbeddingPipeline<Document, E, R> {
|
|
328
|
+
readonly embeddings: EmbeddingStore<E, R>;
|
|
329
|
+
readonly documents: Pick<DocumentStore<Document, E, R>, "getMany">;
|
|
330
|
+
readonly embedder: Embedder<E, R>;
|
|
331
|
+
/** The text to embed for a document (its plain-text serialisation), or none to skip it. */
|
|
332
|
+
readonly text: (document: Document) => Option.Option<string>;
|
|
333
|
+
/** Documents per batch, clamped to `[1, MAX_PAGE_LIMIT]`. */
|
|
334
|
+
readonly batchSize: number;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* What one batch did: how many documents it embedded and how many it
|
|
338
|
+
* discarded (missing, or with no text).
|
|
339
|
+
*
|
|
340
|
+
* @since 0.1.0
|
|
341
|
+
*/
|
|
342
|
+
export interface EmbeddingBatch {
|
|
343
|
+
readonly embedded: number;
|
|
344
|
+
readonly discarded: number;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Embed one batch of pending documents: one `pending` read, one `getMany`,
|
|
348
|
+
* one embedder call and one `write` (`PERF-DB-001`). A pending hash whose
|
|
349
|
+
* document is missing, or whose `text` is none, is discarded rather than
|
|
350
|
+
* retried forever. Fails {@link EmbeddingMismatch} (writing nothing) when
|
|
351
|
+
* the embedder answers with the wrong number of vectors, else with the
|
|
352
|
+
* stores' and the embedder's `E`.
|
|
353
|
+
*
|
|
354
|
+
* @since 0.1.0
|
|
355
|
+
*/
|
|
356
|
+
export declare const embedPending: <Document, E, R>(pipeline: EmbeddingPipeline<Document, E, R>) => Effect.Effect<EmbeddingBatch, E | EmbeddingMismatch, R>;
|
|
357
|
+
/**
|
|
358
|
+
* Embed batches until the queue is drained or `maxBatches` (clamped to at
|
|
359
|
+
* least 1) have run: the daily sweep of the unembedded. A batch that
|
|
360
|
+
* embeds and discards nothing ends the sweep. Returns the totals.
|
|
361
|
+
*
|
|
362
|
+
* @since 0.1.0
|
|
363
|
+
*/
|
|
364
|
+
export declare const sweepEmbeddings: <Document, E, R>(pipeline: EmbeddingPipeline<Document, E, R>, maxBatches: number) => Effect.Effect<EmbeddingBatch, E | EmbeddingMismatch, R>;
|
|
365
|
+
export {};
|
|
366
|
+
//# sourceMappingURL=search.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
|
package/dist/search.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **Hybrid search** (pulpit#204) — the parts of the apps' search pipeline
|
|
3
|
+
* that are not SQL: reciprocal rank fusion over ranked branches, a reranker
|
|
4
|
+
* seam, the branch seam a ranked query fills under the leaf's read scope,
|
|
5
|
+
* and the embedding pipeline as seams.
|
|
6
|
+
*
|
|
7
|
+
* The behavioural spec is website's `apps/api/src/operations/search.ts`
|
|
8
|
+
* (full text, trigram, vector and literal-id branches fused with RRF) and
|
|
9
|
+
* gardeneria's `search.ts` and `packages/ai/src/{embeddings,reranker}.ts`,
|
|
10
|
+
* with both apps' `packages/jobs/src/embedding/queue.ts` and
|
|
11
|
+
* `cron/embedding-sweep.ts`.
|
|
12
|
+
*
|
|
13
|
+
* ## What the kernel owns
|
|
14
|
+
*
|
|
15
|
+
* - **Fusion.** {@link fuseRankings} is pure: each branch's rank list
|
|
16
|
+
* becomes a score `weight / (k + rank)` per hit, summed across branches,
|
|
17
|
+
* ordered by score and then by id (code point), and capped. A
|
|
18
|
+
* {@link RerankerSeam} may reorder the fused candidates, never add one.
|
|
19
|
+
* - **Scope on every branch.** A {@link SearchBranch} is a store seam (the
|
|
20
|
+
* ranked query is the application's SQL) handed the leaf's
|
|
21
|
+
* `searchScopeFilter` (`./subtype`), which its SQL must apply, the
|
|
22
|
+
* live-row rule included, so no branch can rank a row `list` hides
|
|
23
|
+
* (`SEC-AUTH-ENUM-001`). {@link hybridSearch} hands every branch the same
|
|
24
|
+
* scope. `./testing`'s `scopeCheckedBranch` wraps a branch and fails
|
|
25
|
+
* {@link SearchScopeLeak} on a hit outside the scope, so an application's
|
|
26
|
+
* suite can prove its own branches. The fused hits are still re-read
|
|
27
|
+
* through `hydrateSearchHits`, so a stale index never leaks a row either.
|
|
28
|
+
* - **Embeddings.** {@link enqueueEmbeddings} queues a document on its first
|
|
29
|
+
* insert (`onDocumentInsert`, inside the `put`'s transaction), and
|
|
30
|
+
* {@link embedPending} / {@link sweepEmbeddings} embed the pending
|
|
31
|
+
* documents in batches (`PERF-DB-001`): one {@link EmbeddingStore} read,
|
|
32
|
+
* one `getMany`, one {@link Embedder} call and one write per batch. The
|
|
33
|
+
* model, the document's text and the vector column are the application's.
|
|
34
|
+
*
|
|
35
|
+
* `/ask`-style retrieval stays application code over these reads.
|
|
36
|
+
*
|
|
37
|
+
* @copyright Thomas Foster
|
|
38
|
+
* @module pulpit/search
|
|
39
|
+
* @since 0.1.0
|
|
40
|
+
*/
|
|
41
|
+
import * as Arr from "effect/Array";
|
|
42
|
+
import * as Effect from "effect/Effect";
|
|
43
|
+
import * as HashMap from "effect/HashMap";
|
|
44
|
+
import * as HashSet from "effect/HashSet";
|
|
45
|
+
import * as Option from "effect/Option";
|
|
46
|
+
import * as Order from "effect/Order";
|
|
47
|
+
import * as Schema from "effect/Schema";
|
|
48
|
+
import { onDocumentInsert } from "./documents.js";
|
|
49
|
+
import { byCodePoint } from "./internal/list-order.js";
|
|
50
|
+
import { MAX_PAGE_LIMIT } from "./subtype.js";
|
|
51
|
+
// ── Fusion ────────────────────────────────────────────────────────────
|
|
52
|
+
/**
|
|
53
|
+
* The reciprocal rank fusion constant both apps use: a hit at rank `r`
|
|
54
|
+
* scores `weight / (RRF_K + r)`.
|
|
55
|
+
*
|
|
56
|
+
* @since 0.1.0
|
|
57
|
+
*/
|
|
58
|
+
export const RRF_K = 60;
|
|
59
|
+
/** A count clamped to `[1, MAX_PAGE_LIMIT]` (`SEC-INPUT-003`), `MAX_PAGE_LIMIT` when absent or not a number. */
|
|
60
|
+
const bounded = (value) => Option.match(Option.filter(value, (count) => !Number.isNaN(count)), {
|
|
61
|
+
onNone: () => MAX_PAGE_LIMIT,
|
|
62
|
+
onSome: (count) => Math.max(1, Math.min(Math.trunc(count), MAX_PAGE_LIMIT)),
|
|
63
|
+
});
|
|
64
|
+
/** A branch's usable weight: a finite, non-negative number, else 0. */
|
|
65
|
+
const weightOf = (branch) => {
|
|
66
|
+
const weight = branch.weight ?? 1;
|
|
67
|
+
return Number.isFinite(weight) && weight >= 0 ? weight : 0;
|
|
68
|
+
};
|
|
69
|
+
/** Higher score first, then id in code-point order: a total order, so fusion is deterministic. */
|
|
70
|
+
const fusedOrder = Order.combine(Order.mapInput(Order.flip(Order.Number), (hit) => hit.score), Order.mapInput(byCodePoint, (hit) => hit.id));
|
|
71
|
+
/**
|
|
72
|
+
* Reciprocal rank fusion. Each branch's first `perBranch` distinct hits
|
|
73
|
+
* score `weight / (k + rank)` (rank 1-based); a hit's score is the sum over
|
|
74
|
+
* the branches that ranked it, and its `ranks` record each such rank. The
|
|
75
|
+
* result is ordered by score, then by id in code-point order, and capped at
|
|
76
|
+
* `limit`. Pure and total: a branch named twice keeps both contributions
|
|
77
|
+
* under the later name's rank.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* import { deepStrictEqual } from "node:assert";
|
|
82
|
+
* import { fuseRankings } from "@thomasfosterau/pulpit/search";
|
|
83
|
+
*
|
|
84
|
+
* const fused = fuseRankings([
|
|
85
|
+
* { name: "text", hits: [{ id: "a" }, { id: "b" }] },
|
|
86
|
+
* { name: "vector", hits: [{ id: "b" }, { id: "c" }] },
|
|
87
|
+
* ]);
|
|
88
|
+
* deepStrictEqual(
|
|
89
|
+
* fused.map((hit) => hit.id),
|
|
90
|
+
* ["b", "a", "c"],
|
|
91
|
+
* );
|
|
92
|
+
* deepStrictEqual(fused[0]?.ranks, { text: 2, vector: 1 });
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* @since 0.1.0
|
|
96
|
+
*/
|
|
97
|
+
export const fuseRankings = (branches, options = {}) => {
|
|
98
|
+
const k = options.k !== undefined && Number.isFinite(options.k) && options.k > 0 ? options.k : RRF_K;
|
|
99
|
+
const perBranch = bounded(Option.fromUndefinedOr(options.perBranch));
|
|
100
|
+
const contributions = Arr.flatMap(branches, (branch) => {
|
|
101
|
+
const weight = weightOf(branch);
|
|
102
|
+
return Arr.map(Arr.take(Arr.dedupeWith(branch.hits, (a, b) => a.id === b.id), perBranch), (hit, index) => ({ id: hit.id, branch: branch.name, rank: index + 1, weight }));
|
|
103
|
+
});
|
|
104
|
+
const fused = Arr.reduce(contributions, HashMap.empty(), (acc, entry) => {
|
|
105
|
+
const before = Option.getOrElse(HashMap.get(acc, entry.id), () => ({
|
|
106
|
+
id: entry.id,
|
|
107
|
+
score: 0,
|
|
108
|
+
ranks: {},
|
|
109
|
+
}));
|
|
110
|
+
return HashMap.set(acc, entry.id, {
|
|
111
|
+
id: entry.id,
|
|
112
|
+
score: before.score + entry.weight / (k + entry.rank),
|
|
113
|
+
ranks: { ...before.ranks, [entry.branch]: entry.rank },
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
return Arr.take(Arr.sort(HashMap.toValues(fused), fusedOrder), bounded(Option.fromUndefinedOr(options.limit)));
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Reorder `candidates` by a reranker's answer: its ids first, in its order
|
|
120
|
+
* (an unknown or repeated id dropped), then every candidate it omitted, in
|
|
121
|
+
* their fused order. Pure: a reranker can reorder, never add or remove a
|
|
122
|
+
* hit.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```ts
|
|
126
|
+
* import { deepStrictEqual } from "node:assert";
|
|
127
|
+
* import { applyReranking } from "@thomasfosterau/pulpit/search";
|
|
128
|
+
*
|
|
129
|
+
* const candidates = [
|
|
130
|
+
* { id: "a", score: 3, ranks: {} },
|
|
131
|
+
* { id: "b", score: 2, ranks: {} },
|
|
132
|
+
* { id: "c", score: 1, ranks: {} },
|
|
133
|
+
* ];
|
|
134
|
+
* deepStrictEqual(
|
|
135
|
+
* applyReranking(candidates, ["c", "zzz", "a"]).map((hit) => hit.id),
|
|
136
|
+
* ["c", "a", "b"],
|
|
137
|
+
* );
|
|
138
|
+
* ```
|
|
139
|
+
*
|
|
140
|
+
* @since 0.1.0
|
|
141
|
+
*/
|
|
142
|
+
export const applyReranking = (candidates, order) => {
|
|
143
|
+
const byId = HashMap.fromIterable(Arr.map(candidates, (hit) => [hit.id, hit]));
|
|
144
|
+
const ranked = Arr.getSomes(Arr.map(Arr.dedupe(order), (id) => HashMap.get(byId, id)));
|
|
145
|
+
const named = HashSet.fromIterable(Arr.map(ranked, (hit) => hit.id));
|
|
146
|
+
return [...ranked, ...Arr.filter(candidates, (hit) => !HashSet.has(named, hit.id))];
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* A branch returned a hit outside the scope it was handed. Raised by
|
|
150
|
+
* `./testing`'s `scopeCheckedBranch`, which an application's suite wraps
|
|
151
|
+
* around its own branches; the kernel's {@link hybridSearch} never checks
|
|
152
|
+
* a production branch's rows itself (it has none to check against).
|
|
153
|
+
*
|
|
154
|
+
* @since 0.1.0
|
|
155
|
+
*/
|
|
156
|
+
export class SearchScopeLeak extends Schema.TaggedError()("SearchScopeLeak", {
|
|
157
|
+
branch: Schema.String,
|
|
158
|
+
id: Schema.String,
|
|
159
|
+
}) {
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Run every branch under the same scope, one after another in the caller's
|
|
163
|
+
* transaction, fuse their rankings ({@link fuseRankings}) and, with a
|
|
164
|
+
* reranker, reorder the fused candidates ({@link applyReranking}). Returns
|
|
165
|
+
* at most `request.limit` hits (clamped to `[1, MAX_PAGE_LIMIT]`), for the
|
|
166
|
+
* caller to re-read through `hydrateSearchHits`. An empty query asks no
|
|
167
|
+
* branch and finds nothing. Fails with a branch's or the reranker's `E`.
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* ```ts
|
|
171
|
+
* import { deepStrictEqual } from "node:assert";
|
|
172
|
+
* import * as Effect from "effect/Effect";
|
|
173
|
+
* import { always } from "@thomasfosterau/pulpit/filter";
|
|
174
|
+
* import { hybridSearch } from "@thomasfosterau/pulpit/search";
|
|
175
|
+
*
|
|
176
|
+
* const search = hybridSearch({
|
|
177
|
+
* branches: [
|
|
178
|
+
* { name: "text", search: () => Effect.succeed([{ id: "a" }, { id: "b" }]) },
|
|
179
|
+
* { name: "vector", weight: 2, search: () => Effect.succeed([{ id: "b" }]) },
|
|
180
|
+
* ],
|
|
181
|
+
* });
|
|
182
|
+
* const hits = await Effect.runPromise(search({ query: "tomato", scope: always, limit: 10 }));
|
|
183
|
+
* deepStrictEqual(
|
|
184
|
+
* hits.map((hit) => hit.id),
|
|
185
|
+
* ["b", "a"],
|
|
186
|
+
* );
|
|
187
|
+
* ```
|
|
188
|
+
*
|
|
189
|
+
* @since 0.1.0
|
|
190
|
+
*/
|
|
191
|
+
export const hybridSearch = (definition) => (request) => Effect.gen(function* () {
|
|
192
|
+
if (request.query.trim() === "")
|
|
193
|
+
return [];
|
|
194
|
+
const perBranch = bounded(Option.fromUndefinedOr(definition.perBranch));
|
|
195
|
+
const rankings = yield* Effect.forEach(definition.branches, (branch) => Effect.map(branch.search({ query: request.query, scope: request.scope, limit: perBranch }), (hits) => ({ name: branch.name, weight: branch.weight, hits })), { concurrency: 1 });
|
|
196
|
+
const fused = fuseRankings(rankings, {
|
|
197
|
+
k: definition.k,
|
|
198
|
+
perBranch,
|
|
199
|
+
limit: MAX_PAGE_LIMIT,
|
|
200
|
+
});
|
|
201
|
+
const limit = bounded(Option.some(request.limit));
|
|
202
|
+
const reranker = definition.reranker;
|
|
203
|
+
if (reranker === undefined || !Arr.isReadonlyArrayNonEmpty(fused)) {
|
|
204
|
+
return Arr.take(fused, limit);
|
|
205
|
+
}
|
|
206
|
+
const order = yield* reranker.rerank(request.query, fused);
|
|
207
|
+
return Arr.take(applyReranking(fused, order), limit);
|
|
208
|
+
}).pipe(Effect.withSpan("search.hybrid"));
|
|
209
|
+
/**
|
|
210
|
+
* The embedder answered with a number of vectors other than the number of
|
|
211
|
+
* texts it was given, so no vector can be matched to its document. Nothing
|
|
212
|
+
* of the batch is written.
|
|
213
|
+
*
|
|
214
|
+
* @since 0.1.0
|
|
215
|
+
*/
|
|
216
|
+
export class EmbeddingMismatch extends Schema.TaggedError()("EmbeddingMismatch", { expected: Schema.Finite, received: Schema.Finite }) {
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The document store with embedding enqueued on a document's first insert:
|
|
220
|
+
* `onDocumentInsert` with `embeddings.enqueue`, so the queue row lands in
|
|
221
|
+
* the `put`'s own transaction and a rolled-back write queues nothing.
|
|
222
|
+
*
|
|
223
|
+
* @since 0.1.0
|
|
224
|
+
*/
|
|
225
|
+
export const enqueueEmbeddings = (documents, embeddings) => onDocumentInsert(documents, ({ hash }) => embeddings.enqueue(hash));
|
|
226
|
+
/**
|
|
227
|
+
* Embed one batch of pending documents: one `pending` read, one `getMany`,
|
|
228
|
+
* one embedder call and one `write` (`PERF-DB-001`). A pending hash whose
|
|
229
|
+
* document is missing, or whose `text` is none, is discarded rather than
|
|
230
|
+
* retried forever. Fails {@link EmbeddingMismatch} (writing nothing) when
|
|
231
|
+
* the embedder answers with the wrong number of vectors, else with the
|
|
232
|
+
* stores' and the embedder's `E`.
|
|
233
|
+
*
|
|
234
|
+
* @since 0.1.0
|
|
235
|
+
*/
|
|
236
|
+
export const embedPending = (pipeline) => Effect.gen(function* () {
|
|
237
|
+
const hashes = yield* pipeline.embeddings.pending(bounded(Option.some(pipeline.batchSize)));
|
|
238
|
+
if (!Arr.isReadonlyArrayNonEmpty(hashes))
|
|
239
|
+
return { embedded: 0, discarded: 0 };
|
|
240
|
+
const found = yield* pipeline.documents.getMany(hashes);
|
|
241
|
+
const textOf = (hash) => Option.flatMap(HashMap.get(found, hash), pipeline.text);
|
|
242
|
+
const skipped = Arr.filter(hashes, (hash) => Option.isNone(textOf(hash)));
|
|
243
|
+
const texts = Arr.getSomes(Arr.map(hashes, (hash) => Option.map(textOf(hash), (text) => ({ hash, text }))));
|
|
244
|
+
if (Arr.isReadonlyArrayNonEmpty(skipped))
|
|
245
|
+
yield* pipeline.embeddings.discard(skipped);
|
|
246
|
+
if (!Arr.isReadonlyArrayNonEmpty(texts))
|
|
247
|
+
return { embedded: 0, discarded: skipped.length };
|
|
248
|
+
const vectors = yield* pipeline.embedder.embed(Arr.map(texts, ({ text }) => text));
|
|
249
|
+
if (vectors.length !== texts.length) {
|
|
250
|
+
return yield* new EmbeddingMismatch({ expected: texts.length, received: vectors.length });
|
|
251
|
+
}
|
|
252
|
+
yield* pipeline.embeddings.write(Arr.zipWith(texts, vectors, ({ hash }, vector) => ({ hash, vector })));
|
|
253
|
+
return { embedded: texts.length, discarded: skipped.length };
|
|
254
|
+
}).pipe(Effect.withSpan("search.embedPending"));
|
|
255
|
+
/**
|
|
256
|
+
* Embed batches until the queue is drained or `maxBatches` (clamped to at
|
|
257
|
+
* least 1) have run: the daily sweep of the unembedded. A batch that
|
|
258
|
+
* embeds and discards nothing ends the sweep. Returns the totals.
|
|
259
|
+
*
|
|
260
|
+
* @since 0.1.0
|
|
261
|
+
*/
|
|
262
|
+
export const sweepEmbeddings = (pipeline, maxBatches) => {
|
|
263
|
+
const step = (left, total) => Effect.flatMap(embedPending(pipeline), (batch) => {
|
|
264
|
+
const next = {
|
|
265
|
+
embedded: total.embedded + batch.embedded,
|
|
266
|
+
discarded: total.discarded + batch.discarded,
|
|
267
|
+
};
|
|
268
|
+
const done = batch.embedded + batch.discarded === 0 || left <= 1;
|
|
269
|
+
return done ? Effect.succeed(next) : step(left - 1, next);
|
|
270
|
+
});
|
|
271
|
+
return step(Math.max(1, Math.trunc(maxBatches) || 1), { embedded: 0, discarded: 0 }).pipe(Effect.withSpan("search.sweepEmbeddings"));
|
|
272
|
+
};
|
|
273
|
+
//# sourceMappingURL=search.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search.js","sourceRoot":"","sources":["../src/search.ts"],"names":[],"mappings":"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"}
|