lorebase-keeper 0.2.3__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.
- lorebase_keeper/__init__.py +1 -0
- lorebase_keeper/__main__.py +456 -0
- lorebase_keeper/agents/__init__.py +1 -0
- lorebase_keeper/agents/budget.py +247 -0
- lorebase_keeper/agents/context.py +529 -0
- lorebase_keeper/agents/explain.py +582 -0
- lorebase_keeper/agents/feed.py +259 -0
- lorebase_keeper/agents/markdown.py +219 -0
- lorebase_keeper/agents/pack.py +403 -0
- lorebase_keeper/agents/settings.py +111 -0
- lorebase_keeper/app.py +373 -0
- lorebase_keeper/auth/__init__.py +4 -0
- lorebase_keeper/auth/api_keys.py +127 -0
- lorebase_keeper/auth/audit.py +356 -0
- lorebase_keeper/auth/cache.py +53 -0
- lorebase_keeper/auth/config.py +618 -0
- lorebase_keeper/auth/deps.py +183 -0
- lorebase_keeper/auth/keyring.py +14 -0
- lorebase_keeper/auth/oidc.py +242 -0
- lorebase_keeper/auth/permissions.py +142 -0
- lorebase_keeper/auth/ports.py +212 -0
- lorebase_keeper/auth/principals.py +377 -0
- lorebase_keeper/auth/provider.py +250 -0
- lorebase_keeper/auth/rbac.py +247 -0
- lorebase_keeper/auth/read_audit.py +357 -0
- lorebase_keeper/auth/runtime.py +113 -0
- lorebase_keeper/auth/sessions.py +452 -0
- lorebase_keeper/bootstrap.py +151 -0
- lorebase_keeper/client_address.py +129 -0
- lorebase_keeper/context.py +226 -0
- lorebase_keeper/db.py +93 -0
- lorebase_keeper/deps.py +47 -0
- lorebase_keeper/errors.py +257 -0
- lorebase_keeper/health.py +61 -0
- lorebase_keeper/kindhealth/__init__.py +6 -0
- lorebase_keeper/kindhealth/config.py +51 -0
- lorebase_keeper/kindhealth/markdown.py +163 -0
- lorebase_keeper/kindhealth/models.py +179 -0
- lorebase_keeper/kindhealth/report.py +154 -0
- lorebase_keeper/kindhealth/scan.py +512 -0
- lorebase_keeper/middleware.py +285 -0
- lorebase_keeper/pagination.py +87 -0
- lorebase_keeper/ratelimit/__init__.py +10 -0
- lorebase_keeper/ratelimit/bucket.py +118 -0
- lorebase_keeper/ratelimit/config.py +283 -0
- lorebase_keeper/ratelimit/enforce.py +393 -0
- lorebase_keeper/ratelimit/limiter.py +149 -0
- lorebase_keeper/ratelimit/quotas.py +204 -0
- lorebase_keeper/ratelimit/shared.py +242 -0
- lorebase_keeper/readiness.py +257 -0
- lorebase_keeper/reading/__init__.py +1 -0
- lorebase_keeper/reading/caching.py +251 -0
- lorebase_keeper/reading/compiler.py +678 -0
- lorebase_keeper/reading/cursors.py +56 -0
- lorebase_keeper/reading/diff.py +408 -0
- lorebase_keeper/reading/graph.py +268 -0
- lorebase_keeper/reading/history.py +152 -0
- lorebase_keeper/reading/points.py +410 -0
- lorebase_keeper/reading/problems.py +74 -0
- lorebase_keeper/reading/record_history.py +722 -0
- lorebase_keeper/reading/records.py +780 -0
- lorebase_keeper/reading/requests.py +107 -0
- lorebase_keeper/reading/runtime.py +145 -0
- lorebase_keeper/reading/search.py +708 -0
- lorebase_keeper/reading/sensitivity.py +882 -0
- lorebase_keeper/reading/settings.py +339 -0
- lorebase_keeper/reading/snapshots.py +148 -0
- lorebase_keeper/reading/tree.py +98 -0
- lorebase_keeper/reading/views.py +264 -0
- lorebase_keeper/reading/visibility.py +93 -0
- lorebase_keeper/routes/__init__.py +1 -0
- lorebase_keeper/routes/admin.py +597 -0
- lorebase_keeper/routes/agents.py +160 -0
- lorebase_keeper/routes/branches.py +438 -0
- lorebase_keeper/routes/derivations.py +215 -0
- lorebase_keeper/routes/documents.py +497 -0
- lorebase_keeper/routes/domain_api.py +59 -0
- lorebase_keeper/routes/domains.py +220 -0
- lorebase_keeper/routes/events.py +187 -0
- lorebase_keeper/routes/kind_health.py +166 -0
- lorebase_keeper/routes/operations.py +392 -0
- lorebase_keeper/routes/proposal_items.py +563 -0
- lorebase_keeper/routes/proposals.py +1107 -0
- lorebase_keeper/routes/read.py +493 -0
- lorebase_keeper/routes/revisions.py +523 -0
- lorebase_keeper/routes/schema.py +682 -0
- lorebase_keeper/routes/schema_inference.py +437 -0
- lorebase_keeper/routes/schema_suggestions.py +328 -0
- lorebase_keeper/routes/sessions.py +286 -0
- lorebase_keeper/routes/source_preview.py +232 -0
- lorebase_keeper/routes/sources.py +200 -0
- lorebase_keeper/routes/trace.py +506 -0
- lorebase_keeper/routes/vault.py +332 -0
- lorebase_keeper/routes/webhooks.py +298 -0
- lorebase_keeper/routes/whoami.py +88 -0
- lorebase_keeper/services/__init__.py +1 -0
- lorebase_keeper/services/agent_writes.py +1035 -0
- lorebase_keeper/services/branches.py +337 -0
- lorebase_keeper/services/config.py +431 -0
- lorebase_keeper/services/derivation_preview.py +279 -0
- lorebase_keeper/services/document_urls.py +114 -0
- lorebase_keeper/services/domains.py +118 -0
- lorebase_keeper/services/errors.py +196 -0
- lorebase_keeper/services/operation_catalog.py +806 -0
- lorebase_keeper/services/operation_events.py +199 -0
- lorebase_keeper/services/operations.py +1229 -0
- lorebase_keeper/services/pii_import.py +222 -0
- lorebase_keeper/services/proposal_reads.py +158 -0
- lorebase_keeper/services/proposals.py +850 -0
- lorebase_keeper/services/reads.py +286 -0
- lorebase_keeper/services/schema.py +601 -0
- lorebase_keeper/services/schema_inference.py +390 -0
- lorebase_keeper/services/schema_suggestions.py +418 -0
- lorebase_keeper/services/source_preview.py +127 -0
- lorebase_keeper/settings.py +313 -0
- lorebase_keeper/tracing/__init__.py +1 -0
- lorebase_keeper/tracing/capture.py +350 -0
- lorebase_keeper/tracing/config.py +157 -0
- lorebase_keeper/tracing/explain.py +97 -0
- lorebase_keeper/tracing/middleware.py +359 -0
- lorebase_keeper/tracing/notes.py +80 -0
- lorebase_keeper/tracing/recorder.py +180 -0
- lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
- lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
- lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
- lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"""Search over the materialized head (design 8.4).
|
|
2
|
+
|
|
3
|
+
- lexical: `search @@ websearch_to_tsquery(q)`, ranked by `ts_rank_cd`, with typo tolerance:
|
|
4
|
+
each word of `q` the branch does not hold is corrected to its closest words in the branch's
|
|
5
|
+
lexicon (`lorebase_store.reads.search_corrections`, `search.trigram_threshold` and
|
|
6
|
+
`search.typo_*`) and searched as `(word | correction | ...)`. Hits of `q` as written come
|
|
7
|
+
first; the others follow at `search.trigram_weight` times their rank. The response lists
|
|
8
|
+
the corrections. When q as written finds fewer than `search.partial.min_results` Records,
|
|
9
|
+
Records holding only some of its terms follow (`lorebase_store.reads.PartialMatching`:
|
|
10
|
+
the terms joined by OR, negations and phrases kept, scored by `ts_rank_cd` plus
|
|
11
|
+
`search.partial.term_bonus` per term held, corrections applied per term); each hit
|
|
12
|
+
reports `match: all|partial`;
|
|
13
|
+
- semantic: cosine distance between the caller's `query_vector` and the stored embedding of
|
|
14
|
+
each Record's object hash for the configured model and the fingerprint of its Kind's
|
|
15
|
+
embedding template in the searched branch (Keeper never computes vectors);
|
|
16
|
+
- hybrid: reciprocal rank fusion of both candidate lists, computed in SQL:
|
|
17
|
+
`w_lex / (k + rank_lex) + w_sem / (k + rank_sem)`.
|
|
18
|
+
|
|
19
|
+
Filters (`kinds`, `where`) apply before ranking and fusion. Snippets come from `ts_headline`
|
|
20
|
+
over the title and the searchable Attributes.
|
|
21
|
+
|
|
22
|
+
Lexical search without `where` goes through the store primitive `search_records` (design
|
|
23
|
+
8.7), through the security definer function `lb_search_bounded`, so its index plans hold
|
|
24
|
+
under row-level security; snippets are computed for the returned page only. The word lexicon
|
|
25
|
+
bounds the matches of q (`lorebase_store.reads.CommonTerms`): within
|
|
26
|
+
`search.common_term_docs` every match is ranked; when every term is more common, only the
|
|
27
|
+
first `search.common_term_candidates` matches in index order are, and the response says
|
|
28
|
+
`truncated_candidates`. With `search.backend: bm25` the BM25 index of `pg_search` ranks it
|
|
29
|
+
instead (`lorebase_store.bm25`): every match is ranked by BM25, and the store keeps the
|
|
30
|
+
default ranking while the database has no valid BM25 index. Filtered, semantic and hybrid
|
|
31
|
+
searches are compiled here, with `ts_rank_cd`, whatever the backend.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import copy
|
|
37
|
+
from typing import Any, Literal
|
|
38
|
+
|
|
39
|
+
from pgvector.sqlalchemy import HALFVEC, VECTOR
|
|
40
|
+
from sqlalchemy import (
|
|
41
|
+
ARRAY,
|
|
42
|
+
Boolean,
|
|
43
|
+
ColumnElement,
|
|
44
|
+
Float,
|
|
45
|
+
Select,
|
|
46
|
+
Table,
|
|
47
|
+
Text,
|
|
48
|
+
and_,
|
|
49
|
+
case,
|
|
50
|
+
cast,
|
|
51
|
+
false,
|
|
52
|
+
func,
|
|
53
|
+
literal,
|
|
54
|
+
not_,
|
|
55
|
+
null,
|
|
56
|
+
or_,
|
|
57
|
+
over,
|
|
58
|
+
select,
|
|
59
|
+
true,
|
|
60
|
+
tuple_,
|
|
61
|
+
union_all,
|
|
62
|
+
)
|
|
63
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
64
|
+
|
|
65
|
+
from lorebase_core.model import Kind
|
|
66
|
+
from lorebase_core.model.embedding import TemplateError, kind_template
|
|
67
|
+
from lorebase_core.query import QueryError, Target, condition_kinds, resolve_where
|
|
68
|
+
from lorebase_keeper.reading.compiler import Compiler, json_text
|
|
69
|
+
from lorebase_keeper.reading.points import ReadPoint
|
|
70
|
+
from lorebase_keeper.reading.problems import invalid, query_problem
|
|
71
|
+
from lorebase_keeper.reading.records import compiler_for
|
|
72
|
+
from lorebase_keeper.reading.requests import SearchMode, SearchRequest, SearchTuning
|
|
73
|
+
from lorebase_keeper.reading.runtime import ReadRuntime
|
|
74
|
+
from lorebase_keeper.reading.sensitivity import Masking, condition_fields, require_comparable
|
|
75
|
+
from lorebase_keeper.reading.snapshots import with_rows
|
|
76
|
+
from lorebase_keeper.reading.views import (
|
|
77
|
+
SearchCorrection,
|
|
78
|
+
SearchHit,
|
|
79
|
+
SearchResponse,
|
|
80
|
+
kind_name,
|
|
81
|
+
served,
|
|
82
|
+
)
|
|
83
|
+
from lorebase_keeper.tracing.explain import explain
|
|
84
|
+
from lorebase_store.embeddings import fingerprint_of, index_precision, template_fingerprints
|
|
85
|
+
from lorebase_store.models import metadata
|
|
86
|
+
from lorebase_store.reads import (
|
|
87
|
+
CommonTerms,
|
|
88
|
+
Correction,
|
|
89
|
+
PartialMatching,
|
|
90
|
+
PartialQuery,
|
|
91
|
+
TypoTolerance,
|
|
92
|
+
partial_terms,
|
|
93
|
+
search_corrections,
|
|
94
|
+
search_records,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
EMBEDDINGS: Table = metadata.tables["embeddings"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def search_mode(request: SearchRequest) -> SearchMode:
|
|
101
|
+
"""Requested mode, checked against the inputs it needs; 422 otherwise."""
|
|
102
|
+
has_text, has_vector = request.q is not None, request.query_vector is not None
|
|
103
|
+
mode = request.mode
|
|
104
|
+
if mode is None:
|
|
105
|
+
if has_text and has_vector:
|
|
106
|
+
return "hybrid"
|
|
107
|
+
if has_vector:
|
|
108
|
+
return "semantic"
|
|
109
|
+
if has_text:
|
|
110
|
+
return "lexical"
|
|
111
|
+
raise invalid("/body/q", "give q, query_vector or both", "missing")
|
|
112
|
+
if mode in ("lexical", "hybrid") and not has_text:
|
|
113
|
+
raise invalid("/body/q", f"{mode} search needs q", "missing")
|
|
114
|
+
if mode in ("semantic", "hybrid") and not has_vector:
|
|
115
|
+
raise invalid("/body/query_vector", f"{mode} search needs query_vector", "missing")
|
|
116
|
+
return mode
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _target(point: ReadPoint, request: SearchRequest) -> Target:
|
|
120
|
+
if request.kinds is None:
|
|
121
|
+
# Default: every Kind the caller may read; Kinds named explicitly must be readable.
|
|
122
|
+
kinds = tuple(
|
|
123
|
+
sorted(
|
|
124
|
+
(k for k in point.schema.kinds.values() if point.scope.allows(k.api_name)),
|
|
125
|
+
key=lambda k: k.id,
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
return Target("search", kinds)
|
|
129
|
+
point.scope.precheck(request.kinds)
|
|
130
|
+
found: list[Kind] = []
|
|
131
|
+
for index, name in enumerate(request.kinds):
|
|
132
|
+
kind = point.schema.kind_by_api_name(name)
|
|
133
|
+
if kind is None:
|
|
134
|
+
raise invalid(f"/body/kinds/{index}", f"unknown Kind {name!r}", "unknown_kind")
|
|
135
|
+
found.append(kind)
|
|
136
|
+
if not found:
|
|
137
|
+
raise invalid("/body/kinds", "name at least one Kind", "missing")
|
|
138
|
+
return Target(found[0].api_name if len(found) == 1 else "search", tuple(found))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def search(
|
|
142
|
+
session: AsyncSession, runtime: ReadRuntime, point: ReadPoint, request: SearchRequest
|
|
143
|
+
) -> SearchResponse:
|
|
144
|
+
runtime = tuned(runtime, request.tuning)
|
|
145
|
+
settings = runtime.search
|
|
146
|
+
mode = search_mode(request)
|
|
147
|
+
limit = request.limit or settings.results.default_limit
|
|
148
|
+
if limit > settings.results.max_limit:
|
|
149
|
+
raise invalid(
|
|
150
|
+
"/body/limit", f"must not exceed {settings.results.max_limit}", "limit_exceeded"
|
|
151
|
+
)
|
|
152
|
+
dims = runtime.store_settings.embeddings.dims
|
|
153
|
+
if request.query_vector is not None and len(request.query_vector) != dims:
|
|
154
|
+
raise invalid(
|
|
155
|
+
"/body/query_vector",
|
|
156
|
+
f"has {len(request.query_vector)} dimensions; the configured model "
|
|
157
|
+
f"{settings.semantic.model!r} has {dims}",
|
|
158
|
+
"dimension_mismatch",
|
|
159
|
+
)
|
|
160
|
+
target = _target(point, request)
|
|
161
|
+
if mode in ("lexical", "hybrid"):
|
|
162
|
+
target = _indexed_readable(point, target)
|
|
163
|
+
if not target.kinds:
|
|
164
|
+
return SearchResponse(**served(point), mode=mode, items=[])
|
|
165
|
+
where = None
|
|
166
|
+
if request.where is not None:
|
|
167
|
+
if len(target.kinds) != 1:
|
|
168
|
+
raise invalid("/body/where", "filters need exactly one Kind in kinds", "ambiguous")
|
|
169
|
+
try:
|
|
170
|
+
where = resolve_where(
|
|
171
|
+
request.where, target, point.schema, runtime.read.query_limits(), "/where"
|
|
172
|
+
)
|
|
173
|
+
except QueryError as error:
|
|
174
|
+
raise query_problem(error, "/body") from None
|
|
175
|
+
point.scope.require(condition_kinds(where))
|
|
176
|
+
require_comparable(
|
|
177
|
+
point.schema, point.masking, condition_fields(where, target.kinds), "/body/where"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
point = await with_rows(session, runtime, point, "Search")
|
|
181
|
+
compiler = compiler_for(point, runtime)
|
|
182
|
+
corrections: list[Correction] = []
|
|
183
|
+
if request.q is not None and mode in ("lexical", "hybrid"):
|
|
184
|
+
corrections = await search_corrections(
|
|
185
|
+
session,
|
|
186
|
+
point.domain.id,
|
|
187
|
+
point.layout,
|
|
188
|
+
request.q,
|
|
189
|
+
typo_tolerance(runtime),
|
|
190
|
+
kind_ids=list(target.kind_ids),
|
|
191
|
+
runtime=runtime.store,
|
|
192
|
+
)
|
|
193
|
+
partial = partial_matching(runtime)
|
|
194
|
+
if mode == "lexical" and where is None:
|
|
195
|
+
assert request.q is not None
|
|
196
|
+
return await _lexical_page(
|
|
197
|
+
session, runtime, compiler, point, target, request.q, limit, corrections, partial
|
|
198
|
+
)
|
|
199
|
+
r = compiler.records("r")
|
|
200
|
+
base = [compiler.record_scope(r, target)]
|
|
201
|
+
if where is not None:
|
|
202
|
+
base.append(compiler.condition(r, where, target))
|
|
203
|
+
|
|
204
|
+
alternatives = None
|
|
205
|
+
if request.q is not None and partial is not None and mode in ("lexical", "hybrid"):
|
|
206
|
+
alternatives = await partial_terms(session, request.q, partial.stop_words)
|
|
207
|
+
lists: list[tuple[Select[Any], float]] = []
|
|
208
|
+
if mode in ("lexical", "hybrid"):
|
|
209
|
+
assert request.q is not None
|
|
210
|
+
lists.append(
|
|
211
|
+
(
|
|
212
|
+
_lexical(compiler, r, base, request.q, corrections, runtime, alternatives),
|
|
213
|
+
settings.hybrid.lexical_weight,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
if mode in ("semantic", "hybrid"):
|
|
217
|
+
assert request.query_vector is not None
|
|
218
|
+
precision = await index_precision(session, dims)
|
|
219
|
+
lists.append(
|
|
220
|
+
(
|
|
221
|
+
_semantic(
|
|
222
|
+
r,
|
|
223
|
+
[*base, _embedded_readable(r, point, target)],
|
|
224
|
+
request.query_vector,
|
|
225
|
+
runtime,
|
|
226
|
+
target,
|
|
227
|
+
half=precision == "half",
|
|
228
|
+
),
|
|
229
|
+
settings.hybrid.semantic_weight,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
ranked = lists[0][0].subquery("ranked") if len(lists) == 1 else _fused(lists, runtime)
|
|
233
|
+
|
|
234
|
+
final = point.layout.records("hit")
|
|
235
|
+
snippet: ColumnElement[Any] = literal(None)
|
|
236
|
+
if request.q is not None:
|
|
237
|
+
snippet = func.ts_headline(
|
|
238
|
+
compiler.regconfig(),
|
|
239
|
+
func.concat_ws(
|
|
240
|
+
" ",
|
|
241
|
+
_snippet_title(final, target, point.masking),
|
|
242
|
+
*_searchable_text(final, target, point.masking),
|
|
243
|
+
),
|
|
244
|
+
_corrected(
|
|
245
|
+
compiler, request.q if alternatives is None else alternatives.text, corrections
|
|
246
|
+
),
|
|
247
|
+
literal(settings.headline_options),
|
|
248
|
+
)
|
|
249
|
+
# A lexical list puts full matches first, then the hits of q as written; fusion ranks by
|
|
250
|
+
# position already (its `full` is null for semantic-only hits, which DESC would put first).
|
|
251
|
+
fused = len(lists) > 1
|
|
252
|
+
exact_first = [] if fused else [ranked.c[c].desc() for c in ("full", "exact") if c in ranked.c]
|
|
253
|
+
full = ranked.c.full if "full" in ranked.c else literal(None, Boolean)
|
|
254
|
+
query = (
|
|
255
|
+
select(
|
|
256
|
+
ranked.c.kind_id,
|
|
257
|
+
ranked.c.key,
|
|
258
|
+
ranked.c.score,
|
|
259
|
+
full.label("full"),
|
|
260
|
+
final.c.title,
|
|
261
|
+
snippet.label("snippet"),
|
|
262
|
+
)
|
|
263
|
+
.select_from(ranked)
|
|
264
|
+
.join(
|
|
265
|
+
final,
|
|
266
|
+
and_(
|
|
267
|
+
final.c.domain_id == literal(point.domain.id),
|
|
268
|
+
final.c.branch == literal(point.layout.branch),
|
|
269
|
+
final.c.kind_id == ranked.c.kind_id,
|
|
270
|
+
final.c.key == ranked.c.key,
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
.order_by(*exact_first, ranked.c.score.desc(), ranked.c.kind_id, ranked.c.key)
|
|
274
|
+
.limit(limit)
|
|
275
|
+
)
|
|
276
|
+
await explain(session, query, point.domain.id, f"search:{mode}")
|
|
277
|
+
rows = (await session.execute(query)).mappings()
|
|
278
|
+
items = [
|
|
279
|
+
SearchHit(
|
|
280
|
+
kind=kind_name(point.schema, row["kind_id"]),
|
|
281
|
+
key=row["key"],
|
|
282
|
+
title=point.masking.title(point.schema, row["kind_id"], row["title"]),
|
|
283
|
+
snippet=row["snippet"],
|
|
284
|
+
score=float(row["score"]),
|
|
285
|
+
match=_match(row["full"]),
|
|
286
|
+
)
|
|
287
|
+
for row in rows
|
|
288
|
+
]
|
|
289
|
+
return SearchResponse(
|
|
290
|
+
**served(point), mode=mode, items=items, corrections=_applied(corrections)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def tuned(runtime: ReadRuntime, tuning: SearchTuning | None) -> ReadRuntime:
|
|
295
|
+
"""`runtime` with the ranking knobs of `tuning` in place of the `search` settings. Knobs
|
|
296
|
+
that bound the work may only make it smaller: `candidates` and `typo_candidates` lower,
|
|
297
|
+
`trigram_threshold` higher; 422 otherwise."""
|
|
298
|
+
if tuning is None:
|
|
299
|
+
return runtime
|
|
300
|
+
settings = runtime.search
|
|
301
|
+
for name in ("candidates", "typo_candidates"):
|
|
302
|
+
value, bound = getattr(tuning, name), getattr(settings, name)
|
|
303
|
+
if value is not None and value > bound:
|
|
304
|
+
raise invalid(f"/body/tuning/{name}", f"must not exceed {bound}", "limit_exceeded")
|
|
305
|
+
threshold = tuning.trigram_threshold
|
|
306
|
+
if threshold is not None and threshold < settings.trigram_threshold:
|
|
307
|
+
raise invalid(
|
|
308
|
+
"/body/tuning/trigram_threshold",
|
|
309
|
+
f"must not be below {settings.trigram_threshold}",
|
|
310
|
+
"limit_exceeded",
|
|
311
|
+
)
|
|
312
|
+
given: dict[str, Any] = tuning.model_dump(exclude_none=True)
|
|
313
|
+
hybrid: dict[str, Any] = {
|
|
314
|
+
k: given.pop(k) for k in ("lexical_weight", "semantic_weight", "rrf_k") if k in given
|
|
315
|
+
}
|
|
316
|
+
effective = copy.copy(runtime)
|
|
317
|
+
effective.search = settings.model_copy(
|
|
318
|
+
update={**given, "hybrid": settings.hybrid.model_copy(update=hybrid)}
|
|
319
|
+
)
|
|
320
|
+
return effective
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def typo_tolerance(runtime: ReadRuntime) -> TypoTolerance:
|
|
324
|
+
"""Typo tolerance of lexical search from the `search` settings."""
|
|
325
|
+
settings = runtime.search
|
|
326
|
+
return TypoTolerance(
|
|
327
|
+
threshold=settings.trigram_threshold,
|
|
328
|
+
weight=settings.trigram_weight,
|
|
329
|
+
candidates=settings.typo_candidates,
|
|
330
|
+
min_length=settings.typo_min_length,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def partial_matching(runtime: ReadRuntime) -> PartialMatching | None:
|
|
335
|
+
"""Partial matching of lexical search from `search.partial`; `None` when it is off."""
|
|
336
|
+
settings = runtime.search.partial
|
|
337
|
+
if settings.min_results < 1:
|
|
338
|
+
return None
|
|
339
|
+
return PartialMatching(
|
|
340
|
+
min_results=settings.min_results,
|
|
341
|
+
weight=settings.weight,
|
|
342
|
+
term_bonus=settings.term_bonus,
|
|
343
|
+
candidates=settings.candidates,
|
|
344
|
+
stop_words=settings.stop_words,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def common_terms(runtime: ReadRuntime) -> CommonTerms | None:
|
|
349
|
+
"""How many matches lexical search ranks, from `search.common_term_*`; `None` (the store's
|
|
350
|
+
`store.reads.search_candidates`) when `common_term_docs` is 0."""
|
|
351
|
+
settings = runtime.search
|
|
352
|
+
if settings.common_term_docs < 1:
|
|
353
|
+
return None
|
|
354
|
+
return CommonTerms(docs=settings.common_term_docs, candidates=settings.common_term_candidates)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _match(full: bool | None) -> Literal["all", "partial"] | None:
|
|
358
|
+
if full is None:
|
|
359
|
+
return None
|
|
360
|
+
return "all" if full else "partial"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _applied(corrections: list[Correction]) -> list[SearchCorrection]:
|
|
364
|
+
return [
|
|
365
|
+
SearchCorrection(word=c.word, replacements=list(c.replacements))
|
|
366
|
+
for c in corrections
|
|
367
|
+
if c.replacements
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _corrected(compiler: Compiler, q: str, corrections: list[Correction]) -> ColumnElement[Any]:
|
|
372
|
+
"""The tsquery of q with each corrected word widened to `(word | replacement | ...)`, as
|
|
373
|
+
the store's search does (`lb_lexeme_query`, revision 0018)."""
|
|
374
|
+
return _rewritten(compiler.tsquery(q), corrections)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _rewritten(query: ColumnElement[Any], corrections: list[Correction]) -> ColumnElement[Any]:
|
|
378
|
+
for correction in corrections:
|
|
379
|
+
if not correction.replacements:
|
|
380
|
+
continue
|
|
381
|
+
word = literal(correction.word, Text)
|
|
382
|
+
alternatives = literal(list(correction.replacements), ARRAY(Text))
|
|
383
|
+
query = func.ts_rewrite(
|
|
384
|
+
query, func.lb_lexeme_query(word), func.lb_lexeme_query(word, alternatives)
|
|
385
|
+
)
|
|
386
|
+
return query
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def _lexical_page(
|
|
390
|
+
session: AsyncSession,
|
|
391
|
+
runtime: ReadRuntime,
|
|
392
|
+
compiler: Compiler,
|
|
393
|
+
point: ReadPoint,
|
|
394
|
+
target: Target,
|
|
395
|
+
q: str,
|
|
396
|
+
limit: int,
|
|
397
|
+
corrections: list[Correction],
|
|
398
|
+
partial: PartialMatching | None,
|
|
399
|
+
) -> SearchResponse:
|
|
400
|
+
"""Lexical search without filters: the store primitive ranks a bounded candidate set
|
|
401
|
+
(index plans under row-level security too), then snippets for the page by key."""
|
|
402
|
+
settings = runtime.search
|
|
403
|
+
hits = await search_records(
|
|
404
|
+
session,
|
|
405
|
+
point.domain.id,
|
|
406
|
+
point.layout,
|
|
407
|
+
q,
|
|
408
|
+
limit=limit,
|
|
409
|
+
kind_ids=list(target.kind_ids),
|
|
410
|
+
corrections=corrections,
|
|
411
|
+
correction_weight=settings.trigram_weight,
|
|
412
|
+
partial=partial,
|
|
413
|
+
common=common_terms(runtime),
|
|
414
|
+
backend=settings.backend,
|
|
415
|
+
runtime=runtime.store,
|
|
416
|
+
)
|
|
417
|
+
snippets: dict[tuple[str, str], str | None] = {}
|
|
418
|
+
alternatives = None
|
|
419
|
+
if partial is not None and any(h.partial for h in hits):
|
|
420
|
+
alternatives = await partial_terms(session, q, partial.stop_words)
|
|
421
|
+
if hits:
|
|
422
|
+
hit = point.layout.records("hit")
|
|
423
|
+
headline = func.ts_headline(
|
|
424
|
+
compiler.regconfig(),
|
|
425
|
+
func.concat_ws(
|
|
426
|
+
" ",
|
|
427
|
+
_snippet_title(hit, target, point.masking),
|
|
428
|
+
*_searchable_text(hit, target, point.masking),
|
|
429
|
+
),
|
|
430
|
+
_corrected(compiler, q if alternatives is None else alternatives.text, corrections),
|
|
431
|
+
literal(settings.headline_options),
|
|
432
|
+
)
|
|
433
|
+
query = select(hit.c.kind_id, hit.c.key, headline.label("snippet")).where(
|
|
434
|
+
hit.c.domain_id == literal(point.domain.id),
|
|
435
|
+
hit.c.branch == literal(point.layout.branch),
|
|
436
|
+
tuple_(hit.c.kind_id, hit.c.key).in_([(h.kind_id, h.key) for h in hits]),
|
|
437
|
+
)
|
|
438
|
+
for row in (await session.execute(query)).mappings():
|
|
439
|
+
snippets[(row["kind_id"], row["key"])] = row["snippet"]
|
|
440
|
+
items = [
|
|
441
|
+
SearchHit(
|
|
442
|
+
kind=kind_name(point.schema, h.kind_id),
|
|
443
|
+
key=h.key,
|
|
444
|
+
title=point.masking.title(point.schema, h.kind_id, h.title),
|
|
445
|
+
snippet=snippets.get((h.kind_id, h.key)),
|
|
446
|
+
score=h.score,
|
|
447
|
+
match="partial" if h.partial else "all",
|
|
448
|
+
)
|
|
449
|
+
for h in hits
|
|
450
|
+
]
|
|
451
|
+
return SearchResponse(
|
|
452
|
+
**served(point),
|
|
453
|
+
mode="lexical",
|
|
454
|
+
items=items,
|
|
455
|
+
corrections=_applied(corrections),
|
|
456
|
+
truncated_candidates=hits.truncated,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _lexical(
|
|
461
|
+
compiler: Compiler,
|
|
462
|
+
r: Any,
|
|
463
|
+
base: list[ColumnElement[bool]],
|
|
464
|
+
q: str,
|
|
465
|
+
corrections: list[Correction],
|
|
466
|
+
runtime: ReadRuntime,
|
|
467
|
+
alternatives: PartialQuery | None = None,
|
|
468
|
+
) -> Select[Any]:
|
|
469
|
+
"""Lexical candidates with `full` and `exact` columns: matches of q as written first, then
|
|
470
|
+
those found only through a correction at `trigram_weight` times their rank. With
|
|
471
|
+
`alternatives` (`search.partial`), fewer than `min_results` of them are followed by
|
|
472
|
+
Records holding only some terms (`full` false)."""
|
|
473
|
+
settings = runtime.search
|
|
474
|
+
tsquery = compiler.tsquery(q)
|
|
475
|
+
exact: ColumnElement[bool] = r.c.search.op("@@", return_type=Boolean)(tsquery)
|
|
476
|
+
score: ColumnElement[Any] = func.ts_rank_cd(r.c.search, tsquery)
|
|
477
|
+
match: ColumnElement[bool] = exact
|
|
478
|
+
fixes = [c for c in corrections if c.replacements]
|
|
479
|
+
if fixes:
|
|
480
|
+
fixed = _corrected(compiler, q, corrections)
|
|
481
|
+
match = or_(exact, r.c.search.op("@@", return_type=Boolean)(fixed))
|
|
482
|
+
score = case(
|
|
483
|
+
(exact, score),
|
|
484
|
+
else_=literal(settings.trigram_weight, Float) * func.ts_rank_cd(r.c.search, fixed),
|
|
485
|
+
)
|
|
486
|
+
else:
|
|
487
|
+
exact = true()
|
|
488
|
+
flag = func.coalesce(exact, False)
|
|
489
|
+
strict = (
|
|
490
|
+
select(
|
|
491
|
+
r.c.kind_id,
|
|
492
|
+
r.c.key,
|
|
493
|
+
cast(score, Float).label("score"),
|
|
494
|
+
flag.label("exact"),
|
|
495
|
+
true().label("full"),
|
|
496
|
+
)
|
|
497
|
+
.where(*base, match)
|
|
498
|
+
.order_by(not_(flag), score.desc(), r.c.kind_id, r.c.key)
|
|
499
|
+
.limit(settings.candidates)
|
|
500
|
+
)
|
|
501
|
+
if alternatives is None:
|
|
502
|
+
return strict
|
|
503
|
+
full = strict.cte("full_matches")
|
|
504
|
+
enough = select(func.count()).select_from(full).scalar_subquery()
|
|
505
|
+
partial = _partial(compiler, r, base, alternatives, fixes, runtime).where(
|
|
506
|
+
not_(func.coalesce(match, False)),
|
|
507
|
+
enough < literal(settings.partial.min_results),
|
|
508
|
+
)
|
|
509
|
+
lexical = union_all(select(*full.c), partial).subquery("lexical")
|
|
510
|
+
return select(*lexical.c)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _partial(
|
|
514
|
+
compiler: Compiler,
|
|
515
|
+
r: Any,
|
|
516
|
+
base: list[ColumnElement[bool]],
|
|
517
|
+
alternatives: PartialQuery,
|
|
518
|
+
fixes: list[Correction],
|
|
519
|
+
runtime: ReadRuntime,
|
|
520
|
+
) -> Select[Any]:
|
|
521
|
+
"""Records holding any term of q, scored `weight * (rank + term_bonus * terms held)`, as
|
|
522
|
+
the store's `search_records` does with `PartialMatching`."""
|
|
523
|
+
settings = runtime.search
|
|
524
|
+
partial = settings.partial
|
|
525
|
+
written = compiler.tsquery(alternatives.text)
|
|
526
|
+
any_term = r.c.search.op("@@", return_type=Boolean)(written)
|
|
527
|
+
rank: ColumnElement[Any] = func.ts_rank_cd(r.c.search, written)
|
|
528
|
+
if fixes:
|
|
529
|
+
fixed = _corrected(compiler, alternatives.text, fixes)
|
|
530
|
+
rank = case(
|
|
531
|
+
(any_term, rank),
|
|
532
|
+
else_=literal(settings.trigram_weight, Float) * func.ts_rank_cd(r.c.search, fixed),
|
|
533
|
+
)
|
|
534
|
+
any_term = r.c.search.op("@@", return_type=Boolean)(fixed)
|
|
535
|
+
terms = (
|
|
536
|
+
func.unnest(literal(list(alternatives.terms), ARRAY(Text)))
|
|
537
|
+
.table_valued("term")
|
|
538
|
+
.render_derived(name="terms")
|
|
539
|
+
)
|
|
540
|
+
term_query = _rewritten(func.websearch_to_tsquery(compiler.regconfig(), terms.c.term), fixes)
|
|
541
|
+
held = (
|
|
542
|
+
select(func.count())
|
|
543
|
+
.select_from(terms)
|
|
544
|
+
.where(r.c.search.op("@@", return_type=Boolean)(term_query))
|
|
545
|
+
.scalar_subquery()
|
|
546
|
+
)
|
|
547
|
+
score = literal(partial.weight, Float) * (
|
|
548
|
+
rank + literal(partial.term_bonus, Float) * cast(held, Float)
|
|
549
|
+
)
|
|
550
|
+
return (
|
|
551
|
+
select(
|
|
552
|
+
r.c.kind_id,
|
|
553
|
+
r.c.key,
|
|
554
|
+
cast(score, Float).label("score"),
|
|
555
|
+
true().label("exact"),
|
|
556
|
+
false().label("full"),
|
|
557
|
+
)
|
|
558
|
+
.where(*base, any_term)
|
|
559
|
+
.order_by(score.desc(), r.c.kind_id, r.c.key)
|
|
560
|
+
.limit(min(partial.candidates, settings.candidates))
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _indexed_readable(point: ReadPoint, target: Target) -> Target:
|
|
565
|
+
"""The Kinds whose full-text index the caller may search. The index of a branch holds the
|
|
566
|
+
words of the Attributes its schema leaves untagged; when the default branch tagged one of
|
|
567
|
+
them after the branch forked (a searchable or title Attribute), its words are still
|
|
568
|
+
indexed there, so only callers cleared for it search that Kind."""
|
|
569
|
+
readable = []
|
|
570
|
+
for kind in target.kinds:
|
|
571
|
+
stale = [
|
|
572
|
+
a
|
|
573
|
+
for a in kind.attributes.values()
|
|
574
|
+
if (a.searchable or a.id == kind.title_attribute)
|
|
575
|
+
and not a.is_sensitive
|
|
576
|
+
and point.masking.hidden(a, (kind.id,))
|
|
577
|
+
]
|
|
578
|
+
if not stale:
|
|
579
|
+
readable.append(kind)
|
|
580
|
+
return Target(target.name, tuple(readable), target.trait)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _embedded_readable(r: Any, point: ReadPoint, target: Target) -> ColumnElement[bool]:
|
|
584
|
+
"""Kinds whose vectors embed only values the caller may read: a Kind that embeds a
|
|
585
|
+
sensitive Attribute (`search.embedding_sensitive`) ranks by it only for cleared callers."""
|
|
586
|
+
readable = []
|
|
587
|
+
for kind in target.kinds:
|
|
588
|
+
try:
|
|
589
|
+
template = kind_template(kind)
|
|
590
|
+
except TemplateError:
|
|
591
|
+
template = None
|
|
592
|
+
attributes = [] if template is None else template.attributes.values()
|
|
593
|
+
if not any(
|
|
594
|
+
point.masking.hidden(kind.attributes[a], (kind.id,))
|
|
595
|
+
for a in attributes
|
|
596
|
+
if a in kind.attributes
|
|
597
|
+
):
|
|
598
|
+
readable.append(kind.id)
|
|
599
|
+
return r.c.kind_id.in_(readable) if readable else false()
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _semantic(
|
|
603
|
+
r: Any,
|
|
604
|
+
base: list[ColumnElement[bool]],
|
|
605
|
+
vector: list[float],
|
|
606
|
+
runtime: ReadRuntime,
|
|
607
|
+
target: Target,
|
|
608
|
+
*,
|
|
609
|
+
half: bool = False,
|
|
610
|
+
) -> Select[Any]:
|
|
611
|
+
"""Records ranked by the vector stored for their object hash under the fingerprint of
|
|
612
|
+
their Kind's embedding template in the searched branch's schema: another branch's vector
|
|
613
|
+
for the same content, rendered by a different template, is never used. With `half`, the
|
|
614
|
+
distance is computed in half precision, as the HNSW index of the storage profile holds it
|
|
615
|
+
(`lorebase_store.embeddings.index_precision`)."""
|
|
616
|
+
dims = runtime.store_settings.embeddings.dims
|
|
617
|
+
fingerprint = fingerprint_of(r.c.kind_id, template_fingerprints(target.kinds))
|
|
618
|
+
e = EMBEDDINGS.alias("e")
|
|
619
|
+
kind = HALFVEC(dims) if half else VECTOR(dims)
|
|
620
|
+
distance = cast(e.c.embedding, kind).op("<=>", return_type=Float)(literal(vector, kind))
|
|
621
|
+
return (
|
|
622
|
+
select(r.c.kind_id, r.c.key, (literal(1.0, Float) - distance).label("score"))
|
|
623
|
+
.join(
|
|
624
|
+
e,
|
|
625
|
+
and_(
|
|
626
|
+
e.c.domain_id == r.c.domain_id,
|
|
627
|
+
e.c.object_hash == r.c.object_hash,
|
|
628
|
+
e.c.model == literal(runtime.search.semantic.model),
|
|
629
|
+
e.c.fingerprint == fingerprint,
|
|
630
|
+
e.c.dims == literal(dims, literal_execute=True),
|
|
631
|
+
),
|
|
632
|
+
)
|
|
633
|
+
.where(*base)
|
|
634
|
+
.order_by(distance, r.c.kind_id, r.c.key)
|
|
635
|
+
.limit(runtime.search.candidates)
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _fused(lists: list[tuple[Select[Any], float]], runtime: ReadRuntime) -> Any:
|
|
640
|
+
"""Reciprocal rank fusion of the ranked lists (full outer join on the Record)."""
|
|
641
|
+
k = runtime.search.hybrid.rrf_k
|
|
642
|
+
ranked = []
|
|
643
|
+
for index, (candidates, weight) in enumerate(lists):
|
|
644
|
+
inner = candidates.subquery(f"c{index}")
|
|
645
|
+
exact_first = [inner.c[c].desc() for c in ("full", "exact") if c in inner.c]
|
|
646
|
+
position = over(
|
|
647
|
+
func.row_number(),
|
|
648
|
+
order_by=(*exact_first, inner.c.score.desc(), inner.c.kind_id, inner.c.key),
|
|
649
|
+
)
|
|
650
|
+
carried = [inner.c.full] if "full" in inner.c else []
|
|
651
|
+
ranked.append(
|
|
652
|
+
(
|
|
653
|
+
select(inner.c.kind_id, inner.c.key, position.label("rank"), *carried).subquery(
|
|
654
|
+
f"l{index}"
|
|
655
|
+
),
|
|
656
|
+
weight,
|
|
657
|
+
)
|
|
658
|
+
)
|
|
659
|
+
(first, w_first), (second, w_second) = ranked
|
|
660
|
+
full = [ranks.c.full for ranks, _ in ranked if "full" in ranks.c]
|
|
661
|
+
score = func.coalesce(literal(w_first, Float) / (literal(k) + first.c["rank"]), 0.0) + (
|
|
662
|
+
func.coalesce(literal(w_second, Float) / (literal(k) + second.c["rank"]), 0.0)
|
|
663
|
+
)
|
|
664
|
+
return (
|
|
665
|
+
select(
|
|
666
|
+
func.coalesce(first.c.kind_id, second.c.kind_id).label("kind_id"),
|
|
667
|
+
func.coalesce(first.c.key, second.c.key).label("key"),
|
|
668
|
+
cast(score, Float).label("score"),
|
|
669
|
+
*([func.coalesce(*full).label("full")] if full else []),
|
|
670
|
+
)
|
|
671
|
+
.select_from(
|
|
672
|
+
first.join(
|
|
673
|
+
second,
|
|
674
|
+
and_(first.c.kind_id == second.c.kind_id, first.c.key == second.c.key),
|
|
675
|
+
full=True,
|
|
676
|
+
)
|
|
677
|
+
)
|
|
678
|
+
.subquery("ranked")
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _snippet_title(rows: Any, target: Target, masking: Masking) -> ColumnElement[Any]:
|
|
683
|
+
"""The stored title in snippets, except for Kinds whose title Attribute is sensitive (by
|
|
684
|
+
the default branch's tags too): a branch forked before the tag may still store it."""
|
|
685
|
+
hidden = [
|
|
686
|
+
kind.id
|
|
687
|
+
for kind in target.kinds
|
|
688
|
+
if kind.title_attribute in kind.attributes
|
|
689
|
+
and masking.sensitive(kind.attributes[kind.title_attribute])
|
|
690
|
+
]
|
|
691
|
+
title: ColumnElement[Any] = rows.c.title
|
|
692
|
+
if not hidden:
|
|
693
|
+
return title
|
|
694
|
+
return case((rows.c.kind_id.in_(hidden), null()), else_=title)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _searchable_text(rows: Any, target: Target, masking: Masking) -> list[ColumnElement[Any]]:
|
|
698
|
+
"""Text of the snippets: searchable Attributes that are not sensitive (by the tags of the
|
|
699
|
+
default branch too)."""
|
|
700
|
+
attributes = sorted(
|
|
701
|
+
{
|
|
702
|
+
a.id
|
|
703
|
+
for kind in target.kinds
|
|
704
|
+
for a in kind.attributes.values()
|
|
705
|
+
if a.searchable and not masking.sensitive(a)
|
|
706
|
+
}
|
|
707
|
+
)
|
|
708
|
+
return [json_text(rows.c["values"], attribute) for attribute in attributes]
|