@thomasfosterau/pulpit 0.0.0-stage → 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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1272 -2
  3. package/dist/blobs.d.ts +249 -0
  4. package/dist/blobs.d.ts.map +1 -0
  5. package/dist/blobs.js +193 -0
  6. package/dist/blobs.js.map +1 -0
  7. package/dist/budgets.d.ts +255 -0
  8. package/dist/budgets.d.ts.map +1 -0
  9. package/dist/budgets.js +211 -0
  10. package/dist/budgets.js.map +1 -0
  11. package/dist/cache.d.ts +154 -0
  12. package/dist/cache.d.ts.map +1 -0
  13. package/dist/cache.js +118 -0
  14. package/dist/cache.js.map +1 -0
  15. package/dist/collections.d.ts +242 -0
  16. package/dist/collections.d.ts.map +1 -0
  17. package/dist/collections.js +215 -0
  18. package/dist/collections.js.map +1 -0
  19. package/dist/comments.d.ts +212 -0
  20. package/dist/comments.d.ts.map +1 -0
  21. package/dist/comments.js +192 -0
  22. package/dist/comments.js.map +1 -0
  23. package/dist/content.d.ts +231 -0
  24. package/dist/content.d.ts.map +1 -0
  25. package/dist/content.js +204 -0
  26. package/dist/content.js.map +1 -0
  27. package/dist/documents.d.ts +184 -0
  28. package/dist/documents.d.ts.map +1 -0
  29. package/dist/documents.js +190 -0
  30. package/dist/documents.js.map +1 -0
  31. package/dist/edges.d.ts +645 -0
  32. package/dist/edges.d.ts.map +1 -0
  33. package/dist/edges.js +558 -0
  34. package/dist/edges.js.map +1 -0
  35. package/dist/filter.d.ts +461 -0
  36. package/dist/filter.d.ts.map +1 -0
  37. package/dist/filter.js +387 -0
  38. package/dist/filter.js.map +1 -0
  39. package/dist/index.d.ts +133 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +133 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/internal/filter-columns.d.ts +8 -0
  44. package/dist/internal/filter-columns.d.ts.map +1 -0
  45. package/dist/internal/filter-columns.js +43 -0
  46. package/dist/internal/filter-columns.js.map +1 -0
  47. package/dist/internal/list-order.d.ts +26 -0
  48. package/dist/internal/list-order.d.ts.map +1 -0
  49. package/dist/internal/list-order.js +92 -0
  50. package/dist/internal/list-order.js.map +1 -0
  51. package/dist/internal/owned-columns.d.ts +93 -0
  52. package/dist/internal/owned-columns.d.ts.map +1 -0
  53. package/dist/internal/owned-columns.js +98 -0
  54. package/dist/internal/owned-columns.js.map +1 -0
  55. package/dist/internal/plain-rows.d.ts +32 -0
  56. package/dist/internal/plain-rows.d.ts.map +1 -0
  57. package/dist/internal/plain-rows.js +32 -0
  58. package/dist/internal/plain-rows.js.map +1 -0
  59. package/dist/internal/reference-columns.d.ts +80 -0
  60. package/dist/internal/reference-columns.d.ts.map +1 -0
  61. package/dist/internal/reference-columns.js +102 -0
  62. package/dist/internal/reference-columns.js.map +1 -0
  63. package/dist/moderation.d.ts +392 -0
  64. package/dist/moderation.d.ts.map +1 -0
  65. package/dist/moderation.js +375 -0
  66. package/dist/moderation.js.map +1 -0
  67. package/dist/paths.d.ts +204 -0
  68. package/dist/paths.d.ts.map +1 -0
  69. package/dist/paths.js +165 -0
  70. package/dist/paths.js.map +1 -0
  71. package/dist/proposals.d.ts +523 -0
  72. package/dist/proposals.d.ts.map +1 -0
  73. package/dist/proposals.js +505 -0
  74. package/dist/proposals.js.map +1 -0
  75. package/dist/publication.d.ts +148 -0
  76. package/dist/publication.d.ts.map +1 -0
  77. package/dist/publication.js +183 -0
  78. package/dist/publication.js.map +1 -0
  79. package/dist/purge.d.ts +224 -0
  80. package/dist/purge.d.ts.map +1 -0
  81. package/dist/purge.js +151 -0
  82. package/dist/purge.js.map +1 -0
  83. package/dist/reactions.d.ts +246 -0
  84. package/dist/reactions.d.ts.map +1 -0
  85. package/dist/reactions.js +183 -0
  86. package/dist/reactions.js.map +1 -0
  87. package/dist/registry.d.ts +316 -0
  88. package/dist/registry.d.ts.map +1 -0
  89. package/dist/registry.js +305 -0
  90. package/dist/registry.js.map +1 -0
  91. package/dist/search.d.ts +366 -0
  92. package/dist/search.d.ts.map +1 -0
  93. package/dist/search.js +273 -0
  94. package/dist/search.js.map +1 -0
  95. package/dist/social.d.ts +311 -0
  96. package/dist/social.d.ts.map +1 -0
  97. package/dist/social.js +205 -0
  98. package/dist/social.js.map +1 -0
  99. package/dist/subtype.d.ts +1613 -0
  100. package/dist/subtype.d.ts.map +1 -0
  101. package/dist/subtype.js +1300 -0
  102. package/dist/subtype.js.map +1 -0
  103. package/dist/testing.d.ts +675 -0
  104. package/dist/testing.d.ts.map +1 -0
  105. package/dist/testing.js +1022 -0
  106. package/dist/testing.js.map +1 -0
  107. package/dist/version-chain.d.ts +702 -0
  108. package/dist/version-chain.d.ts.map +1 -0
  109. package/dist/version-chain.js +489 -0
  110. package/dist/version-chain.js.map +1 -0
  111. package/dist/working-copy.d.ts +116 -0
  112. package/dist/working-copy.d.ts.map +1 -0
  113. package/dist/working-copy.js +95 -0
  114. package/dist/working-copy.js.map +1 -0
  115. package/package.json +145 -3
  116. package/src/blobs.ts +384 -0
  117. package/src/budgets.ts +338 -0
  118. package/src/cache.ts +218 -0
  119. package/src/collections.ts +427 -0
  120. package/src/comments.ts +392 -0
  121. package/src/content.ts +238 -0
  122. package/src/documents.ts +284 -0
  123. package/src/edges.ts +1185 -0
  124. package/src/filter.ts +523 -0
  125. package/src/index.ts +153 -0
  126. package/src/internal/filter-columns.ts +50 -0
  127. package/src/internal/list-order.ts +117 -0
  128. package/src/internal/owned-columns.ts +140 -0
  129. package/src/internal/plain-rows.ts +54 -0
  130. package/src/internal/reference-columns.ts +156 -0
  131. package/src/moderation.ts +728 -0
  132. package/src/paths.ts +267 -0
  133. package/src/proposals.ts +1014 -0
  134. package/src/publication.ts +225 -0
  135. package/src/purge.ts +313 -0
  136. package/src/reactions.ts +347 -0
  137. package/src/registry.ts +524 -0
  138. package/src/search.ts +540 -0
  139. package/src/social.ts +469 -0
  140. package/src/subtype.ts +3095 -0
  141. package/src/testing.ts +1873 -0
  142. package/src/version-chain.ts +1093 -0
  143. package/src/working-copy.ts +192 -0
@@ -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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"}