multilingual-dev-rag 0.1.0rc1__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.
dev_rag/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ # -*- coding: utf-8 -*-
2
+ """multilingual-dev-rag — local-first RAG over source code and technical docs.
3
+
4
+ from dev_rag import search
5
+ results = search('ферма воркеров', collection='docs', n=5)
6
+
7
+ Set `DEV_RAG_ROOT` to the repository you want to index, and `DEV_RAG_PROFILE`
8
+ to a profile describing which files count. See `dev_rag.config`.
9
+ """
10
+ from .config import DEV_RAG_PROFILE, DEV_RAG_ROOT, RAG_BACKEND
11
+ from .searcher import search
12
+
13
+ __version__ = '0.1.0rc1'
14
+
15
+ __all__ = [
16
+ 'search',
17
+ 'RAG_BACKEND',
18
+ 'DEV_RAG_ROOT',
19
+ 'DEV_RAG_PROFILE',
20
+ '__version__',
21
+ ]
dev_rag/chunker.py ADDED
@@ -0,0 +1,63 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Разбиение текста на чанки с учётом типа файла."""
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Generator
7
+
8
+ from .config import CHUNK_SIZE, CHUNK_OVERLAP
9
+
10
+
11
+ def chunk_text(text: str, path: str) -> Generator[dict, None, None]:
12
+ """Разбить текст на перекрывающиеся чанки с метаданными.
13
+
14
+ Стратегия зависит от расширения:
15
+ .md — по секциям заголовков (##/###)
16
+ .mac — по блокам (двойной перевод строки)
17
+ .py — по границам class/def
18
+ """
19
+ if path.endswith('.md'):
20
+ sections = re.split(r'\n(?=#{1,3} )', text)
21
+ for section in sections:
22
+ if not section.strip():
23
+ continue
24
+ title_match = re.match(r'#{1,3} (.+)', section)
25
+ section_title = title_match.group(1) if title_match else ''
26
+ yield from _sliding_window(section, path, section_title)
27
+ elif path.endswith('.mac') or path.endswith('.MAC'):
28
+ blocks = re.split(r'\n{2,}', text)
29
+ for block in blocks:
30
+ if not block.strip():
31
+ continue
32
+ name_match = re.match(r'(\w+)\s*\(', block.lstrip())
33
+ block_name = name_match.group(1) if name_match else ''
34
+ yield from _sliding_window(block, path, block_name)
35
+ else:
36
+ # Python: split by class/def boundaries
37
+ blocks = re.split(r'\n(?=(?:class |def |\Z))', text)
38
+ for block in blocks:
39
+ if not block.strip():
40
+ continue
41
+ name_match = re.match(r'(?:class|def)\s+(\w+)', block.lstrip())
42
+ block_name = name_match.group(1) if name_match else ''
43
+ yield from _sliding_window(block, path, block_name)
44
+
45
+
46
+ def _sliding_window(text: str, path: str, context: str) -> Generator[dict, None, None]:
47
+ """Скользящее окно по тексту с перекрытием."""
48
+ start = 0
49
+ idx = 0
50
+ while start < len(text):
51
+ end = min(start + CHUNK_SIZE, len(text))
52
+ chunk = text[start:end]
53
+ if chunk.strip():
54
+ yield {
55
+ 'text': chunk,
56
+ 'path': path,
57
+ 'context': context or '',
58
+ 'chunk_idx': idx,
59
+ }
60
+ idx += 1
61
+ if end >= len(text):
62
+ break
63
+ start += CHUNK_SIZE - CHUNK_OVERLAP
dev_rag/cli.py ADDED
@@ -0,0 +1,72 @@
1
+ # -*- coding: utf-8 -*-
2
+ """CLI entry points for multilingual-dev-rag.
3
+
4
+ dev-rag-index — build or rebuild the index
5
+ dev-rag-search — search from the shell
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+
12
+ from .config import RAG_BACKEND
13
+
14
+ COLLECTION_CHOICES = ('all', 'docs', 'code', 'plans')
15
+
16
+
17
+ def main():
18
+ """Entry point for dev-rag-index. Routes to the configured backend."""
19
+ if RAG_BACKEND == 'zvec':
20
+ from .zvec_indexer import main as _zvec_main
21
+ _zvec_main()
22
+ else:
23
+ from .indexer import main as _qdrant_main
24
+ _qdrant_main()
25
+
26
+
27
+ def search_main():
28
+ """Entry point for dev-rag-search."""
29
+ parser = argparse.ArgumentParser(
30
+ prog='dev-rag-search',
31
+ description='Search the indexed corpus (hybrid vector + full-text).',
32
+ )
33
+ parser.add_argument('query', help='what to search for')
34
+ # Restricted to the known collections on purpose: an unknown name used to
35
+ # be swallowed silently — the lookup failed, the exception was caught, and
36
+ # the command printed "No results found" as if the corpus had no match.
37
+ parser.add_argument(
38
+ '-c', '--collection',
39
+ choices=COLLECTION_CHOICES,
40
+ default='all',
41
+ help='which collection to search (default: all)',
42
+ )
43
+ parser.add_argument(
44
+ '-n', '--num-results',
45
+ type=int,
46
+ default=5,
47
+ metavar='N',
48
+ help='how many results to return (default: 5)',
49
+ )
50
+ parser.add_argument(
51
+ '--paths-only',
52
+ action='store_true',
53
+ help='print only scores and paths, without the matching text',
54
+ )
55
+ args = parser.parse_args()
56
+
57
+ from .searcher import search
58
+
59
+ results = search(args.query, collection=args.collection, n=args.num_results)
60
+ if not results:
61
+ print('No results found.')
62
+ sys.exit(1)
63
+
64
+ for i, r in enumerate(results, 1):
65
+ ctx = f' [{r["context"]}]' if r.get('context') else ''
66
+ if args.paths_only:
67
+ print(f'{r["score"]:.4f} {r["path"]}{ctx}')
68
+ continue
69
+ print(f'--- Result {i} (score={r["score"]:.4f}) ---')
70
+ print(f'File: {r["path"]}{ctx}')
71
+ print(r['text'][:300])
72
+ print()
dev_rag/config.py ADDED
@@ -0,0 +1,136 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Configuration for multilingual-dev-rag.
3
+
4
+ Two things must be decided before indexing: **what to index** (the corpus root)
5
+ and **which files count** (the profile). Both come from the environment, so the
6
+ same installed package serves several projects.
7
+
8
+ DEV_RAG_ROOT absolute path to the repository being indexed. Required.
9
+ DEV_RAG_PROFILE built-in profile name, or a path to a profile JSON.
10
+ Default: 'generic'.
11
+
12
+ `DEV_RAG_ROOT` is deliberately **not** guessed from the package location. An
13
+ earlier version defaulted to `<package>/../..`, which quietly resolved to a
14
+ directory that held none of the corpus: globs matched nothing, indexing reported
15
+ "0 chunks" and exited successfully. A wrong answer that looks like a working one
16
+ is worse than a crash, so a missing root now raises — see `require_root()`.
17
+
18
+ Profiles keep project-specific knowledge out of the code. `generic` indexes
19
+ markdown and Python anywhere; `k3_mebel` is a hand-tuned example for a CAD
20
+ codebase with `.mac` macros. To describe your own corpus, copy a built-in
21
+ profile, edit the globs, and point `DEV_RAG_PROFILE` at the file.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ import os
28
+
29
+ _PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
30
+ _PROFILES_DIR = os.path.join(_PACKAGE_DIR, 'profiles')
31
+
32
+ # ==================== Corpus root ====================
33
+ # Empty (not a guess) when unset: see require_root().
34
+ DEV_RAG_ROOT = os.environ.get('DEV_RAG_ROOT', '')
35
+
36
+
37
+ def require_root() -> str:
38
+ """Return the corpus root, or raise with an actionable message.
39
+
40
+ Called by the indexer and searcher rather than at import time, so that
41
+ `import dev_rag` stays cheap and testable without a corpus on disk.
42
+ """
43
+ if not DEV_RAG_ROOT:
44
+ raise RuntimeError(
45
+ 'DEV_RAG_ROOT is not set. Point it at the repository you want to '
46
+ 'index, e.g. DEV_RAG_ROOT=/path/to/your/repo. It is not guessed '
47
+ 'from the package location on purpose: a wrong guess indexes '
48
+ 'nothing and reports success.'
49
+ )
50
+ if not os.path.isdir(DEV_RAG_ROOT):
51
+ raise RuntimeError(
52
+ f'DEV_RAG_ROOT points at {DEV_RAG_ROOT!r}, which is not a '
53
+ f'directory. Nothing would be indexed.'
54
+ )
55
+ return DEV_RAG_ROOT
56
+
57
+
58
+ # ==================== Profile ====================
59
+ DEV_RAG_PROFILE = os.environ.get('DEV_RAG_PROFILE', 'generic')
60
+
61
+
62
+ def load_profile(name_or_path: str = None) -> dict:
63
+ """Load a profile by built-in name or by path to a JSON file."""
64
+ name_or_path = name_or_path or DEV_RAG_PROFILE
65
+ path = name_or_path
66
+ if not os.path.isfile(path):
67
+ path = os.path.join(_PROFILES_DIR, f'{name_or_path}.json')
68
+ if not os.path.isfile(path):
69
+ available = sorted(
70
+ f[:-5] for f in os.listdir(_PROFILES_DIR) if f.endswith('.json')
71
+ )
72
+ raise RuntimeError(
73
+ f'Profile {name_or_path!r} not found. Built-in profiles: '
74
+ f'{", ".join(available)}. Or pass a path to a profile JSON.'
75
+ )
76
+ with open(path, encoding='utf-8') as f:
77
+ return json.load(f)
78
+
79
+
80
+ _profile = load_profile()
81
+
82
+ # Collection names and glob patterns come from the profile, not from this file.
83
+ COLLECTIONS = _profile['collections']
84
+ INDEX_PATTERNS = _profile['index_patterns']
85
+
86
+ # ==================== Backend selection ====================
87
+ # zvec — in-process vector DB + local multilingual embeddings, no servers.
88
+ # qdrant — legacy: needs a Qdrant server and an Ollama embedder.
89
+ RAG_BACKEND = os.getenv('RAG_BACKEND', 'zvec')
90
+
91
+ # ==================== zvec backend ====================
92
+
93
+ def _default_db_path() -> str:
94
+ """Where the index lives: a per-user, per-corpus directory.
95
+
96
+ Not inside the package. The index is derived data — rebuilt by
97
+ `dev-rag-index`, and holding chunks of whatever corpus you pointed at.
98
+ Writing it into `site-packages/` works for an editable install and breaks
99
+ for a normal one: on Linux that directory belongs to root.
100
+
101
+ Keyed by the corpus root, so one installation can serve several
102
+ repositories. With a single shared path, pointing DEV_RAG_ROOT at another
103
+ repository and re-indexing would silently overwrite the previous index —
104
+ and a search would then answer confidently from the wrong corpus.
105
+
106
+ The directory name keeps the root's basename for readability and a short
107
+ hash of its absolute path for uniqueness. Override wholesale with
108
+ ZVEC_DB_PATH if you want the index somewhere specific.
109
+ """
110
+ if os.name == 'nt':
111
+ base = os.environ.get('LOCALAPPDATA') or os.path.join(
112
+ os.path.expanduser('~'), 'AppData', 'Local'
113
+ )
114
+ else:
115
+ base = os.environ.get('XDG_DATA_HOME') or os.path.join(
116
+ os.path.expanduser('~'), '.local', 'share'
117
+ )
118
+ root = os.path.abspath(DEV_RAG_ROOT) if DEV_RAG_ROOT else '_unset'
119
+ slug = os.path.basename(root.rstrip(os.sep)) or 'root'
120
+ digest = hashlib.md5(root.encode('utf-8')).hexdigest()[:8]
121
+ return os.path.join(base, 'dev-rag', f'{slug}-{digest}', 'zvec_data')
122
+
123
+
124
+ ZVEC_DB_PATH = os.getenv('ZVEC_DB_PATH') or _default_db_path()
125
+ ZVEC_MODEL = os.getenv('ZVEC_MODEL', 'paraphrase-multilingual-MiniLM-L12-v2')
126
+ ZVEC_EMBED_DIM = 384 # paraphrase-multilingual-MiniLM-L12-v2 output dimension
127
+
128
+ # ==================== Qdrant + Ollama (legacy backend) ====================
129
+ QDRANT_URL = os.getenv('QDRANT_URL', 'http://localhost:6333')
130
+ OLLAMA_URL = os.getenv('OLLAMA_URL', 'http://localhost:11434')
131
+ EMBED_MODEL = os.getenv('EMBED_MODEL', 'nomic-embed-text')
132
+ EMBED_DIM = 768 # nomic-embed-text output dimension
133
+
134
+ # ==================== Chunking ====================
135
+ CHUNK_SIZE = 800 # characters per chunk
136
+ CHUNK_OVERLAP = 100 # overlap between consecutive chunks
dev_rag/embedder.py ADDED
@@ -0,0 +1,33 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Эмбеддинги через Ollama nomic-embed-text (legacy-бэкенд).
3
+
4
+ Используется только при RAG_BACKEND=qdrant.
5
+ Для zvec-бэкенда эмбеддинги генерируются локально (sentence-transformers)
6
+ внутри zvec_searcher.py.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import httpx
11
+
12
+ from .config import OLLAMA_URL, EMBED_MODEL
13
+
14
+
15
+ def embed(text: str) -> list:
16
+ """Получить вектор эмбеддинга через Ollama API."""
17
+ r = httpx.post(
18
+ f'{OLLAMA_URL}/api/embeddings',
19
+ json={'model': EMBED_MODEL, 'prompt': text},
20
+ timeout=120.0,
21
+ )
22
+ r.raise_for_status()
23
+ return r.json()['embedding']
24
+
25
+
26
+ def embed_batch(texts: list, batch_size: int = 16) -> list:
27
+ """Эмбеддинги для списка текстов."""
28
+ vectors = []
29
+ for i in range(0, len(texts), batch_size):
30
+ batch = texts[i:i + batch_size]
31
+ for text in batch:
32
+ vectors.append(embed(text))
33
+ return vectors
@@ -0,0 +1,115 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Нормализация текста для FTS-поиска по не-ASCII письменностям.
3
+
4
+ ## Зачем это нужно
5
+
6
+ FTS-движок zvec не умеет искать по тексту вне ASCII, и на то две независимые
7
+ причины (обе проверены зондами на реальном корпусе, а не на игрушечных примерах):
8
+
9
+ 1. **Токенайзер `standard` выбрасывает не-ASCII токены целиком.** Любой русский
10
+ запрос через `Fts(match_string='воркер')` возвращает 0 результатов, тогда как
11
+ латиница (`RabbitMQ`, `assembly`) находится. В гибриде FTS+vector это значит,
12
+ что для русских запросов работал только вектор.
13
+ 2. **Фильтр `lowercase` внутри zvec обрабатывает только ASCII.** Он не приводит
14
+ кириллицу к нижнему регистру, поэтому даже токенайзер, сохраняющий русские
15
+ токены, находит `Коробка` и не находит `коробка`.
16
+
17
+ Альтернативы, которые проверены и отвергнуты:
18
+
19
+ - `tokenizer_name='jieba'` — кириллицу индексирует, но режет её на мелкие
20
+ фрагменты. На корпусе из 5 документов давал 7/9 верных ответов и выглядел
21
+ решением; на реальных 837 чанках выродился в шум: `микрорепозитории` →
22
+ план 022, `воркер` → план 022, а три разных регистра давали три разных
23
+ неверных ответа. Хуже `standard`: тот хотя бы честно возвращал пусто.
24
+ - Транслитерация в ASCII + hex-ключи — решает проблему, которой нет: коллизии
25
+ (`ел`/`ель` → `el`) возникают только из-за самой транслитерации. Без неё
26
+ `ел` и `ель` — разные токены.
27
+ - Другого токенайзера нет: в `_zvec.*.pyd` доступны ровно три (`standard`,
28
+ `whitespace`, `jieba`) и ровно один фильтр (`lowercase`).
29
+
30
+ ## Решение
31
+
32
+ `whitespace`-токенайзер сохраняет не-ASCII токены целиком, а привести их к
33
+ нижнему регистру и убрать пунктуацию можно на стороне Python: `str.lower()`
34
+ работает с юникодом, в отличие от ASCII-фильтра zvec.
35
+
36
+ Нормализованный текст пишется в **отдельное** поле `text_fts`, а поле `text`
37
+ остаётся нетронутым: его отдаёт RAG в выдачу, и на нём работает `standard`,
38
+ который умеет разбивать идентификаторы (`obj_k3_gab3` находится по частям
39
+ `obj`/`k3`/`gab3` — это полезно и терять это нельзя).
40
+
41
+ В `text_fts` попадают **только токены с не-ASCII символами**. Чистая латиница
42
+ туда не идёт намеренно: она уже проиндексирована в `text`, и дублирование дало
43
+ бы документу двойной вес в RRF при смешанном запросе. Так две FTS-дороги не
44
+ пересекаются: `text` отвечает за ASCII и идентификаторы, `text_fts` — за всё
45
+ остальное.
46
+
47
+ ## Граница применимости
48
+
49
+ Подход работает для письменностей, где **слова разделены пробелами**: кириллица,
50
+ греческий, латиница с диакритикой, армянский, грузинский, иврит, арабский.
51
+ Проверено сквозным зондом (запрос → FTS → документ) на ru/de/el/fr/ar.
52
+
53
+ **CJK так не поедет.** В китайском и японском пробелов между словами нет, и
54
+ `whitespace` склеит предложение в один токен. Для них нужен `tokenizer_name='jieba'`
55
+ на отдельном поле — он в zvec есть и для китайского как раз по назначению
56
+ (на кириллице он бесполезен, см. выше).
57
+
58
+ **Турецкий `İ`/`ı` не поддержан.** Беcрегистровое сравнение там зависит от локали
59
+ (`İstanbul` vs `istanbul`), и ни `lower()`, ни `casefold()` его не решают — нужен
60
+ turkish casing. Для турецкого корпуса потребуется отдельная обработка.
61
+ """
62
+ from __future__ import annotations
63
+
64
+ import re
65
+
66
+ # Версия схемы нормализации. Менять при любой правке make_fts_unicode, иначе
67
+ # старый индекс будет молча искать по правилам, которых больше нет.
68
+ FTS_NORM_VERSION = 'unicode_casefold_v1'
69
+
70
+ _PUNCT_RE = re.compile(r'[^\w\s]', re.UNICODE)
71
+ _SPACE_RE = re.compile(r'\s+', re.UNICODE)
72
+ _ASCII_WORD_RE = re.compile(r'[a-z0-9]', re.IGNORECASE)
73
+
74
+
75
+ def has_non_ascii(text: str) -> bool:
76
+ """Есть ли в тексте символы вне ASCII (значит, поле text_fts может помочь).
77
+
78
+ Именно не-ASCII, а не «кириллица»: токенайзер standard слеп ко всему за
79
+ пределами ASCII одинаково, поэтому и обход нужен один на все письменности.
80
+ """
81
+ return any(ord(ch) > 127 for ch in text)
82
+
83
+
84
+ def has_ascii_word(text: str) -> bool:
85
+ """Есть ли в тексте латиница или цифры (значит, поле text может помочь)."""
86
+ return bool(_ASCII_WORD_RE.search(text))
87
+
88
+
89
+ def make_fts_unicode(text: str) -> str:
90
+ """Вернуть не-ASCII токены текста, приведённые к виду, пригодному для FTS.
91
+
92
+ Складывает регистр через `casefold()`, заменяет пунктуацию пробелами и
93
+ оставляет только токены, содержащие хотя бы один не-ASCII символ.
94
+ `ё` → `е` — русская любезность, чтобы «ёлка» и «елка» искались одинаково.
95
+
96
+ Именно `casefold()`, а не `lower()`: последний не решает немецкую асимметрию
97
+ `ß`/`SS` — `'ß'.lower()` остаётся `ß`, а `'SS'.lower()` даёт `ss`, поэтому
98
+ `TÜRGRÖSSE` не находил `Türgröße`. `casefold()` сводит оба к `türgrösse`.
99
+ Найдено сквозным зондом; юнит-тесты нормализатора это пропускали, потому что
100
+ сравнивали функцию с самой собой.
101
+
102
+ Результат пишется в поле text_fts с токенайзером whitespace. Пустая строка —
103
+ нормальный результат для чисто ASCII-текста.
104
+
105
+ >>> make_fts_unicode('Коробка двери KSM12.')
106
+ 'коробка двери'
107
+ >>> make_fts_unicode('def build_door(x): pass')
108
+ ''
109
+ >>> make_fts_unicode('Größe Ελλάδα')
110
+ 'grösse ελλάδα'
111
+ """
112
+ lowered = text.casefold().replace('ё', 'е')
113
+ spaced = _PUNCT_RE.sub(' ', lowered)
114
+ tokens = [t for t in _SPACE_RE.split(spaced) if t and has_non_ascii(t)]
115
+ return ' '.join(tokens)
dev_rag/indexer.py ADDED
@@ -0,0 +1,167 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Qdrant-индексатор (legacy) — индексация через Qdrant + Ollama.
3
+
4
+ Используется только при RAG_BACKEND=qdrant.
5
+ Для zvec используйте zvec_indexer.py.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import glob
11
+ import hashlib
12
+ import argparse
13
+
14
+ from qdrant_client import QdrantClient
15
+ from qdrant_client.models import (
16
+ Distance, VectorParams, PointStruct,
17
+ Filter, FieldCondition, MatchValue,
18
+ )
19
+
20
+ from .config import (
21
+ QDRANT_URL, EMBED_DIM, DEV_RAG_ROOT,
22
+ COLLECTIONS, INDEX_PATTERNS,
23
+ )
24
+ from .embedder import embed
25
+ from .chunker import chunk_text
26
+ from .reader import read_file_text, file_hash
27
+
28
+ client = QdrantClient(url=QDRANT_URL)
29
+
30
+
31
+ def _ensure_collections():
32
+ existing = {c.name for c in client.get_collections().collections}
33
+ for name in COLLECTIONS.values():
34
+ if name not in existing:
35
+ client.create_collection(
36
+ collection_name=name,
37
+ vectors_config=VectorParams(size=EMBED_DIM, distance=Distance.COSINE),
38
+ )
39
+ print(f'Created collection: {name}')
40
+
41
+
42
+ def _collection_for(path: str) -> str:
43
+ rel = os.path.relpath(path, DEV_RAG_ROOT).replace('\\', '/')
44
+ if rel.startswith('plans/'):
45
+ return COLLECTIONS['plans']
46
+ if rel.endswith('.py') or rel.endswith('.mac'):
47
+ return COLLECTIONS['code']
48
+ return COLLECTIONS['docs']
49
+
50
+
51
+ def index_file(path: str, force: bool = False) -> int:
52
+ abs_path = os.path.abspath(path)
53
+ rel_path = os.path.relpath(abs_path, DEV_RAG_ROOT).replace('\\', '/')
54
+ collection = _collection_for(abs_path)
55
+ fhash = file_hash(abs_path)
56
+
57
+ if not force:
58
+ results = client.scroll(
59
+ collection_name=collection,
60
+ scroll_filter=Filter(must=[
61
+ FieldCondition(key='path', match=MatchValue(value=rel_path)),
62
+ FieldCondition(key='hash', match=MatchValue(value=fhash)),
63
+ ]),
64
+ limit=1,
65
+ )
66
+ if results[0]:
67
+ return 0 # уже актуален
68
+
69
+ client.delete(
70
+ collection_name=collection,
71
+ points_selector=Filter(must=[
72
+ FieldCondition(key='path', match=MatchValue(value=rel_path))
73
+ ]),
74
+ )
75
+
76
+ try:
77
+ text = read_file_text(abs_path)
78
+ except Exception as e:
79
+ print(f' Skip {rel_path}: {e}')
80
+ return 0
81
+
82
+ chunks = list(chunk_text(text, rel_path))
83
+ if not chunks:
84
+ return 0
85
+
86
+ points = []
87
+ for i, chunk in enumerate(chunks):
88
+ chunk_id = int(hashlib.md5(f'{rel_path}:{i}'.encode()).hexdigest()[:15], 16)
89
+ vector = embed(chunk['text'])
90
+ points.append(PointStruct(
91
+ id=chunk_id,
92
+ vector=vector,
93
+ payload={
94
+ 'path': rel_path,
95
+ 'context': chunk['context'],
96
+ 'text': chunk['text'],
97
+ 'hash': fhash,
98
+ 'chunk_index': i,
99
+ },
100
+ ))
101
+
102
+ client.upsert(collection_name=collection, points=points)
103
+ return len(chunks)
104
+
105
+
106
+ def index_all(force: bool = False) -> int:
107
+ _ensure_collections()
108
+ total = 0
109
+ for group, patterns in INDEX_PATTERNS.items():
110
+ print(f'\n[{group}]')
111
+ for pattern in patterns:
112
+ for path in glob.glob(os.path.join(DEV_RAG_ROOT, pattern), recursive=True):
113
+ n = index_file(path, force=force)
114
+ if n:
115
+ rel = os.path.relpath(path, DEV_RAG_ROOT)
116
+ print(f' {rel}: {n} chunks')
117
+ total += n
118
+ print(f'\nTotal: {total} chunks indexed')
119
+ return total
120
+
121
+
122
+ def index_changed_files(changed_paths: list, force: bool = False) -> int:
123
+ _ensure_collections()
124
+ total = 0
125
+ for path in changed_paths:
126
+ abs_path = os.path.join(DEV_RAG_ROOT, path)
127
+ if not os.path.exists(abs_path):
128
+ continue
129
+ if not (path.endswith('.py') or path.endswith('.md') or path.endswith('.mac')):
130
+ continue
131
+ n = index_file(abs_path, force=force)
132
+ if n:
133
+ print(f' {path}: {n} chunks')
134
+ total += n
135
+ return total
136
+
137
+
138
+ def main():
139
+ parser = argparse.ArgumentParser(description='Qdrant indexer for multilingual-dev-rag')
140
+ parser.add_argument('--all', action='store_true', help='Index all configured files')
141
+ parser.add_argument('--changed-only', action='store_true', help='Index files changed in last commit')
142
+ parser.add_argument('--force', action='store_true', help='Force reindex')
143
+ parser.add_argument('files', nargs='*', help='Specific files to index')
144
+ args = parser.parse_args()
145
+
146
+ if args.changed_only:
147
+ import subprocess
148
+ try:
149
+ out = subprocess.check_output(
150
+ ['git', 'diff', 'HEAD~1', 'HEAD', '--name-only'],
151
+ cwd=DEV_RAG_ROOT, stderr=subprocess.DEVNULL,
152
+ ).decode().splitlines()
153
+ except Exception:
154
+ out = []
155
+ n = index_changed_files(out, force=args.force)
156
+ print(f'Indexed {n} chunks from changed files')
157
+ elif args.files:
158
+ _ensure_collections()
159
+ for f in args.files:
160
+ n = index_file(f, force=args.force)
161
+ print(f'{f}: {n} chunks')
162
+ else:
163
+ index_all(force=args.force)
164
+
165
+
166
+ if __name__ == '__main__':
167
+ main()