ragx-cli 0.1.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.
- ragx/__init__.py +1 -0
- ragx/cli/__init__.py +0 -0
- ragx/cli/app.py +105 -0
- ragx/cli/eval_cmd.py +74 -0
- ragx/cli/inspect_cmd.py +122 -0
- ragx/cli/output.py +19 -0
- ragx/cli/pipeline.py +93 -0
- ragx/core/__init__.py +0 -0
- ragx/core/chunking.py +183 -0
- ragx/core/config.py +130 -0
- ragx/core/discovery.py +79 -0
- ragx/core/errors.py +10 -0
- ragx/core/eval.py +93 -0
- ragx/core/expansion.py +89 -0
- ragx/core/fusion.py +14 -0
- ragx/core/graph.py +37 -0
- ragx/core/indexer.py +118 -0
- ragx/core/models.py +67 -0
- ragx/core/query.py +181 -0
- ragx/core/scoring.py +48 -0
- ragx/core/store.py +161 -0
- ragx/core/traversal.py +68 -0
- ragx/core/vectors.py +105 -0
- ragx/providers/__init__.py +0 -0
- ragx/providers/base.py +46 -0
- ragx/providers/openai_compat.py +117 -0
- ragx/providers/registry.py +81 -0
- ragx/providers/st_reranker.py +28 -0
- ragx_cli-0.1.0.dist-info/METADATA +297 -0
- ragx_cli-0.1.0.dist-info/RECORD +33 -0
- ragx_cli-0.1.0.dist-info/WHEEL +4 -0
- ragx_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ragx_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
ragx/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
ragx/cli/__init__.py
ADDED
|
File without changes
|
ragx/cli/app.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""ragx CLI shell: init, status, config. index/query are wired in a later pass."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from ragx.cli.output import emit_json, fail
|
|
12
|
+
from ragx.core.config import Config, RAGX_DIR, db_path, require_root, write_default_config
|
|
13
|
+
from ragx.core.errors import RagxError
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(add_completion=False, no_args_is_help=True)
|
|
16
|
+
config_app = typer.Typer(add_completion=False, no_args_is_help=True)
|
|
17
|
+
app.add_typer(config_app, name="config")
|
|
18
|
+
|
|
19
|
+
from ragx.cli import eval_cmd, inspect_cmd, pipeline # noqa: E402 (needs `app` defined above)
|
|
20
|
+
|
|
21
|
+
pipeline.register(app)
|
|
22
|
+
inspect_cmd.register(app)
|
|
23
|
+
eval_cmd.register(app)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _require_root() -> Path:
|
|
27
|
+
try:
|
|
28
|
+
return require_root()
|
|
29
|
+
except RagxError as exc:
|
|
30
|
+
fail(str(exc))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command()
|
|
34
|
+
def init(path: Path = typer.Argument(Path("."))) -> None:
|
|
35
|
+
"""Create .ragx/ with a default config at PATH (default: cwd)."""
|
|
36
|
+
root = path.resolve()
|
|
37
|
+
if (root / RAGX_DIR).exists():
|
|
38
|
+
fail(f"{root / RAGX_DIR} already exists")
|
|
39
|
+
cfg_path = write_default_config(root)
|
|
40
|
+
typer.echo(f"created {cfg_path}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _counts(root: Path) -> tuple[int, int, int]:
|
|
44
|
+
db = db_path(root)
|
|
45
|
+
if not db.exists():
|
|
46
|
+
return 0, 0, 0
|
|
47
|
+
try:
|
|
48
|
+
from ragx.core.store import Store
|
|
49
|
+
except ImportError:
|
|
50
|
+
return 0, 0, 0
|
|
51
|
+
with Store(db) as store:
|
|
52
|
+
return store.file_count(), store.chunk_count(), store.edge_count()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command()
|
|
56
|
+
def status(json_out: bool = typer.Option(False, "--json")) -> None:
|
|
57
|
+
"""Show corpus root, embedding config, and index counts."""
|
|
58
|
+
root = _require_root()
|
|
59
|
+
cfg = Config.load(root)
|
|
60
|
+
files, chunks, edges = _counts(root)
|
|
61
|
+
doc = {
|
|
62
|
+
"schema": "ragx.status.v1",
|
|
63
|
+
"root": str(root),
|
|
64
|
+
"embedding_provider": cfg.get("embeddings.provider"),
|
|
65
|
+
"embedding_model": cfg.get("embeddings.model"),
|
|
66
|
+
"files": files,
|
|
67
|
+
"chunks": chunks,
|
|
68
|
+
"edges": edges,
|
|
69
|
+
}
|
|
70
|
+
if json_out:
|
|
71
|
+
emit_json(doc)
|
|
72
|
+
else:
|
|
73
|
+
typer.echo(f"root: {doc['root']}")
|
|
74
|
+
typer.echo(f"embedding: {doc['embedding_provider']}/{doc['embedding_model']}")
|
|
75
|
+
typer.echo(f"files: {files} chunks: {chunks} edges: {edges}")
|
|
76
|
+
if files == 0 and chunks == 0 and edges == 0:
|
|
77
|
+
raise typer.Exit(code=1)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@config_app.command("get")
|
|
81
|
+
def config_get(key: str) -> None:
|
|
82
|
+
root = _require_root()
|
|
83
|
+
cfg = Config.load(root)
|
|
84
|
+
try:
|
|
85
|
+
value = cfg.get(key)
|
|
86
|
+
except RagxError as exc:
|
|
87
|
+
fail(str(exc))
|
|
88
|
+
typer.echo(str(value))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@config_app.command("set")
|
|
92
|
+
def config_set(key: str, value: str) -> None:
|
|
93
|
+
root = _require_root()
|
|
94
|
+
cfg = Config.load(root)
|
|
95
|
+
try:
|
|
96
|
+
cfg.set(key, value)
|
|
97
|
+
except RagxError as exc:
|
|
98
|
+
fail(str(exc))
|
|
99
|
+
cfg.save(root)
|
|
100
|
+
typer.echo(f"{key} = {cfg.get(key)}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main() -> None:
|
|
104
|
+
logging.basicConfig(level=logging.INFO, stream=sys.stderr, format="%(levelname)s: %(message)s")
|
|
105
|
+
app()
|
ragx/cli/eval_cmd.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""`ragx eval` command. Registered onto the main app by app.py (integration-owned)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ragx.cli.output import emit_json, fail
|
|
11
|
+
from ragx.core.config import Config, require_root
|
|
12
|
+
from ragx.core.errors import RagxError
|
|
13
|
+
from ragx.core.eval import evaluate, load_queries
|
|
14
|
+
from ragx.core.query import QueryOptions, run_query
|
|
15
|
+
from ragx.providers.registry import make_embedder, make_generator, make_reranker
|
|
16
|
+
|
|
17
|
+
_BUILTIN_CONFIGS: dict[str, QueryOptions] = {
|
|
18
|
+
"baseline": QueryOptions(expand=False, graph=False, rerank=False),
|
|
19
|
+
"graph": QueryOptions(expand=False, graph=True, rerank=False),
|
|
20
|
+
"rerank": QueryOptions(expand=False, graph=True, rerank=True),
|
|
21
|
+
"full": QueryOptions(expand=True, graph=True, rerank=True),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def register(app: typer.Typer) -> None:
|
|
26
|
+
app.command("eval")(eval_cmd)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def eval_cmd(
|
|
30
|
+
queries_file: Path = typer.Argument(..., help="path to a queries.jsonl file"),
|
|
31
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
32
|
+
top: int = typer.Option(10, "--top"),
|
|
33
|
+
configs: str = typer.Option("baseline,graph,full", "--configs"),
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Evaluate retrieval quality (recall@5/@10, MRR) against labeled queries."""
|
|
36
|
+
try:
|
|
37
|
+
queries = load_queries(queries_file)
|
|
38
|
+
except RagxError as exc:
|
|
39
|
+
fail(str(exc))
|
|
40
|
+
|
|
41
|
+
names = [n.strip() for n in configs.split(",") if n.strip()]
|
|
42
|
+
unknown = [n for n in names if n not in _BUILTIN_CONFIGS]
|
|
43
|
+
if unknown:
|
|
44
|
+
fail(f"unknown config(s): {', '.join(unknown)}")
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
root = require_root()
|
|
48
|
+
cfg = Config.load(root)
|
|
49
|
+
embedder = make_embedder(cfg)
|
|
50
|
+
generator = make_generator(cfg) if any(_BUILTIN_CONFIGS[n].expand for n in names) else None
|
|
51
|
+
reranker = make_reranker(cfg) if any(_BUILTIN_CONFIGS[n].rerank for n in names) else None
|
|
52
|
+
except RagxError as exc:
|
|
53
|
+
fail(str(exc))
|
|
54
|
+
|
|
55
|
+
def query_fn(text: str, opts: QueryOptions):
|
|
56
|
+
return run_query(
|
|
57
|
+
root, cfg, embedder, text, opts,
|
|
58
|
+
generator=generator if opts.expand else None,
|
|
59
|
+
reranker=reranker if opts.rerank else None,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# retrieve chunks deep enough that file-level ranking has >= `top` files to rank
|
|
63
|
+
selected = [
|
|
64
|
+
(name, replace(_BUILTIN_CONFIGS[name], top=top * 3)) for name in names
|
|
65
|
+
]
|
|
66
|
+
result = evaluate(queries, selected, query_fn, top=top)
|
|
67
|
+
|
|
68
|
+
if json_out:
|
|
69
|
+
emit_json(result)
|
|
70
|
+
return
|
|
71
|
+
header = f"{'config':<12}{'recall@5':>10}{'recall@10':>10}{'mrr':>10}"
|
|
72
|
+
typer.echo(header)
|
|
73
|
+
for c in result["configs"]:
|
|
74
|
+
typer.echo(f"{c['name']:<12}{c['recall_at_5']:>10.4f}{c['recall_at_10']:>10.4f}{c['mrr']:>10.4f}")
|
ragx/cli/inspect_cmd.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""ragx inspect: read-only introspection into the store (chunks, files, neighbors).
|
|
2
|
+
|
|
3
|
+
No embeddings or vector index needed — everything here reads Store directly.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ragx.cli.output import emit_json, fail
|
|
11
|
+
from ragx.core.config import db_path, require_root
|
|
12
|
+
from ragx.core.errors import RagxError
|
|
13
|
+
from ragx.core.store import Store
|
|
14
|
+
|
|
15
|
+
inspect_app = typer.Typer(add_completion=False, no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register(app: typer.Typer) -> None:
|
|
19
|
+
app.add_typer(inspect_app, name="inspect")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _open_store() -> Store:
|
|
23
|
+
try:
|
|
24
|
+
root = require_root()
|
|
25
|
+
except RagxError as exc:
|
|
26
|
+
fail(str(exc))
|
|
27
|
+
return Store(db_path(root))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _neighbor_info(store: Store, chunk_id: int) -> list[dict]:
|
|
31
|
+
raw = store.neighbors(chunk_id)
|
|
32
|
+
others = {c.id: c for c in store.get_chunks([other_id for other_id, _ in raw])}
|
|
33
|
+
return [
|
|
34
|
+
{
|
|
35
|
+
"id": other_id,
|
|
36
|
+
"weight": round(weight, 6),
|
|
37
|
+
"file": others[other_id].file_path if other_id in others else None,
|
|
38
|
+
"line_start": others[other_id].line_start if other_id in others else None,
|
|
39
|
+
"line_end": others[other_id].line_end if other_id in others else None,
|
|
40
|
+
}
|
|
41
|
+
for other_id, weight in raw
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@inspect_app.command("chunk")
|
|
46
|
+
def inspect_chunk(chunk_id: int, json_out: bool = typer.Option(False, "--json")) -> None:
|
|
47
|
+
"""Show a chunk's text, location, and edges."""
|
|
48
|
+
with _open_store() as store:
|
|
49
|
+
chunks = store.get_chunks([chunk_id])
|
|
50
|
+
if not chunks:
|
|
51
|
+
fail(f"unknown chunk id: {chunk_id}")
|
|
52
|
+
chunk = chunks[0]
|
|
53
|
+
edges = _neighbor_info(store, chunk_id)
|
|
54
|
+
doc = {
|
|
55
|
+
"schema": "ragx.inspect.chunk.v1",
|
|
56
|
+
"id": chunk.id,
|
|
57
|
+
"file": chunk.file_path,
|
|
58
|
+
"byte_start": chunk.byte_start,
|
|
59
|
+
"byte_end": chunk.byte_end,
|
|
60
|
+
"line_start": chunk.line_start,
|
|
61
|
+
"line_end": chunk.line_end,
|
|
62
|
+
"text": chunk.text,
|
|
63
|
+
"edges": edges,
|
|
64
|
+
}
|
|
65
|
+
if json_out:
|
|
66
|
+
emit_json(doc)
|
|
67
|
+
else:
|
|
68
|
+
typer.echo(f"chunk {chunk.id} {chunk.file_path}:{chunk.line_start}-{chunk.line_end}")
|
|
69
|
+
typer.echo(chunk.text)
|
|
70
|
+
typer.echo(f"edges ({len(edges)}):")
|
|
71
|
+
for e in edges:
|
|
72
|
+
typer.echo(f" {e['id']} {e['weight']:.4f} {e['file']}")
|
|
73
|
+
if not edges:
|
|
74
|
+
raise typer.Exit(code=1)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@inspect_app.command("file")
|
|
78
|
+
def inspect_file(path: str, json_out: bool = typer.Option(False, "--json")) -> None:
|
|
79
|
+
"""Show a file's record and its chunks."""
|
|
80
|
+
with _open_store() as store:
|
|
81
|
+
hashes = store.get_file_hashes()
|
|
82
|
+
if path not in hashes:
|
|
83
|
+
fail(f"unknown file: {path}")
|
|
84
|
+
ids = store.chunk_ids_for_file(path)
|
|
85
|
+
chunks = store.get_chunks(ids)
|
|
86
|
+
chunk_docs = [
|
|
87
|
+
{"id": c.id, "line_start": c.line_start, "line_end": c.line_end, "preview": c.text[:80]}
|
|
88
|
+
for c in chunks
|
|
89
|
+
]
|
|
90
|
+
doc = {
|
|
91
|
+
"schema": "ragx.inspect.file.v1",
|
|
92
|
+
"path": path,
|
|
93
|
+
"content_hash": hashes[path],
|
|
94
|
+
"chunk_count": len(chunk_docs),
|
|
95
|
+
"chunks": chunk_docs,
|
|
96
|
+
}
|
|
97
|
+
if json_out:
|
|
98
|
+
emit_json(doc)
|
|
99
|
+
else:
|
|
100
|
+
typer.echo(f"file {path} ({doc['chunk_count']} chunks) hash={doc['content_hash']}")
|
|
101
|
+
for c in chunk_docs:
|
|
102
|
+
typer.echo(f" {c['id']} {c['line_start']}-{c['line_end']} {c['preview']}")
|
|
103
|
+
if not chunk_docs:
|
|
104
|
+
raise typer.Exit(code=1)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@inspect_app.command("neighbors")
|
|
108
|
+
def inspect_neighbors(chunk_id: int, json_out: bool = typer.Option(False, "--json")) -> None:
|
|
109
|
+
"""Show a chunk's neighbors enriched with file/line info."""
|
|
110
|
+
with _open_store() as store:
|
|
111
|
+
if not store.get_chunks([chunk_id]):
|
|
112
|
+
fail(f"unknown chunk id: {chunk_id}")
|
|
113
|
+
neighbors = _neighbor_info(store, chunk_id)
|
|
114
|
+
doc = {"schema": "ragx.inspect.neighbors.v1", "id": chunk_id, "neighbors": neighbors}
|
|
115
|
+
if json_out:
|
|
116
|
+
emit_json(doc)
|
|
117
|
+
else:
|
|
118
|
+
typer.echo(f"neighbors of {chunk_id}:")
|
|
119
|
+
for n in neighbors:
|
|
120
|
+
typer.echo(f" {n['id']} {n['weight']:.4f} {n['file']}:{n['line_start']}-{n['line_end']}")
|
|
121
|
+
if not neighbors:
|
|
122
|
+
raise typer.Exit(code=1)
|
ragx/cli/output.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Stdout/exit conventions for the CLI. stdout is reserved for result payloads;
|
|
2
|
+
everything diagnostic goes to stderr (configured in app.main())."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import NoReturn
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def emit_json(doc: dict) -> None:
|
|
12
|
+
"""Write exactly one JSON document to stdout, nothing else on stdout."""
|
|
13
|
+
sys.stdout.write(json.dumps(doc) + "\n")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fail(msg: str, code: int = 2) -> NoReturn:
|
|
17
|
+
"""Print an error message to stderr and exit with `code`."""
|
|
18
|
+
print(msg, file=sys.stderr)
|
|
19
|
+
raise SystemExit(code)
|
ragx/cli/pipeline.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""`index` and `query` commands. Registered onto the main app by app.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ragx.cli.output import emit_json, fail
|
|
11
|
+
from ragx.core.config import Config, require_root
|
|
12
|
+
from ragx.core.errors import RagxError
|
|
13
|
+
from ragx.core.indexer import run_index
|
|
14
|
+
from ragx.core.query import QueryOptions, run_query, to_files_json, to_query_json
|
|
15
|
+
from ragx.providers.registry import make_embedder, make_generator, make_reranker
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register(app: typer.Typer) -> None:
|
|
19
|
+
app.command()(index)
|
|
20
|
+
app.command()(query)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def index(
|
|
24
|
+
path: Path | None = typer.Argument(None),
|
|
25
|
+
changed: bool = typer.Option(False, "--changed", help="only re-process new/modified/deleted files"),
|
|
26
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Chunk, embed, and index the corpus (full rebuild unless --changed)."""
|
|
29
|
+
try:
|
|
30
|
+
root = require_root(path)
|
|
31
|
+
cfg = Config.load(root)
|
|
32
|
+
stats = run_index(root, cfg, make_embedder(cfg), changed_only=changed)
|
|
33
|
+
except RagxError as exc:
|
|
34
|
+
fail(str(exc))
|
|
35
|
+
doc = {"schema": "ragx.index.v1", **stats.__dict__}
|
|
36
|
+
if json_out:
|
|
37
|
+
emit_json(doc)
|
|
38
|
+
else:
|
|
39
|
+
typer.echo(
|
|
40
|
+
f"indexed {stats.files_indexed} files ({stats.chunks_added} chunks), "
|
|
41
|
+
f"deleted {stats.files_deleted} ({stats.chunks_deleted} chunks), "
|
|
42
|
+
f"unchanged {stats.files_unchanged}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def query(
|
|
47
|
+
text: str = typer.Argument(..., help="query text, or '-' to read from stdin"),
|
|
48
|
+
top: int | None = typer.Option(None, "--top"),
|
|
49
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
50
|
+
files_only: bool = typer.Option(False, "--files-only"),
|
|
51
|
+
no_expand: bool = typer.Option(False, "--no-expand"),
|
|
52
|
+
no_graph: bool = typer.Option(False, "--no-graph"),
|
|
53
|
+
no_rerank: bool = typer.Option(False, "--no-rerank"),
|
|
54
|
+
hops: int | None = typer.Option(None, "--hops"),
|
|
55
|
+
explain: bool = typer.Option(False, "--explain"),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Search the index; ranked chunks (or files with --files-only)."""
|
|
58
|
+
if text == "-":
|
|
59
|
+
text = sys.stdin.read().strip()
|
|
60
|
+
try:
|
|
61
|
+
root = require_root()
|
|
62
|
+
cfg = Config.load(root)
|
|
63
|
+
opts = QueryOptions(
|
|
64
|
+
top=top if top is not None else cfg.get("query.top"),
|
|
65
|
+
files_only=files_only,
|
|
66
|
+
expand=not no_expand,
|
|
67
|
+
graph=not no_graph,
|
|
68
|
+
rerank=not no_rerank,
|
|
69
|
+
hops=hops,
|
|
70
|
+
explain=explain,
|
|
71
|
+
)
|
|
72
|
+
out = run_query(
|
|
73
|
+
root, cfg, make_embedder(cfg), text, opts,
|
|
74
|
+
generator=make_generator(cfg) if opts.expand else None,
|
|
75
|
+
reranker=make_reranker(cfg) if opts.rerank else None,
|
|
76
|
+
)
|
|
77
|
+
except RagxError as exc:
|
|
78
|
+
fail(str(exc))
|
|
79
|
+
if json_out:
|
|
80
|
+
if files_only:
|
|
81
|
+
emit_json(to_files_json(out))
|
|
82
|
+
else:
|
|
83
|
+
emit_json(to_query_json(out, max_chunk_chars=cfg.get("query.max_chunk_chars")))
|
|
84
|
+
elif files_only:
|
|
85
|
+
for f in to_files_json(out)["files"]:
|
|
86
|
+
typer.echo(f"{f['score']:.4f} {f['file']}")
|
|
87
|
+
else:
|
|
88
|
+
for r in out.results:
|
|
89
|
+
loc = f"{r.chunk.file_path}:{r.chunk.line_start}-{r.chunk.line_end}"
|
|
90
|
+
first_line = r.chunk.text.strip().splitlines()[0][:100] if r.chunk.text.strip() else ""
|
|
91
|
+
typer.echo(f"{r.score:.4f} {loc} {first_line}")
|
|
92
|
+
if not out.results:
|
|
93
|
+
raise typer.Exit(code=1)
|
ragx/core/__init__.py
ADDED
|
File without changes
|
ragx/core/chunking.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Text chunking: markdown/code/recursive splitters producing byte-exact ChunkDraft slices."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from bisect import bisect_right
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ragx.core.models import ChunkDraft
|
|
10
|
+
|
|
11
|
+
MARKDOWN_EXTS = {".md", ".markdown"}
|
|
12
|
+
CODE_EXTS = {".py", ".ts", ".js", ".tsx", ".jsx", ".go", ".rs", ".java", ".rb"}
|
|
13
|
+
SEPARATORS = ["\n\n", "\n", ". ", " "]
|
|
14
|
+
MIN_CHUNK_CHARS = 100 # fragments below this merge into a neighbor (may nudge it past hard max)
|
|
15
|
+
|
|
16
|
+
_HEADING_RE = re.compile(r"^#{1,6} .*$", re.MULTILINE)
|
|
17
|
+
_CODE_BOUNDARY_RE = re.compile(r"^(?:def|class|function|fn|func)\b", re.MULTILINE)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _find_boundary(s: str, start: int, hard_end: int) -> int:
|
|
21
|
+
region = s[start:hard_end]
|
|
22
|
+
for sep in SEPARATORS:
|
|
23
|
+
idx = region.rfind(sep)
|
|
24
|
+
if idx != -1:
|
|
25
|
+
candidate = start + idx + len(sep)
|
|
26
|
+
if candidate > start:
|
|
27
|
+
return candidate
|
|
28
|
+
return hard_end
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _recursive_split_range(s: str, target_chars: int, hard_max_chars: int, overlap: float) -> list[tuple[int, int]]:
|
|
32
|
+
n = len(s)
|
|
33
|
+
if n == 0:
|
|
34
|
+
return []
|
|
35
|
+
ranges: list[tuple[int, int]] = []
|
|
36
|
+
pos = 0
|
|
37
|
+
while pos < n:
|
|
38
|
+
remaining = n - pos
|
|
39
|
+
if remaining <= hard_max_chars:
|
|
40
|
+
end = n
|
|
41
|
+
else:
|
|
42
|
+
hard_end = min(pos + hard_max_chars, n)
|
|
43
|
+
end = _find_boundary(s, pos, hard_end)
|
|
44
|
+
ranges.append((pos, end))
|
|
45
|
+
if end >= n:
|
|
46
|
+
break
|
|
47
|
+
chunk_len = end - pos
|
|
48
|
+
overlap_chars = int(round(chunk_len * overlap))
|
|
49
|
+
next_pos = end - overlap_chars
|
|
50
|
+
if next_pos <= pos:
|
|
51
|
+
next_pos = end
|
|
52
|
+
pos = next_pos
|
|
53
|
+
return ranges
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _recursive_split(
|
|
57
|
+
text: str, start: int, end: int, target_chars: int, hard_max_chars: int, overlap: float
|
|
58
|
+
) -> list[tuple[int, int]]:
|
|
59
|
+
rel = _recursive_split_range(text[start:end], target_chars, hard_max_chars, overlap)
|
|
60
|
+
return [(start + a, start + b) for a, b in rel]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _sections_from_boundaries(text: str, starts: list[int]) -> list[tuple[int, int]]:
|
|
64
|
+
sections: list[tuple[int, int]] = []
|
|
65
|
+
if not starts:
|
|
66
|
+
return [(0, len(text))]
|
|
67
|
+
if starts[0] > 0:
|
|
68
|
+
sections.append((0, starts[0]))
|
|
69
|
+
for i, s in enumerate(starts):
|
|
70
|
+
e = starts[i + 1] if i + 1 < len(starts) else len(text)
|
|
71
|
+
sections.append((s, e))
|
|
72
|
+
return sections
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _markdown_split(text: str, target_chars: int, hard_max_chars: int, overlap: float) -> list[tuple[int, int]]:
|
|
76
|
+
starts = [m.start() for m in _HEADING_RE.finditer(text)]
|
|
77
|
+
sections = _sections_from_boundaries(text, starts)
|
|
78
|
+
|
|
79
|
+
ranges: list[tuple[int, int]] = []
|
|
80
|
+
buffer: tuple[int, int] | None = None
|
|
81
|
+
for s, e in sections:
|
|
82
|
+
size = e - s
|
|
83
|
+
if size > hard_max_chars:
|
|
84
|
+
if buffer is not None:
|
|
85
|
+
ranges.append(buffer)
|
|
86
|
+
buffer = None
|
|
87
|
+
ranges.extend(_recursive_split(text, s, e, target_chars, hard_max_chars, overlap))
|
|
88
|
+
continue
|
|
89
|
+
if buffer is None:
|
|
90
|
+
buffer = (s, e)
|
|
91
|
+
elif (buffer[1] - buffer[0]) + size <= target_chars:
|
|
92
|
+
buffer = (buffer[0], e)
|
|
93
|
+
else:
|
|
94
|
+
ranges.append(buffer)
|
|
95
|
+
buffer = (s, e)
|
|
96
|
+
if buffer is not None:
|
|
97
|
+
ranges.append(buffer)
|
|
98
|
+
return ranges
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _code_split(text: str, target_chars: int, hard_max_chars: int, overlap: float) -> list[tuple[int, int]]:
|
|
102
|
+
starts = [m.start() for m in _CODE_BOUNDARY_RE.finditer(text)]
|
|
103
|
+
sections = _sections_from_boundaries(text, starts)
|
|
104
|
+
|
|
105
|
+
ranges: list[tuple[int, int]] = []
|
|
106
|
+
for s, e in sections:
|
|
107
|
+
if (e - s) > hard_max_chars:
|
|
108
|
+
ranges.extend(_recursive_split(text, s, e, target_chars, hard_max_chars, overlap))
|
|
109
|
+
else:
|
|
110
|
+
ranges.append((s, e))
|
|
111
|
+
return ranges
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _char_byte_offsets(text: str) -> list[int]:
|
|
115
|
+
offsets = [0] * (len(text) + 1)
|
|
116
|
+
total = 0
|
|
117
|
+
for i, ch in enumerate(text):
|
|
118
|
+
offsets[i] = total
|
|
119
|
+
total += len(ch.encode("utf-8"))
|
|
120
|
+
offsets[len(text)] = total
|
|
121
|
+
return offsets
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _line_starts(text: str) -> list[int]:
|
|
125
|
+
starts = [0]
|
|
126
|
+
for i, ch in enumerate(text):
|
|
127
|
+
if ch == "\n":
|
|
128
|
+
starts.append(i + 1)
|
|
129
|
+
return starts
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _merge_tiny_ranges(text: str, ranges: list[tuple[int, int]], min_chars: int) -> list[tuple[int, int]]:
|
|
133
|
+
"""Absorb ranges with < min_chars of stripped text into the previous (or next) range."""
|
|
134
|
+
out: list[tuple[int, int]] = []
|
|
135
|
+
for start, end in ranges:
|
|
136
|
+
if out and len(text[start:end].strip()) < min_chars:
|
|
137
|
+
out[-1] = (out[-1][0], max(out[-1][1], end))
|
|
138
|
+
else:
|
|
139
|
+
out.append((start, end))
|
|
140
|
+
if len(out) >= 2 and len(text[out[0][0] : out[0][1]].strip()) < min_chars:
|
|
141
|
+
out[1] = (out[0][0], out[1][1])
|
|
142
|
+
out.pop(0)
|
|
143
|
+
return out
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def chunk_text(text: str, path: str, size_tokens: int = 800, overlap: float = 0.15) -> list[ChunkDraft]:
|
|
147
|
+
"""Split `text` into byte-exact ChunkDrafts, dispatching on `path`'s extension."""
|
|
148
|
+
if not text.strip():
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
target_chars = size_tokens * 4
|
|
152
|
+
hard_max_chars = int(round(target_chars * 1.5))
|
|
153
|
+
|
|
154
|
+
ext = Path(path).suffix.lower()
|
|
155
|
+
if ext in MARKDOWN_EXTS:
|
|
156
|
+
ranges = _markdown_split(text, target_chars, hard_max_chars, overlap)
|
|
157
|
+
elif ext in CODE_EXTS:
|
|
158
|
+
ranges = _code_split(text, target_chars, hard_max_chars, overlap)
|
|
159
|
+
else:
|
|
160
|
+
ranges = _recursive_split(text, 0, len(text), target_chars, hard_max_chars, overlap)
|
|
161
|
+
min_chars = min(MIN_CHUNK_CHARS, max(1, target_chars // 8))
|
|
162
|
+
ranges = _merge_tiny_ranges(text, ranges, min_chars)
|
|
163
|
+
|
|
164
|
+
char_to_byte = _char_byte_offsets(text)
|
|
165
|
+
line_starts = _line_starts(text)
|
|
166
|
+
|
|
167
|
+
drafts: list[ChunkDraft] = []
|
|
168
|
+
for start, end in ranges:
|
|
169
|
+
if end <= start:
|
|
170
|
+
continue
|
|
171
|
+
chunk_str = text[start:end]
|
|
172
|
+
if not chunk_str.strip():
|
|
173
|
+
continue
|
|
174
|
+
drafts.append(
|
|
175
|
+
ChunkDraft(
|
|
176
|
+
text=chunk_str,
|
|
177
|
+
byte_start=char_to_byte[start],
|
|
178
|
+
byte_end=char_to_byte[end],
|
|
179
|
+
line_start=bisect_right(line_starts, start),
|
|
180
|
+
line_end=bisect_right(line_starts, end - 1),
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
return drafts
|