codebase-navigator 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.
@@ -0,0 +1,102 @@
1
+ """Configuration, schema definitions, and shared utilities for codebase-navigator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import hashlib
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Iterator
10
+
11
+ # Ensure portable user caching & offline environment variables before importing ML libraries
12
+ _cache_base = Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache"))
13
+ if "TRITON_CACHE_DIR" not in os.environ or "/root/" in os.environ["TRITON_CACHE_DIR"]:
14
+ os.environ["TRITON_CACHE_DIR"] = str(_cache_base / "triton")
15
+ if "TORCH_HOME" not in os.environ or "/root/" in os.environ["TORCH_HOME"]:
16
+ os.environ["TORCH_HOME"] = str(_cache_base / "torch")
17
+ if "HF_HOME" not in os.environ or "/root/" in os.environ["HF_HOME"]:
18
+ os.environ["HF_HOME"] = str(_cache_base / "huggingface")
19
+
20
+ os.environ.setdefault("HF_HUB_OFFLINE", "1")
21
+ os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
22
+ os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
23
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "0")
24
+ os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
25
+ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
26
+
27
+ import pyarrow as pa
28
+
29
+ EMBEDDING_MODEL_NAME = "all-MiniLM-L6-v2"
30
+ VECTOR_DIM = 384
31
+
32
+ DOC_SCHEMA = pa.schema([
33
+ pa.field("id", pa.string()),
34
+ pa.field("path", pa.string()),
35
+ pa.field("abs_path", pa.string()),
36
+ pa.field("doc_type", pa.string()), # "markdown" or "code_doc"
37
+ pa.field("title", pa.string()),
38
+ pa.field("start_line", pa.int32()),
39
+ pa.field("end_line", pa.int32()),
40
+ pa.field("content", pa.string()),
41
+ pa.field("vector", pa.list_(pa.float32(), VECTOR_DIM)),
42
+ ])
43
+
44
+ CODE_EXTENSIONS = {
45
+ ".py", ".rs", ".go", ".ts", ".tsx", ".js", ".jsx",
46
+ ".c", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".hh",
47
+ ".nix", ".sh", ".bash", ".zsh", ".sql", ".java",
48
+ ".kt", ".scala", ".rb", ".php", ".cs", ".swift",
49
+ ".lua", ".zig", ".nim", ".elm", ".ex", ".exs",
50
+ ".erl", ".hrl", ".hs", ".ml", ".mli", ".pl", ".pm",
51
+ ".r", ".jl", ".clj", ".cljs", ".lisp", ".scm",
52
+ }
53
+
54
+ DOC_EXTENSIONS = {
55
+ ".md", ".markdown", ".rst", ".adoc", ".org",
56
+ }
57
+
58
+ IGNORE_DIR_NAMES = {
59
+ ".git", ".hg", ".svn",
60
+ "node_modules", "target", "build", "dist",
61
+ ".venv", "venv", "env", ".direnv",
62
+ "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache",
63
+ ".cache", ".devel-index", ".devel-tools", ".codebase-navigator", ".dagster_home", "pipeline-cache",
64
+ }
65
+
66
+
67
+ @contextlib.contextmanager
68
+ def silence_stdio() -> Iterator[None]:
69
+ """Silence low-level stdout/stderr (e.g. from C-level libraries and model loaders)."""
70
+ try:
71
+ null_fd = os.open(os.devnull, os.O_RDWR)
72
+ save_stdout = os.dup(1)
73
+ save_stderr = os.dup(2)
74
+ os.dup2(null_fd, 1)
75
+ os.dup2(null_fd, 2)
76
+ try:
77
+ yield
78
+ finally:
79
+ os.dup2(save_stdout, 1)
80
+ os.dup2(save_stderr, 2)
81
+ os.close(null_fd)
82
+ os.close(save_stdout)
83
+ os.close(save_stderr)
84
+ except Exception:
85
+ yield
86
+
87
+
88
+ def get_cache_dir(folder: Path, custom_index_dir: str | None = None) -> Path:
89
+ """Determine the persistence directory for vector indexes and tools metadata."""
90
+ if custom_index_dir:
91
+ cdir = Path(custom_index_dir).resolve()
92
+ cdir.mkdir(parents=True, exist_ok=True)
93
+ return cdir
94
+
95
+ target = folder / ".codebase-navigator"
96
+ target.mkdir(parents=True, exist_ok=True)
97
+ return target
98
+
99
+
100
+ def get_socket_path(folder: Path, custom_index_dir: str | None = None) -> Path:
101
+ """Return the Unix Domain Socket path used for IPC with cn watch."""
102
+ return get_cache_dir(folder, custom_index_dir) / "watch.sock"
@@ -0,0 +1,281 @@
1
+ """Semantic content extraction from Markdown files and source code comments/docstrings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import hashlib
7
+ from pathlib import Path
8
+ import re
9
+ from typing import Any
10
+
11
+
12
+ class DocExtractor:
13
+ """Extracts semantic chunks from Markdown documentation and code docstrings/comments."""
14
+
15
+ def __init__(self, base_folder: Path):
16
+ self.base_folder = base_folder
17
+
18
+ def extract_markdown(self, path: Path) -> list[dict[str, Any]]:
19
+ """Extract markdown sections and term definitions preserving headers and line ranges."""
20
+ try:
21
+ text = path.read_text(encoding="utf-8", errors="replace")
22
+ except Exception:
23
+ return []
24
+
25
+ try:
26
+ rel_path = str(path.relative_to(self.base_folder))
27
+ except ValueError:
28
+ rel_path = str(path)
29
+
30
+ lines = text.splitlines()
31
+ chunks: list[dict[str, Any]] = []
32
+
33
+ header_re = re.compile(r"^(#{1,6})\s+(.+)$")
34
+ term_re = re.compile(r"^(?:-\s+)?(?:\*\*([^*]+)\*\*|__([^_]+)__)\s*[-—:]\s*(.+)$")
35
+
36
+ current_headers: list[str] = []
37
+ chunk_start = 1
38
+ chunk_lines: list[str] = []
39
+
40
+ def flush_chunk(end_line: int):
41
+ nonlocal chunk_start, chunk_lines
42
+ content = "\n".join(chunk_lines).strip()
43
+ if len(content) >= 20:
44
+ title = " > ".join(current_headers) if current_headers else f"{path.name} (top)"
45
+ chunk_id = hashlib.sha256(f"{rel_path}:{chunk_start}:{title}".encode("utf-8")).hexdigest()[:16]
46
+ chunks.append({
47
+ "id": chunk_id,
48
+ "path": rel_path,
49
+ "abs_path": str(path.resolve()),
50
+ "doc_type": "markdown",
51
+ "title": title,
52
+ "start_line": chunk_start,
53
+ "end_line": end_line,
54
+ "content": f"# {title}\n\n{content}",
55
+ })
56
+ chunk_lines = []
57
+
58
+ # 1. Section-level chunking
59
+ for idx, line in enumerate(lines, start=1):
60
+ match = header_re.match(line)
61
+ if match:
62
+ level = len(match.group(1))
63
+ heading_text = match.group(2).strip()
64
+
65
+ if chunk_lines:
66
+ flush_chunk(idx - 1)
67
+
68
+ while len(current_headers) >= level:
69
+ current_headers.pop()
70
+ current_headers.append(heading_text)
71
+ chunk_start = idx
72
+ chunk_lines.append(line)
73
+ else:
74
+ chunk_lines.append(line)
75
+
76
+ if chunk_lines:
77
+ flush_chunk(len(lines))
78
+
79
+ # 2. Granular term/definition chunking (for glossaries, bullet rules, specifications)
80
+ t_start = 0
81
+ t_name = ""
82
+ t_lines: list[str] = []
83
+ for idx, line in enumerate(lines, start=1):
84
+ s = line.strip()
85
+ match = term_re.match(s)
86
+ if match:
87
+ if t_lines and t_name and len("\n".join(t_lines)) >= 30:
88
+ c_id = hashlib.sha256(f"{rel_path}:{t_start}:{t_name}".encode("utf-8")).hexdigest()[:16]
89
+ chunks.append({
90
+ "id": c_id,
91
+ "path": rel_path,
92
+ "abs_path": str(path.resolve()),
93
+ "doc_type": "markdown",
94
+ "title": f"{path.name} > {t_name}",
95
+ "start_line": t_start,
96
+ "end_line": idx - 1,
97
+ "content": f"# {path.name} > {t_name}\n\n" + "\n".join(t_lines),
98
+ })
99
+ t_name = match.group(1) or match.group(2)
100
+ t_start = idx
101
+ t_lines = [line]
102
+ elif t_lines:
103
+ if s == "" and len(t_lines) >= 2 and not any(
104
+ lines[min(len(lines) - 1, idx)].strip().startswith(p) for p in ["-", "*", ">"]
105
+ ):
106
+ if len("\n".join(t_lines)) >= 30:
107
+ c_id = hashlib.sha256(f"{rel_path}:{t_start}:{t_name}".encode("utf-8")).hexdigest()[:16]
108
+ chunks.append({
109
+ "id": c_id,
110
+ "path": rel_path,
111
+ "abs_path": str(path.resolve()),
112
+ "doc_type": "markdown",
113
+ "title": f"{path.name} > {t_name}",
114
+ "start_line": t_start,
115
+ "end_line": idx,
116
+ "content": f"# {path.name} > {t_name}\n\n" + "\n".join(t_lines),
117
+ })
118
+ t_name = ""
119
+ t_lines = []
120
+ else:
121
+ t_lines.append(line)
122
+
123
+ if t_lines and t_name and len("\n".join(t_lines)) >= 30:
124
+ c_id = hashlib.sha256(f"{rel_path}:{t_start}:{t_name}".encode("utf-8")).hexdigest()[:16]
125
+ chunks.append({
126
+ "id": c_id,
127
+ "path": rel_path,
128
+ "abs_path": str(path.resolve()),
129
+ "doc_type": "markdown",
130
+ "title": f"{path.name} > {t_name}",
131
+ "start_line": t_start,
132
+ "end_line": len(lines),
133
+ "content": f"# {path.name} > {t_name}\n\n" + "\n".join(t_lines),
134
+ })
135
+
136
+ return chunks
137
+
138
+ def extract_code_doc(self, path: Path) -> list[dict[str, Any]]:
139
+ """Extract docstrings and comment blocks from source files."""
140
+ if path.suffix == ".py":
141
+ return self._extract_python(path)
142
+ return self._extract_generic_comments(path)
143
+
144
+ def _extract_python(self, path: Path) -> list[dict[str, Any]]:
145
+ try:
146
+ source = path.read_text(encoding="utf-8", errors="replace")
147
+ except Exception:
148
+ return []
149
+
150
+ try:
151
+ rel_path = str(path.relative_to(self.base_folder))
152
+ except ValueError:
153
+ rel_path = str(path)
154
+
155
+ chunks: list[dict[str, Any]] = []
156
+ try:
157
+ tree = ast.parse(source, filename=str(path))
158
+ except (SyntaxError, ValueError):
159
+ return self._extract_generic_comments(path)
160
+
161
+ lines = source.splitlines()
162
+
163
+ # Module docstring
164
+ module_doc = ast.get_docstring(tree)
165
+ if module_doc and len(module_doc.strip()) > 10:
166
+ doc_lines = len(module_doc.splitlines())
167
+ chunk_id = hashlib.sha256(f"{rel_path}:1:module".encode("utf-8")).hexdigest()[:16]
168
+ chunks.append({
169
+ "id": chunk_id,
170
+ "path": rel_path,
171
+ "abs_path": str(path.resolve()),
172
+ "doc_type": "code_doc",
173
+ "title": f"{path.name} (module docstring)",
174
+ "start_line": 1,
175
+ "end_line": min(len(lines), doc_lines + 5),
176
+ "content": f"Module {rel_path}:\n{module_doc.strip()}",
177
+ })
178
+
179
+ # Functions & Classes
180
+ for node in ast.walk(tree):
181
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
182
+ doc = ast.get_docstring(node)
183
+ start_l = getattr(node, "lineno", 1)
184
+ end_l = getattr(node, "end_lineno", start_l + 10)
185
+ def_line = lines[start_l - 1].strip() if start_l <= len(lines) else ""
186
+
187
+ decorators = []
188
+ for dec in getattr(node, "decorator_list", []):
189
+ d_line = getattr(dec, "lineno", None)
190
+ if d_line and d_line <= len(lines):
191
+ decorators.append(lines[d_line - 1].strip())
192
+
193
+ kind = "class" if isinstance(node, ast.ClassDef) else "function"
194
+ node_name = node.name
195
+
196
+ text_parts = []
197
+ if decorators:
198
+ text_parts.append("\n".join(decorators))
199
+ text_parts.append(def_line)
200
+ if doc:
201
+ text_parts.append(doc.strip())
202
+
203
+ text = "\n".join(text_parts).strip()
204
+ if len(text) > 30:
205
+ chunk_id = hashlib.sha256(f"{rel_path}:{start_l}:{node_name}".encode("utf-8")).hexdigest()[:16]
206
+ chunks.append({
207
+ "id": chunk_id,
208
+ "path": rel_path,
209
+ "abs_path": str(path.resolve()),
210
+ "doc_type": "code_doc",
211
+ "title": f"{path.name} > {node_name} ({kind})",
212
+ "start_line": start_l,
213
+ "end_line": end_l,
214
+ "content": f"{rel_path} ({kind} {node_name}):\n{text}",
215
+ })
216
+
217
+ return chunks
218
+
219
+ def _extract_generic_comments(self, path: Path) -> list[dict[str, Any]]:
220
+ try:
221
+ source = path.read_text(encoding="utf-8", errors="replace")
222
+ except Exception:
223
+ return []
224
+
225
+ try:
226
+ rel_path = str(path.relative_to(self.base_folder))
227
+ except ValueError:
228
+ rel_path = str(path)
229
+
230
+ lines = source.splitlines()
231
+ chunks: list[dict[str, Any]] = []
232
+
233
+ comment_block: list[str] = []
234
+ block_start = 1
235
+
236
+ for idx, line in enumerate(lines, start=1):
237
+ s = line.strip()
238
+ is_comment = False
239
+ comment_content = ""
240
+
241
+ if s.startswith(("//", "#", "--", ";", "/*", "*")):
242
+ is_comment = True
243
+ comment_content = re.sub(r"^(\/\/|#|--|;|\/\*|\*)\s*", "", s)
244
+
245
+ if is_comment and comment_content:
246
+ if not comment_block:
247
+ block_start = idx
248
+ comment_block.append(comment_content)
249
+ else:
250
+ if len(comment_block) >= 3:
251
+ text = "\n".join(comment_block).strip()
252
+ if len(text) > 40:
253
+ chunk_id = hashlib.sha256(f"{rel_path}:{block_start}:comment".encode("utf-8")).hexdigest()[:16]
254
+ chunks.append({
255
+ "id": chunk_id,
256
+ "path": rel_path,
257
+ "abs_path": str(path.resolve()),
258
+ "doc_type": "code_doc",
259
+ "title": f"{path.name}: comment (L{block_start}-{idx-1})",
260
+ "start_line": block_start,
261
+ "end_line": idx - 1,
262
+ "content": f"{rel_path} (L{block_start}-{idx-1}):\n{text}",
263
+ })
264
+ comment_block = []
265
+
266
+ if len(comment_block) >= 3:
267
+ text = "\n".join(comment_block).strip()
268
+ if len(text) > 40:
269
+ chunk_id = hashlib.sha256(f"{rel_path}:{block_start}:comment".encode("utf-8")).hexdigest()[:16]
270
+ chunks.append({
271
+ "id": chunk_id,
272
+ "path": rel_path,
273
+ "abs_path": str(path.resolve()),
274
+ "doc_type": "code_doc",
275
+ "title": f"{path.name}: comment (L{block_start}-{len(lines)})",
276
+ "start_line": block_start,
277
+ "end_line": len(lines),
278
+ "content": f"{rel_path} (L{block_start}-{len(lines)}):\n{text}",
279
+ })
280
+
281
+ return chunks
@@ -0,0 +1,279 @@
1
+ """LanceDB vector index management and hybrid semantic search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ import re
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ import lancedb
11
+
12
+ if TYPE_CHECKING:
13
+ from sentence_transformers import SentenceTransformer
14
+
15
+ from .config import (
16
+ DOC_SCHEMA,
17
+ EMBEDDING_MODEL_NAME,
18
+ get_cache_dir,
19
+ silence_stdio,
20
+ )
21
+ from .extractor import DocExtractor
22
+ from .tags import get_available_files
23
+
24
+ COMMON_STOPWORDS = {
25
+ "what", "is", "a", "an", "the", "in", "on", "of", "for", "to",
26
+ "and", "or", "how", "why", "where", "which", "does", "do", "can",
27
+ }
28
+
29
+
30
+ class VectorIndex:
31
+ """LanceDB index manager with SentenceTransformer embeddings and hybrid re-ranking."""
32
+
33
+ def __init__(self, folder: Path, custom_index_dir: str | None = None):
34
+ self.folder = folder
35
+ self.cache_dir = get_cache_dir(folder, custom_index_dir)
36
+ self.db_dir = self.cache_dir / "lancedb"
37
+ self.meta_file = self.cache_dir / "files_meta.json"
38
+ self.db = lancedb.connect(str(self.db_dir))
39
+ self._model: SentenceTransformer | None = None
40
+ self._ensure_table()
41
+
42
+ @property
43
+ def model(self) -> SentenceTransformer:
44
+ if self._model is None:
45
+ from sentence_transformers import SentenceTransformer
46
+ with silence_stdio():
47
+ try:
48
+ self._model = SentenceTransformer(EMBEDDING_MODEL_NAME, local_files_only=True)
49
+ except Exception:
50
+ self._model = SentenceTransformer(EMBEDDING_MODEL_NAME)
51
+ return self._model
52
+
53
+ def _ensure_table(self):
54
+ try:
55
+ self.table = self.db.open_table("documents")
56
+ except Exception:
57
+ self.table = self.db.create_table("documents", schema=DOC_SCHEMA, mode="create")
58
+
59
+ def load_meta(self) -> dict[str, dict[str, Any]]:
60
+ if self.meta_file.exists():
61
+ try:
62
+ return json.loads(self.meta_file.read_text(encoding="utf-8"))
63
+ except Exception:
64
+ return {}
65
+ return {}
66
+
67
+ def save_meta(self, meta: dict[str, dict[str, Any]]):
68
+ self.meta_file.write_text(json.dumps(meta, indent=2), encoding="utf-8")
69
+
70
+ def sync(self, force: bool = False) -> tuple[int, int, int]:
71
+ """Incrementally sync all available files into LanceDB.
72
+
73
+ Returns (files_updated, chunks_indexed, files_pruned).
74
+ """
75
+ extractor = DocExtractor(self.folder)
76
+ code_files, doc_files = get_available_files(self.folder)
77
+ all_files = code_files + doc_files
78
+
79
+ meta = {} if force else self.load_meta()
80
+ current_rel_paths: set[str] = set()
81
+
82
+ files_to_index: list[tuple[Path, list[dict[str, Any]]]] = []
83
+ all_chunks_to_embed: list[dict[str, Any]] = []
84
+
85
+ for fpath in all_files:
86
+ try:
87
+ rel_p = str(fpath.relative_to(self.folder))
88
+ except ValueError:
89
+ rel_p = str(fpath)
90
+ current_rel_paths.add(rel_p)
91
+
92
+ stat = fpath.stat()
93
+ last_meta = meta.get(rel_p)
94
+ if (
95
+ not force
96
+ and last_meta
97
+ and last_meta.get("mtime") == stat.st_mtime
98
+ and last_meta.get("size") == stat.st_size
99
+ ):
100
+ continue
101
+
102
+ if fpath.suffix.lower() in {".md", ".markdown", ".rst", ".adoc", ".org"}:
103
+ chunks = extractor.extract_markdown(fpath)
104
+ else:
105
+ chunks = extractor.extract_code_doc(fpath)
106
+
107
+ if chunks:
108
+ files_to_index.append((fpath, chunks))
109
+ all_chunks_to_embed.extend(chunks)
110
+
111
+ # Prune deleted files
112
+ deleted_paths = [p for p in meta.keys() if p not in current_rel_paths]
113
+ if deleted_paths:
114
+ for p in deleted_paths:
115
+ try:
116
+ escaped_p = p.replace('"', '\\"')
117
+ self.table.delete(f'path = "{escaped_p}"')
118
+ except Exception:
119
+ pass
120
+ meta.pop(p, None)
121
+
122
+ if force:
123
+ try:
124
+ self.db.drop_table("documents")
125
+ except Exception:
126
+ pass
127
+ self.table = self.db.create_table("documents", schema=DOC_SCHEMA, mode="create")
128
+ else:
129
+ for fpath, _ in files_to_index:
130
+ try:
131
+ rel_p = str(fpath.relative_to(self.folder))
132
+ except ValueError:
133
+ rel_p = str(fpath)
134
+ escaped_p = rel_p.replace('"', '\\"')
135
+ try:
136
+ self.table.delete(f'path = "{escaped_p}"')
137
+ except Exception:
138
+ pass
139
+
140
+ if all_chunks_to_embed:
141
+ texts = [c["content"] for c in all_chunks_to_embed]
142
+ embeddings = self.model.encode(
143
+ texts,
144
+ batch_size=256,
145
+ show_progress_bar=False,
146
+ normalize_embeddings=True,
147
+ )
148
+ for chunk, vec in zip(all_chunks_to_embed, embeddings):
149
+ chunk["vector"] = vec.tolist()
150
+
151
+ self.table.add(all_chunks_to_embed)
152
+
153
+ for fpath, chunks in files_to_index:
154
+ try:
155
+ rel_p = str(fpath.relative_to(self.folder))
156
+ except ValueError:
157
+ rel_p = str(fpath)
158
+ stat = fpath.stat()
159
+ meta[rel_p] = {
160
+ "mtime": stat.st_mtime,
161
+ "size": stat.st_size,
162
+ "chunks": len(chunks),
163
+ }
164
+
165
+ self.save_meta(meta)
166
+ return (len(files_to_index), len(all_chunks_to_embed), len(deleted_paths))
167
+
168
+ def update_single_file(self, fpath: Path) -> int:
169
+ """Incrementally update one file in LanceDB."""
170
+ try:
171
+ rel_p = str(fpath.relative_to(self.folder))
172
+ except ValueError:
173
+ rel_p = str(fpath)
174
+
175
+ escaped_p = rel_p.replace('"', '\\"')
176
+ try:
177
+ self.table.delete(f'path = "{escaped_p}"')
178
+ except Exception:
179
+ pass
180
+
181
+ if not fpath.exists():
182
+ meta = self.load_meta()
183
+ meta.pop(rel_p, None)
184
+ self.save_meta(meta)
185
+ return 0
186
+
187
+ extractor = DocExtractor(self.folder)
188
+ if fpath.suffix.lower() in {".md", ".markdown", ".rst", ".adoc", ".org"}:
189
+ chunks = extractor.extract_markdown(fpath)
190
+ else:
191
+ chunks = extractor.extract_code_doc(fpath)
192
+
193
+ if chunks:
194
+ texts = [c["content"] for c in chunks]
195
+ embeddings = self.model.encode(texts, batch_size=64, show_progress_bar=False, normalize_embeddings=True)
196
+ for chunk, vec in zip(chunks, embeddings):
197
+ chunk["vector"] = vec.tolist()
198
+ self.table.add(chunks)
199
+
200
+ meta = self.load_meta()
201
+ stat = fpath.stat()
202
+ meta[rel_p] = {
203
+ "mtime": stat.st_mtime,
204
+ "size": stat.st_size,
205
+ "chunks": len(chunks),
206
+ }
207
+ self.save_meta(meta)
208
+ return len(chunks)
209
+
210
+ def search(
211
+ self,
212
+ query: str,
213
+ limit: int = 5,
214
+ doc_type: str | None = None,
215
+ ) -> list[dict[str, Any]]:
216
+ """Hybrid semantic vector search with keyword & title match re-ranking."""
217
+ q_vec = self.model.encode(query, normalize_embeddings=True).tolist()
218
+ fetch_limit = max(limit * 4, 20)
219
+ search_query = self.table.search(q_vec).metric("cosine").limit(fetch_limit)
220
+
221
+ if doc_type and doc_type != "all":
222
+ norm_type = "markdown" if doc_type in ["md", "markdown"] else "code_doc" if doc_type in ["code", "code_doc"] else doc_type
223
+ search_query = search_query.where(f'doc_type = "{norm_type}"')
224
+
225
+ try:
226
+ raw_results = search_query.to_list()
227
+ except Exception:
228
+ raw_results = []
229
+
230
+ if not raw_results:
231
+ return []
232
+
233
+ # Extract significant query terms
234
+ clean_terms = [
235
+ w.lower()
236
+ for w in re.findall(r"[A-Za-z0-9_]+", query)
237
+ if len(w) >= 2 and w.lower() not in COMMON_STOPWORDS
238
+ ]
239
+ clean_phrase = " ".join(clean_terms)
240
+
241
+ scored_results: list[dict[str, Any]] = []
242
+ for r in raw_results:
243
+ dist = r.get("_distance", 0.0)
244
+ base_score = max(0.0, min(1.0, 1.0 - (dist / 2.0)))
245
+ score = base_score
246
+
247
+ title_lower = r.get("title", "").lower()
248
+ content_lower = r.get("content", "").lower()
249
+ dtype = r.get("doc_type", "")
250
+
251
+ # 1. Exact phrase match in title
252
+ if clean_phrase and clean_phrase in title_lower:
253
+ score += 0.12
254
+ # 2. Individual term matches in title
255
+ for term in clean_terms:
256
+ if term in title_lower:
257
+ score += 0.04
258
+
259
+ # 3. Term definitions or documentation boost
260
+ if dtype == "markdown":
261
+ score += 0.04
262
+
263
+ score = min(0.99, score)
264
+
265
+ scored_results.append({
266
+ "score": round(score, 3),
267
+ "base_score": round(base_score, 3),
268
+ "path": r["path"],
269
+ "abs_path": r["abs_path"],
270
+ "doc_type": r["doc_type"],
271
+ "title": r["title"],
272
+ "start_line": r["start_line"],
273
+ "end_line": r["end_line"],
274
+ "content": r["content"],
275
+ })
276
+
277
+ # Re-sort by boosted hybrid score
278
+ scored_results.sort(key=lambda x: x["score"], reverse=True)
279
+ return scored_results[:limit]