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 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())