Skip to content

Commit 1e24fcd

Browse files
committed
feat: migrate to persistent storage (SQLite/LanceDB) and introduce exact query matching
- Storage Evolution: Replaced the legacy InMemoryChunkRepository with SqliteChunkRepository featuring FTS5 for BM25 search. - LanceDB Integration: Added LancedbVectorStore as a scalable vector storage backend, improving retrieval performance for large codebases. - Exact Query Engine: Implemented ExactQueryEngine to handle quoted queries, allowing for precise substring matching across the codebase. - Live Context Hydration: Introduced LiveSourceLoader to ContextSynthesizer, ensuring context bundles can be hydrated with the most up-to-date source code from the file system. - Architectural Refinement: Migrated KnowledgeStore to SQLite persistence and updated CLI/API defaults to use persistent stores. - Verification: Comprehensive updates to unit and integration tests to support the new persistence layer and verify the exact matching logic.
1 parent 02fb62c commit 1e24fcd

45 files changed

Lines changed: 1354 additions & 632 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ server = [
2424
search = [
2525
"faiss-cpu>=1.7.0",
2626
"numpy>=1.24.0",
27+
"lancedb>=0.12.0",
2728
]
2829
llm = [
2930
"openai>=1.0.0",
@@ -37,6 +38,7 @@ all = [
3738
"slowapi>=0.1.9",
3839
"faiss-cpu>=1.7.0",
3940
"numpy>=1.24.0",
41+
"lancedb>=0.12.0",
4042
"openai>=1.0.0",
4143
"google-genai>=0.3.0",
4244
"google-api-core>=2.29.0",

scripts/fix_swallowed.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import os
2+
import re
3+
4+
target_dir = "/Users/deepg/Desktop/KnowCode/src/knowcode"
5+
pattern = re.compile(r'except Exception:\s+pass')
6+
replacement = r'''except Exception as e:
7+
import logging
8+
logging.getLogger(__name__).warning("Ignored exception: %s", e)'''
9+
10+
def fix_swallowed(path):
11+
with open(path, 'r', encoding='utf-8') as f:
12+
content = f.read()
13+
14+
new_content = pattern.sub(replacement, content)
15+
if new_content != content:
16+
with open(path, 'w', encoding='utf-8') as f:
17+
f.write(new_content)
18+
print(f"Fixed {path}")
19+
20+
for root, _, files in os.walk(target_dir):
21+
for file in files:
22+
if file.endswith('.py'):
23+
fix_swallowed(os.path.join(root, file))

src/knowcode/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,10 @@
55
from knowcode.data_models import CodeChunk as CodeChunk
66
from knowcode.data_models import EmbeddingConfig as EmbeddingConfig
77
from knowcode.storage.chunk_repository import ChunkRepository as ChunkRepository
8-
from knowcode.storage.chunk_repository import InMemoryChunkRepository as InMemoryChunkRepository
98

109
__all__ = [
1110
"__version__",
1211
"ChunkRepository",
1312
"CodeChunk",
1413
"EmbeddingConfig",
15-
"InMemoryChunkRepository",
1614
]

src/knowcode/analysis/context_synthesizer.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""Context synthesizer for generating AI-ready context bundles."""
22

33
from dataclasses import dataclass
4-
from typing import Optional
4+
from typing import Optional, TYPE_CHECKING
5+
6+
if TYPE_CHECKING:
7+
from knowcode.analysis.live_source_loader import LiveSourceLoader
58

69
from knowcode.storage.knowledge_store import KnowledgeStore
710
from knowcode.data_models import Entity, EntityKind, TaskType
@@ -74,6 +77,7 @@ def __init__(
7477
store: KnowledgeStore,
7578
max_tokens: int = DEFAULT_MAX_TOKENS,
7679
model: str = "gpt-4",
80+
live_loader: Optional["LiveSourceLoader"] = None,
7781
) -> None:
7882
"""Initialize context synthesizer.
7983
@@ -85,6 +89,15 @@ def __init__(
8589
self.store = store
8690
self.max_tokens = max_tokens
8791
self.tokenizer = TokenCounter(model)
92+
self.live_loader = live_loader
93+
94+
def _get_entity_source(self, entity: Entity) -> str:
95+
"""Get entity source code, preferring live file if available."""
96+
if self.live_loader:
97+
live_source = self.live_loader.load_source(entity)
98+
if live_source is not None:
99+
return live_source
100+
return entity.source_code or ""
88101

89102
def synthesize(self, entity_id: str, summarize: bool = False) -> Optional[ContextBundle]:
90103
"""Synthesize context bundle for an entity.
@@ -146,14 +159,15 @@ def synthesize(self, entity_id: str, summarize: bool = False) -> Optional[Contex
146159
current_tokens += t
147160

148161
# Priority 2: Source Code (Huge consumer, often truncated)
149-
if entity.source_code and not summarize:
162+
source_code = self._get_entity_source(entity)
163+
if source_code and not summarize:
150164
code_header = "## Source Code\n\n```python\n"
151165
code_footer = "\n```"
152166
overhead = self.tokenizer.count_tokens(code_header + code_footer)
153167
remaining = self.max_tokens - current_tokens - overhead
154168

155169
if remaining > 100: # Only add if we have decent space
156-
code_body = entity.source_code
170+
code_body = source_code
157171
code_tokens = self.tokenizer.count_tokens(code_body)
158172

159173
if code_tokens > remaining:
@@ -211,7 +225,7 @@ def synthesize(self, entity_id: str, summarize: bool = False) -> Optional[Contex
211225
context_text = "\n\n---\n\n".join(sections)
212226

213227
# Check if we skipped source code but had it
214-
if entity.source_code and "## Source Code" not in context_text:
228+
if source_code and "## Source Code" not in context_text:
215229
is_truncated = True
216230

217231
return ContextBundle(
@@ -403,10 +417,11 @@ def synthesize_with_task(
403417
if entity.docstring:
404418
content_sections["docstring"] = f"## Description\n\n{entity.docstring}"
405419

406-
if entity.source_code and not summarize:
420+
source_code = self._get_entity_source(entity)
421+
if source_code and not summarize:
407422
code_header = "## Source Code\n\n```python\n"
408423
code_footer = "\n```"
409-
code_body = entity.source_code
424+
code_body = source_code
410425
# Pre-truncate if too long
411426
max_code_tokens = int(self.max_tokens * 0.5) # Reserve half for code max
412427
code_tokens = self.tokenizer.count_tokens(code_body)
@@ -518,7 +533,8 @@ def _calculate_sufficiency(
518533
score += weight
519534

520535
# Bonus for having source code (always valuable)
521-
if entity.source_code and "## Source Code" in context_text:
536+
source_code = self._get_entity_source(entity)
537+
if source_code and "## Source Code" in context_text:
522538
score += 0.2
523539
max_score += 0.2
524540

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Loader for authoritative source code reads from disk."""
2+
3+
from pathlib import Path
4+
from knowcode.data_models import Entity
5+
6+
7+
class LiveSourceLoader:
8+
"""Safely reads and slices live source files from disk."""
9+
10+
def __init__(self, root_dir: str | Path) -> None:
11+
"""Initialize with repository root directory."""
12+
self.root_dir = Path(root_dir)
13+
14+
def load_source(self, entity: Entity) -> str | None:
15+
"""Load live source code from disk for the given entity.
16+
17+
Args:
18+
entity: Target entity containing location data.
19+
20+
Returns:
21+
The source code string sliced from the live file,
22+
or None if the file is missing or reading fails.
23+
"""
24+
if not entity.location or not entity.location.file_path:
25+
return None
26+
27+
file_path = self.root_dir / entity.location.file_path
28+
if not file_path.exists():
29+
return None
30+
31+
try:
32+
with open(file_path, "r", encoding="utf-8") as f:
33+
lines = f.readlines()
34+
35+
start = entity.location.line_start - 1
36+
end = entity.location.line_end
37+
38+
if start < 0:
39+
start = 0
40+
if end > len(lines):
41+
end = len(lines)
42+
43+
if start >= len(lines):
44+
return None
45+
46+
return "".join(lines[start:end])
47+
except Exception:
48+
return None

src/knowcode/api/api.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -364,9 +364,10 @@ def trace_calls(
364364
up to the specified depth. Each result includes the call_depth indicating
365365
how many hops from the starting entity.
366366
"""
367-
return service.store.trace_calls(
367+
from typing import cast
368+
return cast(list[dict[str, Any]], service.store.trace_calls(
368369
entity_id, direction=direction.value, depth=depth, max_results=max_results
369-
)
370+
))
370371

371372

372373
@router.get("/impact/{entity_id:path}", summary="Impact Analysis")
@@ -387,4 +388,5 @@ def get_impact(
387388
- affected_files: Files that would need review
388389
- risk_score: 0.0-1.0 indicating modification risk
389390
"""
390-
return service.store.get_impact(entity_id, max_depth=max_depth)
391+
from typing import cast
392+
return cast(dict[str, Any], service.store.get_impact(entity_id, max_depth=max_depth))

src/knowcode/cli/cli.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,17 @@ def analyze(directory: str, output: str, ignore: tuple[str, ...], temporal: bool
105105
type=click.Path(exists=True, dir_okay=False),
106106
help="Path to configuration file (aimodels.yaml) for embedding models.",
107107
)
108+
@click.option(
109+
"--incremental",
110+
is_flag=True,
111+
help="Use incremental indexing to speed up subsequent builds.",
112+
)
108113
def build(
109114
directory: str,
110115
ignore: tuple[str, ...],
111116
temporal: bool,
112117
config: Optional[str],
118+
incremental: bool,
113119
) -> None:
114120
"""Build the knowledge base and semantic index for a directory.
115121
@@ -134,6 +140,7 @@ def build(
134140
output=str(target),
135141
ignore=list(ignore),
136142
temporal=temporal,
143+
incremental=incremental,
137144
)
138145

139146
click.echo("\n✓ Build complete!")
@@ -174,7 +181,12 @@ def build(
174181
type=click.Path(exists=True, dir_okay=False),
175182
help="Path to configuration file (aimodels.yaml) for embedding models.",
176183
)
177-
def index(directory: str, output: str, config: Optional[str]) -> None:
184+
@click.option(
185+
"--incremental",
186+
is_flag=True,
187+
help="Use incremental indexing to speed up subsequent builds.",
188+
)
189+
def index(directory: str, output: str, config: Optional[str], incremental: bool) -> None:
178190
"""Build semantic search index for a codebase.
179191
180192
DIRECTORY: Path to the codebase to index.
@@ -191,7 +203,27 @@ def index(directory: str, output: str, config: Optional[str]) -> None:
191203
provider = create_embedding_provider(app_config=app_config)
192204
indexer = Indexer(provider)
193205

194-
count = indexer.index_directory(directory)
206+
index_path = Path(output)
207+
if incremental and (index_path / "index_manifest.json").exists():
208+
from knowcode.storage.sqlite_chunk_repository import SqliteChunkRepository
209+
db_path = index_path / "chunks.db"
210+
indexer.chunk_repo = SqliteChunkRepository(db_path)
211+
212+
vector_backend = app_config.vector_backend
213+
dimension = provider.config.dimension
214+
if vector_backend == "lancedb":
215+
from knowcode.storage.lancedb_vector_store import LanceDBVectorStore
216+
vs_path = index_path / "vectors.lancedb"
217+
indexer.vector_store = LanceDBVectorStore(dimension=dimension, path=vs_path)
218+
else:
219+
from knowcode.storage.vector_store import VectorStore
220+
indexer.vector_store = VectorStore(dimension=dimension, index_path=index_path)
221+
222+
indexer.load(index_path)
223+
count = indexer.index_incremental(directory)
224+
else:
225+
count = indexer.index_directory(directory)
226+
195227
indexer.save(output)
196228

197229
click.echo(f"✓ Indexing complete! Created {count} chunks.")
@@ -607,7 +639,7 @@ def history(target: Optional[str], store: str, limit: int) -> None:
607639
changes = []
608640
for rel in rels:
609641
if rel.kind == RelationshipKind.CHANGED_BY:
610-
commit = knowledge.get_entity(rel.target_id) # type: ignore
642+
commit = knowledge.get_entity(rel.target_id)
611643
if commit:
612644
# Get modification stats from edge metadata
613645
stats = f"(+{rel.metadata.get('insertions', 0)}/-{rel.metadata.get('deletions', 0)})"
@@ -622,7 +654,7 @@ def history(target: Optional[str], store: str, limit: int) -> None:
622654

623655
for _, commit, stats in changes[:limit]:
624656
date = commit.metadata.get("date", "")
625-
click.echo(f" {date} {commit.name} {stats}: {commit.docstring.splitlines()[0]}") # type: ignore
657+
click.echo(f" {date} {commit.name} {stats}: {commit.docstring.splitlines()[0]}")
626658

627659

628660
@cli.command()

src/knowcode/config.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,15 @@ class AppConfig:
3333
"eval_models",
3434
"config",
3535
}
36-
KNOWN_CONFIG_KEYS = {"sufficiency_threshold", "hybrid_alpha", "reranker_top_k_multiplier"}
36+
KNOWN_CONFIG_KEYS = {"sufficiency_threshold", "hybrid_alpha", "reranker_top_k_multiplier", "vector_backend"}
3737

3838
models: list[ModelConfig] = field(default_factory=list)
3939
embedding_models: list[ModelConfig] = field(default_factory=list)
4040
reranking_models: list[ModelConfig] = field(default_factory=list)
4141
sufficiency_threshold: float = 0.8 # For local-first answering
4242
hybrid_alpha: float = 0.2
4343
reranker_top_k_multiplier: int = 5
44+
vector_backend: str = "lancedb"
4445

4546
@classmethod
4647
def load(cls, config_path: Optional[str] = None, strict: bool = False) -> "AppConfig":
@@ -91,6 +92,7 @@ def default(cls) -> "AppConfig":
9192
sufficiency_threshold=0.8,
9293
hybrid_alpha=0.2,
9394
reranker_top_k_multiplier=5,
95+
vector_backend="lancedb",
9496
)
9597

9698
@classmethod
@@ -165,6 +167,10 @@ def _load_from_yaml(cls, path: Path, strict: bool = False) -> "AppConfig":
165167
if not isinstance(reranker_top_k_multiplier, int):
166168
raise ValueError("'config.reranker_top_k_multiplier' must be an integer.")
167169

170+
vector_backend = config_section.get("vector_backend", "lancedb")
171+
if not isinstance(vector_backend, str) or vector_backend not in ("faiss", "lancedb"):
172+
raise ValueError("'config.vector_backend' must be 'faiss' or 'lancedb'.")
173+
168174
if not models:
169175
models = cls.default().models
170176

@@ -175,6 +181,7 @@ def _load_from_yaml(cls, path: Path, strict: bool = False) -> "AppConfig":
175181
sufficiency_threshold=sufficiency_threshold,
176182
hybrid_alpha=hybrid_alpha,
177183
reranker_top_k_multiplier=reranker_top_k_multiplier,
184+
vector_backend=vector_backend,
178185
)
179186
except Exception as e:
180187
message = f"Failed to load config from {path}: {e}"

0 commit comments

Comments
 (0)