File size: 9,714 Bytes
ad61deb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
"""Chunk the documentation and build a FAISS index over it.

Chunking is the part that decides whether retrieval works, so it is done on
document structure rather than on a fixed character count. RST carries its own
section headings; splitting on those keeps a chunk to one topic and lets every
retrieved passage cite the section it came from. A blind 1000-character split
would cut mid-sentence and mix two subsystems into one vector.

    python scripts/build_index.py
    python scripts/build_index.py --docs data/raw_docs --out data/index
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    sys.stderr.reconfigure(encoding="utf-8", errors="replace")

sys.path.insert(0, str(Path(__file__).resolve().parent))
import source_config as cfg  # noqa: E402

ROOT = Path(__file__).resolve().parent.parent
DEFAULT_DOCS = ROOT / "data" / "raw_docs"
DEFAULT_OUT = ROOT / "data" / "index"
EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"

# An RST section underline: a run of one punctuation character on its own line,
# directly under the title it underlines.
RST_UNDERLINE = re.compile(r"^([=\-`:'\"~^_*+#<>])\1{2,}\s*$")

MIN_CHARS = 120
MAX_CHARS = 1600

SKIP_FILES = cfg.SKIP_FILES

# Sections that are identifier lists rather than prose.
SKIP_SECTIONS = re.compile(cfg.SKIP_SECTION_PATTERN, re.IGNORECASE)


def looks_like_prose(text: str) -> bool:
    """Reject chunks that are mostly punctuation, IDs or markup."""
    letters = sum(character.isalpha() for character in text)
    if letters < len(text) * 0.45:
        return False
    words = text.split()
    if not words:
        return False
    return sum(len(word) for word in words) / len(words) >= 3.0


def clean(text: str) -> str:
    """Strip the RST directives that carry no meaning for a reader."""
    lines: list[str] = []
    for line in text.splitlines():
        stripped = line.strip()
        # Comments, toctrees and figure/image directives are navigation and
        # layout. They retrieve badly and answer nothing.
        if stripped.startswith(".. toctree::") or stripped.startswith(".. figure::"):
            continue
        if stripped.startswith(".. image::") or stripped.startswith(".."):
            if re.match(r"^\.\.\s+_[\w.-]+:", stripped):
                continue  # anchor target
            if stripped.startswith(".. code-block::") or stripped.startswith(".. note::"):
                lines.append(line)  # keep — the body that follows is content
                continue
            continue
        if stripped.startswith(":") and stripped.count(":") >= 2 and len(stripped) < 80:
            continue  # field list / directive option
        lines.append(line)
    return "\n".join(lines)


def sections(text: str) -> list[tuple[str, str]]:
    """Split RST into (heading, body). The first block inherits the document title."""
    lines = text.splitlines()
    blocks: list[tuple[str, list[str]]] = [("", [])]

    index = 0
    while index < len(lines):
        line = lines[index]
        following = lines[index + 1] if index + 1 < len(lines) else ""
        is_heading = (
            line.strip()
            and RST_UNDERLINE.match(following)
            and len(following.strip()) >= len(line.strip()) - 2
        )
        if is_heading:
            blocks.append((line.strip(), []))
            index += 2
            continue
        blocks[-1][1].append(line)
        index += 1

    return [(heading, "\n".join(body).strip()) for heading, body in blocks]


def split_long(body: str, limit: int = MAX_CHARS) -> list[str]:
    """Break an over-long section on blank lines, never mid-paragraph.

    Paragraph splitting alone is not enough. A section with no blank lines — a
    long table, a generated list — comes back as one piece however large it is.
    The first version of this used only blank lines and emitted a single
    126 KB chunk, so anything still over the limit is hard-split on line
    boundaries as a backstop.
    """
    if len(body) <= limit:
        return [body]

    parts: list[str] = []
    current = ""
    for paragraph in body.split("\n\n"):
        if current and len(current) + len(paragraph) + 2 > limit:
            parts.append(current.strip())
            current = paragraph
        else:
            current = f"{current}\n\n{paragraph}" if current else paragraph
    if current.strip():
        parts.append(current.strip())

    bounded: list[str] = []
    for part in parts:
        if len(part) <= limit:
            bounded.append(part)
            continue
        buffer = ""
        for line in part.splitlines():
            # A single line can itself exceed the limit — ROS 2 changelogs put
            # an entire release's bullets on one line, which line-splitting
            # cannot divide. Wrap on whitespace as a last resort.
            for piece in _wrap(line, limit):
                if buffer and len(buffer) + len(piece) + 1 > limit:
                    bounded.append(buffer.strip())
                    buffer = piece
                else:
                    buffer = f"{buffer}\n{piece}" if buffer else piece
        if buffer.strip():
            bounded.append(buffer.strip())
    return bounded


def _wrap(line: str, limit: int) -> list[str]:
    """Split one over-long line on whitespace, never mid-word."""
    if len(line) <= limit:
        return [line]
    pieces: list[str] = []
    current = ""
    for word in line.split(" "):
        if current and len(current) + len(word) + 1 > limit:
            pieces.append(current)
            current = word
        else:
            current = f"{current} {word}" if current else word
    if current:
        pieces.append(current)
    return pieces


def chunk_document(path: Path) -> list[dict]:
    raw = path.read_text(encoding="utf-8", errors="replace")
    # The fetcher flattens "kernel/services/threads.rst" to
    # "kernel__services__threads.rst" so the source path survives as a citation.
    source = path.stem.replace("__", "/")
    body = clean(raw)

    title = ""
    for heading, _ in sections(body):
        if heading:
            title = heading
            break

    chunks: list[dict] = []
    for heading, section_body in sections(body):
        if len(section_body) < MIN_CHARS:
            continue
        if heading and SKIP_SECTIONS.search(heading):
            continue
        for part in split_long(section_body):
            if len(part) < MIN_CHARS or not looks_like_prose(part):
                continue
            chunks.append(
                {
                    "text": part,
                    "source": source,
                    "title": title or source,
                    "section": heading or title or source,
                    "url": cfg.DOC_URL.format(path=source),
                }
            )
    return chunks


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--docs", type=Path, default=DEFAULT_DOCS)
    parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
    parser.add_argument("--model", default=EMBEDDING_MODEL)
    parser.add_argument("--batch-size", type=int, default=64)
    args = parser.parse_args()

    if not args.docs.is_dir():
        raise SystemExit(f"no docs at {args.docs} — run scripts/fetch_docs.py first")

    files = sorted(p for p in args.docs.glob("*") if p.suffix.lower() in {".rst", ".md", ".txt"})
    files = [p for p in files if p.name != "_SOURCE.txt" and p.name not in SKIP_FILES]
    print(f"Chunking {len(files)} documents...")

    chunks: list[dict] = []
    for path in files:
        chunks.extend(chunk_document(path))
    if not chunks:
        raise SystemExit("no chunks produced — check the docs directory")

    lengths = sorted(len(c["text"]) for c in chunks)
    print(
        f"{len(chunks)} chunks | median {lengths[len(lengths) // 2]} chars, "
        f"max {lengths[-1]}"
    )

    # Imported here so --help and the chunking stats stay fast on a machine
    # without torch installed.
    import faiss
    import numpy as np
    from sentence_transformers import SentenceTransformer

    print(f"Embedding with {args.model}...")
    model = SentenceTransformer(args.model)
    vectors = model.encode(
        [c["text"] for c in chunks],
        batch_size=args.batch_size,
        show_progress_bar=True,
        convert_to_numpy=True,
        normalize_embeddings=True,
    ).astype("float32")

    # Inner product over L2-normalised vectors is cosine similarity, which is
    # what the retrieval scores in ask.py are reported as.
    index = faiss.IndexFlatIP(vectors.shape[1])
    index.add(vectors)

    args.out.mkdir(parents=True, exist_ok=True)
    faiss.write_index(index, str(args.out / "docs.faiss"))
    with (args.out / "chunks.jsonl").open("w", encoding="utf-8") as handle:
        for chunk in chunks:
            handle.write(json.dumps(chunk, ensure_ascii=False) + "\n")

    source_note = (args.docs / "_SOURCE.txt")
    meta = {
        "embedding_model": args.model,
        "dimensions": int(vectors.shape[1]),
        "chunks": len(chunks),
        "documents": len(files),
        "index": "IndexFlatIP (cosine over normalised vectors)",
        "source": source_note.read_text(encoding="utf-8") if source_note.exists() else "unknown",
    }
    (args.out / "meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")

    print(f"\nIndex written to {args.out}")
    print(f"{len(chunks)} chunks x {vectors.shape[1]} dims")
    return 0


if __name__ == "__main__":
    sys.exit(main())