scrydb 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scrydb/__init__.py +12 -0
- scrydb/cli.py +307 -0
- scrydb/core.py +1317 -0
- scrydb-0.2.0.dist-info/METADATA +260 -0
- scrydb-0.2.0.dist-info/RECORD +9 -0
- scrydb-0.2.0.dist-info/WHEEL +5 -0
- scrydb-0.2.0.dist-info/entry_points.txt +2 -0
- scrydb-0.2.0.dist-info/licenses/LICENSE +21 -0
- scrydb-0.2.0.dist-info/top_level.txt +1 -0
scrydb/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""scrydb — lexical, semantic, and hybrid search built on SQLite."""
|
|
2
|
+
|
|
3
|
+
from .core import Index, Run, SearchResult, SentenceEmbedding
|
|
4
|
+
|
|
5
|
+
__all__ = ["Index", "Run", "SearchResult", "SentenceEmbedding"]
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from importlib.metadata import version as _version
|
|
9
|
+
|
|
10
|
+
__version__ = _version("scrydb")
|
|
11
|
+
except Exception: # pragma: no cover - package not installed
|
|
12
|
+
__version__ = "0.0.0"
|
scrydb/cli.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""
|
|
2
|
+
scrydb command-line interface.
|
|
3
|
+
|
|
4
|
+
Thin argparse wrapper around the ``Index``/``Run`` API (see
|
|
5
|
+
:mod:`scrydb.core`) so scrydb can be indexed, searched, and batch-searched
|
|
6
|
+
without writing Python. This is what the Docker image (see ``../../Dockerfile``
|
|
7
|
+
at the repo root) runs by default -- it lets a mounted SQLite index be built
|
|
8
|
+
and queried from a container alone, no local install required.
|
|
9
|
+
|
|
10
|
+
Every option can be set via flag or the matching ``SCRYDB_*`` environment
|
|
11
|
+
variable (flags win); the Docker image relies on the latter since ``docker
|
|
12
|
+
run -e`` is the natural way to configure a container.
|
|
13
|
+
|
|
14
|
+
Subcommands
|
|
15
|
+
-----------
|
|
16
|
+
scrydb index -- index documents and/or queries into a database
|
|
17
|
+
scrydb search -- run one ad-hoc query, print JSON results
|
|
18
|
+
scrydb batch-search -- run every stored query, write a TREC run file
|
|
19
|
+
scrydb auto -- environment-driven pipeline: index whatever
|
|
20
|
+
input files are present, then batch-search
|
|
21
|
+
or run a single ad-hoc query -- the Docker
|
|
22
|
+
image's default command
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from .core import Index, SearchResult, SentenceEmbedding
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _env(name: str, default: "str | None" = None) -> "str | None":
|
|
37
|
+
value = os.environ.get(name)
|
|
38
|
+
return value if value else default
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _rerank_type(value: str):
|
|
42
|
+
normalized = value.lower()
|
|
43
|
+
if normalized in ("none", "false", ""):
|
|
44
|
+
return False
|
|
45
|
+
if normalized in ("binary", "int8", "float", "hamming", "cosine"):
|
|
46
|
+
return normalized
|
|
47
|
+
if normalized == "true":
|
|
48
|
+
return True
|
|
49
|
+
raise argparse.ArgumentTypeError(
|
|
50
|
+
f"invalid rerank {value!r}; expected none, binary, int8, float "
|
|
51
|
+
"(or the legacy aliases hamming/cosine)"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _result_to_dict(result: SearchResult) -> dict:
|
|
56
|
+
return dict(result)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _print_results(results) -> None:
|
|
60
|
+
print(json.dumps([_result_to_dict(r) for r in results], ensure_ascii=False, indent=2, default=str))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _open_index(db: str, model_name: "str | None") -> Index:
|
|
64
|
+
index = Index.open(db)
|
|
65
|
+
if model_name:
|
|
66
|
+
index.add_model(SentenceEmbedding(model_name=model_name))
|
|
67
|
+
return index
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ===========================================================================
|
|
71
|
+
# Subcommands
|
|
72
|
+
# ===========================================================================
|
|
73
|
+
|
|
74
|
+
def cmd_index(args: argparse.Namespace) -> int:
|
|
75
|
+
if not args.documents and not args.queries:
|
|
76
|
+
print("scrydb index: nothing to do -- pass --documents and/or --queries", file=sys.stderr)
|
|
77
|
+
return 1
|
|
78
|
+
with _open_index(args.db, args.model) as index:
|
|
79
|
+
if args.documents:
|
|
80
|
+
print(f"scrydb: indexing documents from {args.documents} -> {args.db}", file=sys.stderr)
|
|
81
|
+
index.index_documents(
|
|
82
|
+
args.documents,
|
|
83
|
+
id_field=args.doc_id_field or args.id_field,
|
|
84
|
+
text_field=args.text_field,
|
|
85
|
+
embedding_field=args.embedding_field,
|
|
86
|
+
store_int8_embeddings=args.store_int8,
|
|
87
|
+
)
|
|
88
|
+
if args.queries:
|
|
89
|
+
print(f"scrydb: indexing queries from {args.queries} -> {args.db}", file=sys.stderr)
|
|
90
|
+
index.index_queries(
|
|
91
|
+
args.queries,
|
|
92
|
+
id_field=args.query_id_field or args.id_field,
|
|
93
|
+
text_field=args.text_field,
|
|
94
|
+
embedding_field=args.embedding_field,
|
|
95
|
+
store_int8_embeddings=args.store_int8,
|
|
96
|
+
)
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
101
|
+
with _open_index(args.db, args.model) as index:
|
|
102
|
+
results = index.search(
|
|
103
|
+
args.query, mode=args.mode, top_k=args.top_k, rerank=args.rerank, precision=args.precision
|
|
104
|
+
)
|
|
105
|
+
_print_results(results)
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cmd_batch_search(args: argparse.Namespace) -> int:
|
|
110
|
+
with _open_index(args.db, args.model) as index:
|
|
111
|
+
run = _run_batch_search(index, args)
|
|
112
|
+
return 0 if run is not None else 1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _run_batch_search(index: Index, args: argparse.Namespace):
|
|
116
|
+
if len(index.queries) == 0:
|
|
117
|
+
print(f"scrydb: index {args.db!r} has no stored queries -- nothing to batch-search", file=sys.stderr)
|
|
118
|
+
return None
|
|
119
|
+
print(
|
|
120
|
+
f"scrydb: batch-searching {len(index.queries)} stored queries "
|
|
121
|
+
f"(mode={args.mode!r}, precision={args.precision!r}, rerank={args.rerank!r})",
|
|
122
|
+
file=sys.stderr,
|
|
123
|
+
)
|
|
124
|
+
run = index.batch_search(
|
|
125
|
+
mode=args.mode, top_k=args.top_k, rerank=args.rerank, precision=args.precision
|
|
126
|
+
)
|
|
127
|
+
out = Path(args.output)
|
|
128
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
run.write_trec(out, tag=args.tag)
|
|
130
|
+
n_hits = sum(len(hits) for hits in run.values())
|
|
131
|
+
print(f"scrydb: wrote {n_hits} results over {len(run)} queries to {out}", file=sys.stderr)
|
|
132
|
+
return run
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_auto(args: argparse.Namespace) -> int:
|
|
136
|
+
"""Environment-driven pipeline for the Docker image's default command:
|
|
137
|
+
index whatever input files are present, then either answer one ad-hoc
|
|
138
|
+
query or batch-search whatever queries ended up stored -- so the same
|
|
139
|
+
invocation handles "index + search fresh data" and "query an existing
|
|
140
|
+
index" without the caller having to pick a subcommand."""
|
|
141
|
+
documents = args.documents if args.documents and Path(args.documents).is_file() else None
|
|
142
|
+
queries = args.queries if args.queries and Path(args.queries).is_file() else None
|
|
143
|
+
|
|
144
|
+
with _open_index(args.db, args.model) as index:
|
|
145
|
+
if documents:
|
|
146
|
+
print(f"scrydb: indexing documents from {documents} -> {args.db}", file=sys.stderr)
|
|
147
|
+
index.index_documents(
|
|
148
|
+
documents,
|
|
149
|
+
id_field=args.doc_id_field or args.id_field,
|
|
150
|
+
text_field=args.text_field,
|
|
151
|
+
embedding_field=args.embedding_field,
|
|
152
|
+
store_int8_embeddings=args.store_int8,
|
|
153
|
+
)
|
|
154
|
+
if queries:
|
|
155
|
+
print(f"scrydb: indexing queries from {queries} -> {args.db}", file=sys.stderr)
|
|
156
|
+
index.index_queries(
|
|
157
|
+
queries,
|
|
158
|
+
id_field=args.query_id_field or args.id_field,
|
|
159
|
+
text_field=args.text_field,
|
|
160
|
+
embedding_field=args.embedding_field,
|
|
161
|
+
store_int8_embeddings=args.store_int8,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if args.query:
|
|
165
|
+
results = index.search(
|
|
166
|
+
args.query, mode=args.mode, top_k=args.top_k, rerank=args.rerank, precision=args.precision
|
|
167
|
+
)
|
|
168
|
+
_print_results(results)
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
if len(index.queries) > 0:
|
|
172
|
+
return 0 if _run_batch_search(index, args) is not None else 1
|
|
173
|
+
|
|
174
|
+
print(
|
|
175
|
+
f"scrydb: nothing to do -- {index!r}\n"
|
|
176
|
+
" Mount documents/queries JSONL at the paths given by --documents/--queries\n"
|
|
177
|
+
" (env: SCRYDB_DOCUMENTS/SCRYDB_QUERIES) to index them, set --query/SCRYDB_QUERY\n"
|
|
178
|
+
" for a one-off search, or store queries first (`scrydb index --queries ...`)\n"
|
|
179
|
+
" to enable a batch run. See `docker run --rm scrydb --help`.",
|
|
180
|
+
file=sys.stderr,
|
|
181
|
+
)
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ===========================================================================
|
|
186
|
+
# argparse wiring
|
|
187
|
+
# ===========================================================================
|
|
188
|
+
|
|
189
|
+
def _add_db_arg(parser: argparse.ArgumentParser) -> None:
|
|
190
|
+
parser.add_argument(
|
|
191
|
+
"--db", default=_env("SCRYDB_DB", "index.db"),
|
|
192
|
+
help="Path to the SQLite index file (env: SCRYDB_DB; default: %(default)s)",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _add_model_arg(parser: argparse.ArgumentParser) -> None:
|
|
197
|
+
parser.add_argument(
|
|
198
|
+
"--model", default=_env("SCRYDB_MODEL"),
|
|
199
|
+
help="sentence-transformers model name for on-the-fly embedding "
|
|
200
|
+
"(env: SCRYDB_MODEL). Omit to rely on precomputed embedding fields, "
|
|
201
|
+
"or for lexical-only search.",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _add_field_args(parser: argparse.ArgumentParser) -> None:
|
|
206
|
+
parser.add_argument(
|
|
207
|
+
"--id-field", default=_env("SCRYDB_ID_FIELD", "id"),
|
|
208
|
+
help="Id field used for both documents and queries, unless overridden by "
|
|
209
|
+
"--doc-id-field/--query-id-field (env: SCRYDB_ID_FIELD)",
|
|
210
|
+
)
|
|
211
|
+
parser.add_argument(
|
|
212
|
+
"--doc-id-field", default=_env("SCRYDB_DOC_ID_FIELD"),
|
|
213
|
+
help="Id field for documents only, e.g. 'docid'; overrides --id-field (env: SCRYDB_DOC_ID_FIELD)",
|
|
214
|
+
)
|
|
215
|
+
parser.add_argument(
|
|
216
|
+
"--query-id-field", default=_env("SCRYDB_QUERY_ID_FIELD"),
|
|
217
|
+
help="Id field for queries only, e.g. 'qid'; overrides --id-field (env: SCRYDB_QUERY_ID_FIELD)",
|
|
218
|
+
)
|
|
219
|
+
parser.add_argument("--text-field", default=_env("SCRYDB_TEXT_FIELD", "text"), help="env: SCRYDB_TEXT_FIELD")
|
|
220
|
+
parser.add_argument(
|
|
221
|
+
"--embedding-field", default=_env("SCRYDB_EMBEDDING_FIELD", "emb"), help="env: SCRYDB_EMBEDDING_FIELD"
|
|
222
|
+
)
|
|
223
|
+
parser.add_argument(
|
|
224
|
+
"--store-int8", action="store_true", default=_env("SCRYDB_STORE_INT8", "") not in ("", "0", "false", "False"),
|
|
225
|
+
help="Also store int8-quantized embeddings, in addition to the always-stored binary "
|
|
226
|
+
"embeddings and (by default) full-precision embeddings (env: SCRYDB_STORE_INT8)",
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _add_search_args(parser: argparse.ArgumentParser) -> None:
|
|
231
|
+
parser.add_argument(
|
|
232
|
+
"--mode", choices=["lexical", "semantic", "hybrid"], default=_env("SCRYDB_MODE", "lexical"),
|
|
233
|
+
help="env: SCRYDB_MODE (default: %(default)s)",
|
|
234
|
+
)
|
|
235
|
+
parser.add_argument(
|
|
236
|
+
"--precision", choices=["binary", "int8", "float"], default=_env("SCRYDB_PRECISION", "binary"),
|
|
237
|
+
help="Vector precision for mode=semantic/hybrid's semantic side "
|
|
238
|
+
"(env: SCRYDB_PRECISION, default: %(default)s)",
|
|
239
|
+
)
|
|
240
|
+
parser.add_argument(
|
|
241
|
+
"--rerank", type=_rerank_type, default=_rerank_type(_env("SCRYDB_RERANK", "none")),
|
|
242
|
+
help="none, binary, int8, or float (legacy aliases hamming/cosine also accepted) "
|
|
243
|
+
"(env: SCRYDB_RERANK, default: none)",
|
|
244
|
+
)
|
|
245
|
+
parser.add_argument(
|
|
246
|
+
"--top-k", type=int, default=int(_env("SCRYDB_TOP_K", "10")), help="env: SCRYDB_TOP_K (default: %(default)s)"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
251
|
+
parser = argparse.ArgumentParser(
|
|
252
|
+
prog="scrydb", description="Lexical, semantic, and hybrid search over a SQLite index."
|
|
253
|
+
)
|
|
254
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
255
|
+
|
|
256
|
+
p_index = sub.add_parser("index", help="Index documents and/or queries into a database")
|
|
257
|
+
_add_db_arg(p_index)
|
|
258
|
+
_add_model_arg(p_index)
|
|
259
|
+
_add_field_args(p_index)
|
|
260
|
+
p_index.add_argument("--documents", default=_env("SCRYDB_DOCUMENTS"), help="JSONL path (env: SCRYDB_DOCUMENTS)")
|
|
261
|
+
p_index.add_argument("--queries", default=_env("SCRYDB_QUERIES"), help="JSONL path (env: SCRYDB_QUERIES)")
|
|
262
|
+
p_index.set_defaults(func=cmd_index)
|
|
263
|
+
|
|
264
|
+
p_search = sub.add_parser("search", help="Run one ad-hoc query, print JSON results")
|
|
265
|
+
_add_db_arg(p_search)
|
|
266
|
+
_add_model_arg(p_search)
|
|
267
|
+
_add_search_args(p_search)
|
|
268
|
+
p_search.add_argument("query")
|
|
269
|
+
p_search.set_defaults(func=cmd_search)
|
|
270
|
+
|
|
271
|
+
p_batch = sub.add_parser("batch-search", help="Run every stored query, write a TREC run file")
|
|
272
|
+
_add_db_arg(p_batch)
|
|
273
|
+
_add_model_arg(p_batch)
|
|
274
|
+
_add_search_args(p_batch)
|
|
275
|
+
p_batch.add_argument("--output", default=_env("SCRYDB_OUTPUT", "run.trec"), help="env: SCRYDB_OUTPUT")
|
|
276
|
+
p_batch.add_argument("--tag", default=_env("SCRYDB_TAG", "scrydb"), help="env: SCRYDB_TAG")
|
|
277
|
+
p_batch.set_defaults(func=cmd_batch_search)
|
|
278
|
+
|
|
279
|
+
p_auto = sub.add_parser(
|
|
280
|
+
"auto",
|
|
281
|
+
help="Environment-driven pipeline: index what's present, then batch-search or "
|
|
282
|
+
"ad-hoc search (Docker image default)",
|
|
283
|
+
)
|
|
284
|
+
_add_db_arg(p_auto)
|
|
285
|
+
_add_model_arg(p_auto)
|
|
286
|
+
_add_field_args(p_auto)
|
|
287
|
+
_add_search_args(p_auto)
|
|
288
|
+
p_auto.add_argument(
|
|
289
|
+
"--documents", default=_env("SCRYDB_DOCUMENTS", "documents.jsonl"), help="env: SCRYDB_DOCUMENTS"
|
|
290
|
+
)
|
|
291
|
+
p_auto.add_argument("--queries", default=_env("SCRYDB_QUERIES", "queries.jsonl"), help="env: SCRYDB_QUERIES")
|
|
292
|
+
p_auto.add_argument("--query", default=_env("SCRYDB_QUERY"), help="One-off ad-hoc query text (env: SCRYDB_QUERY)")
|
|
293
|
+
p_auto.add_argument("--output", default=_env("SCRYDB_OUTPUT", "run.trec"), help="env: SCRYDB_OUTPUT")
|
|
294
|
+
p_auto.add_argument("--tag", default=_env("SCRYDB_TAG", "scrydb"), help="env: SCRYDB_TAG")
|
|
295
|
+
p_auto.set_defaults(func=cmd_auto)
|
|
296
|
+
|
|
297
|
+
return parser
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def main(argv: "list[str] | None" = None) -> int:
|
|
301
|
+
parser = build_parser()
|
|
302
|
+
args = parser.parse_args(argv)
|
|
303
|
+
return args.func(args)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
if __name__ == "__main__":
|
|
307
|
+
raise SystemExit(main())
|