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 +21 -0
- dev_rag/chunker.py +63 -0
- dev_rag/cli.py +72 -0
- dev_rag/config.py +136 -0
- dev_rag/embedder.py +33 -0
- dev_rag/fts_normalizer.py +115 -0
- dev_rag/indexer.py +167 -0
- dev_rag/mcp_server.py +128 -0
- dev_rag/profiles/generic.json +20 -0
- dev_rag/profiles/k3_mebel.json +32 -0
- dev_rag/qdrant_searcher.py +42 -0
- dev_rag/reader.py +27 -0
- dev_rag/searcher.py +19 -0
- dev_rag/zvec_indexer.py +300 -0
- dev_rag/zvec_searcher.py +181 -0
- multilingual_dev_rag-0.1.0rc1.dist-info/METADATA +237 -0
- multilingual_dev_rag-0.1.0rc1.dist-info/RECORD +21 -0
- multilingual_dev_rag-0.1.0rc1.dist-info/WHEEL +5 -0
- multilingual_dev_rag-0.1.0rc1.dist-info/entry_points.txt +4 -0
- multilingual_dev_rag-0.1.0rc1.dist-info/licenses/LICENSE +201 -0
- multilingual_dev_rag-0.1.0rc1.dist-info/top_level.txt +1 -0
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()
|