velune-cli 0.9.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.
- velune/__init__.py +5 -0
- velune/__main__.py +6 -0
- velune/cli/__init__.py +5 -0
- velune/cli/app.py +208 -0
- velune/cli/autocomplete.py +80 -0
- velune/cli/banner.py +60 -0
- velune/cli/commands/__init__.py +32 -0
- velune/cli/commands/ask.py +175 -0
- velune/cli/commands/base.py +16 -0
- velune/cli/commands/chat.py +228 -0
- velune/cli/commands/config.py +224 -0
- velune/cli/commands/daemon.py +88 -0
- velune/cli/commands/doctor.py +721 -0
- velune/cli/commands/init.py +170 -0
- velune/cli/commands/mcp.py +82 -0
- velune/cli/commands/memory.py +293 -0
- velune/cli/commands/models.py +683 -0
- velune/cli/commands/preflight.py +95 -0
- velune/cli/commands/run.py +270 -0
- velune/cli/commands/setup.py +184 -0
- velune/cli/commands/workspace.py +249 -0
- velune/cli/context.py +36 -0
- velune/cli/councilmodel_ui.py +199 -0
- velune/cli/display/council_view.py +254 -0
- velune/cli/display/memory_view.py +126 -0
- velune/cli/display/panels.py +35 -0
- velune/cli/display/progress.py +25 -0
- velune/cli/display/themes.py +25 -0
- velune/cli/main.py +15 -0
- velune/cli/model_selector.py +51 -0
- velune/cli/modes.py +86 -0
- velune/cli/pull_ui.py +123 -0
- velune/cli/registry.py +80 -0
- velune/cli/rendering/__init__.py +5 -0
- velune/cli/rendering/error_panel.py +79 -0
- velune/cli/rendering/markdown.py +63 -0
- velune/cli/repl.py +1855 -0
- velune/cli/session_manager.py +71 -0
- velune/cli/slash_commands.py +37 -0
- velune/cli/theme.py +8 -0
- velune/cognition/__init__.py +23 -0
- velune/cognition/agents/__init__.py +7 -0
- velune/cognition/agents/coder.py +209 -0
- velune/cognition/agents/planner.py +156 -0
- velune/cognition/agents/reviewer.py +195 -0
- velune/cognition/arbitrator.py +220 -0
- velune/cognition/architecture.py +415 -0
- velune/cognition/budget.py +65 -0
- velune/cognition/council/__init__.py +47 -0
- velune/cognition/council/base.py +217 -0
- velune/cognition/council/challenger.py +74 -0
- velune/cognition/council/coder.py +79 -0
- velune/cognition/council/critic_agent.py +43 -0
- velune/cognition/council/critic_configs.py +111 -0
- velune/cognition/council/critics.py +41 -0
- velune/cognition/council/debate.py +46 -0
- velune/cognition/council/factory.py +140 -0
- velune/cognition/council/messages.py +56 -0
- velune/cognition/council/planner.py +124 -0
- velune/cognition/council/reviewer.py +74 -0
- velune/cognition/council/synthesizer.py +67 -0
- velune/cognition/council/tiers.py +188 -0
- velune/cognition/council_orchestrator.py +282 -0
- velune/cognition/firewall.py +354 -0
- velune/cognition/module.py +46 -0
- velune/cognition/orchestrator.py +1205 -0
- velune/cognition/personality.py +238 -0
- velune/cognition/state.py +104 -0
- velune/cognition/style_resolver.py +64 -0
- velune/cognition/verification.py +205 -0
- velune/context/__init__.py +28 -0
- velune/context/assembler.py +240 -0
- velune/context/budget.py +97 -0
- velune/context/extractive.py +95 -0
- velune/context/prompt_adaptation.py +480 -0
- velune/context/sections.py +99 -0
- velune/context/token_counter.py +134 -0
- velune/context/utilization.py +33 -0
- velune/context/window.py +63 -0
- velune/core/__init__.py +89 -0
- velune/core/background.py +5 -0
- velune/core/config/__init__.py +37 -0
- velune/core/errors/__init__.py +90 -0
- velune/core/errors/catalog.py +188 -0
- velune/core/errors/execution.py +31 -0
- velune/core/errors/memory.py +25 -0
- velune/core/errors/orchestration.py +31 -0
- velune/core/errors/provider.py +37 -0
- velune/core/event_loop.py +35 -0
- velune/core/logging.py +83 -0
- velune/core/paths.py +165 -0
- velune/core/runtime.py +113 -0
- velune/core/startup_profiler.py +56 -0
- velune/core/task_registry.py +117 -0
- velune/core/trace.py +83 -0
- velune/core/types/__init__.py +48 -0
- velune/core/types/agent.py +53 -0
- velune/core/types/context.py +42 -0
- velune/core/types/inference.py +38 -0
- velune/core/types/memory.py +42 -0
- velune/core/types/model.py +70 -0
- velune/core/types/provider.py +62 -0
- velune/core/types/repository.py +38 -0
- velune/core/types/task.py +61 -0
- velune/core/types/workspace.py +28 -0
- velune/daemon/client.py +13 -0
- velune/daemon/server.py +127 -0
- velune/daemon/transport.py +179 -0
- velune/events.py +204 -0
- velune/execution/__init__.py +22 -0
- velune/execution/benchmarker.py +315 -0
- velune/execution/cancellation.py +53 -0
- velune/execution/checkpointer.py +130 -0
- velune/execution/command_spec.py +165 -0
- velune/execution/diff_preview.py +197 -0
- velune/execution/executor.py +181 -0
- velune/execution/module.py +18 -0
- velune/execution/multi_diff.py +67 -0
- velune/execution/path_guard.py +74 -0
- velune/execution/planner.py +91 -0
- velune/execution/rollback.py +89 -0
- velune/execution/sandbox.py +268 -0
- velune/execution/validator.py +115 -0
- velune/hardware/__init__.py +1 -0
- velune/hardware/detector.py +192 -0
- velune/kernel/__init__.py +55 -0
- velune/kernel/bootstrap.py +125 -0
- velune/kernel/config.py +426 -0
- velune/kernel/entrypoint.py +78 -0
- velune/kernel/health.py +54 -0
- velune/kernel/lifecycle.py +143 -0
- velune/kernel/module.py +17 -0
- velune/kernel/modules.py +23 -0
- velune/kernel/registry.py +96 -0
- velune/kernel/schemas.py +28 -0
- velune/main.py +9 -0
- velune/mcp/__init__.py +9 -0
- velune/mcp/client.py +115 -0
- velune/mcp/config.py +19 -0
- velune/mcp/server.py +624 -0
- velune/memory/__init__.py +32 -0
- velune/memory/compaction.py +506 -0
- velune/memory/embedding_pipeline.py +241 -0
- velune/memory/lifecycle.py +680 -0
- velune/memory/module.py +218 -0
- velune/memory/prioritizer.py +67 -0
- velune/memory/storage/episodic_schema.sql +53 -0
- velune/memory/storage/lancedb_store.py +282 -0
- velune/memory/storage/sqlite_manager.py +369 -0
- velune/memory/storage/sqlite_pool.py +149 -0
- velune/memory/tiers/episodic.py +588 -0
- velune/memory/tiers/graph.py +378 -0
- velune/memory/tiers/lineage.py +416 -0
- velune/memory/tiers/semantic.py +475 -0
- velune/memory/tiers/working.py +168 -0
- velune/memory/vitality.py +132 -0
- velune/models/__init__.py +15 -0
- velune/models/family.py +76 -0
- velune/models/module.py +20 -0
- velune/models/probes.py +192 -0
- velune/models/profile_cache.py +84 -0
- velune/models/profiler.py +108 -0
- velune/models/registry.py +251 -0
- velune/models/scorer.py +233 -0
- velune/models/specializations.py +205 -0
- velune/orchestration/__init__.py +19 -0
- velune/orchestration/engine.py +239 -0
- velune/orchestration/module.py +15 -0
- velune/orchestration/role_assignments.py +82 -0
- velune/orchestration/schemas.py +98 -0
- velune/plugins/__init__.py +20 -0
- velune/plugins/hooks.py +50 -0
- velune/plugins/loader.py +161 -0
- velune/plugins/registry.py +56 -0
- velune/plugins/schemas.py +21 -0
- velune/providers/__init__.py +23 -0
- velune/providers/adapters/anthropic.py +257 -0
- velune/providers/adapters/fireworks.py +115 -0
- velune/providers/adapters/google.py +234 -0
- velune/providers/adapters/groq.py +151 -0
- velune/providers/adapters/huggingface.py +210 -0
- velune/providers/adapters/llamacpp.py +208 -0
- velune/providers/adapters/lmstudio.py +175 -0
- velune/providers/adapters/ollama.py +233 -0
- velune/providers/adapters/openai.py +213 -0
- velune/providers/adapters/openrouter.py +81 -0
- velune/providers/adapters/together.py +134 -0
- velune/providers/adapters/xai.py +60 -0
- velune/providers/base.py +86 -0
- velune/providers/benchmarker.py +138 -0
- velune/providers/discovery/__init__.py +33 -0
- velune/providers/discovery/anthropic.py +79 -0
- velune/providers/discovery/benchmarks.py +44 -0
- velune/providers/discovery/classifier.py +69 -0
- velune/providers/discovery/fireworks.py +95 -0
- velune/providers/discovery/gguf.py +88 -0
- velune/providers/discovery/google.py +95 -0
- velune/providers/discovery/gpu.py +117 -0
- velune/providers/discovery/groq.py +21 -0
- velune/providers/discovery/huggingface.py +67 -0
- velune/providers/discovery/lmstudio.py +80 -0
- velune/providers/discovery/ollama.py +162 -0
- velune/providers/discovery/openai.py +96 -0
- velune/providers/discovery/openrouter.py +113 -0
- velune/providers/discovery/scanner.py +115 -0
- velune/providers/discovery/together.py +114 -0
- velune/providers/discovery/xai.py +57 -0
- velune/providers/health.py +67 -0
- velune/providers/health_monitor.py +169 -0
- velune/providers/keystore.py +142 -0
- velune/providers/local_paths.py +49 -0
- velune/providers/local_resolver.py +229 -0
- velune/providers/module.py +51 -0
- velune/providers/ollama_manager.py +193 -0
- velune/providers/registry.py +220 -0
- velune/providers/router.py +255 -0
- velune/providers/task_classifier.py +288 -0
- velune/py.typed +0 -0
- velune/repository/__init__.py +33 -0
- velune/repository/analyzer.py +127 -0
- velune/repository/ast_parser.py +822 -0
- velune/repository/blast_radius.py +298 -0
- velune/repository/boundary_classifier.py +295 -0
- velune/repository/cognition.py +316 -0
- velune/repository/grapher.py +179 -0
- velune/repository/import_graph.py +263 -0
- velune/repository/incremental_indexer.py +275 -0
- velune/repository/index_state.py +96 -0
- velune/repository/indexer.py +243 -0
- velune/repository/module.py +17 -0
- velune/repository/parser.py +474 -0
- velune/repository/project_type.py +300 -0
- velune/repository/rename_journal.py +287 -0
- velune/repository/scanner.py +193 -0
- velune/repository/schemas.py +102 -0
- velune/repository/symbol_registry.py +365 -0
- velune/repository/tracker.py +252 -0
- velune/retrieval/__init__.py +27 -0
- velune/retrieval/cache.py +110 -0
- velune/retrieval/fast_path.py +391 -0
- velune/retrieval/graph.py +124 -0
- velune/retrieval/hybrid.py +271 -0
- velune/retrieval/keyword.py +131 -0
- velune/retrieval/module.py +26 -0
- velune/retrieval/pipeline.py +303 -0
- velune/retrieval/reranker.py +102 -0
- velune/retrieval/schemas.py +59 -0
- velune/retrieval/slow_path.py +364 -0
- velune/retrieval/vector.py +203 -0
- velune/telemetry/__init__.py +59 -0
- velune/telemetry/cognition.py +267 -0
- velune/telemetry/cost_estimator.py +92 -0
- velune/telemetry/debug.py +304 -0
- velune/telemetry/doctor.py +244 -0
- velune/telemetry/logging.py +286 -0
- velune/telemetry/spans.py +277 -0
- velune/telemetry/token_tracker.py +140 -0
- velune/telemetry/usage_tracker.py +340 -0
- velune/tools/__init__.py +41 -0
- velune/tools/base/registry.py +87 -0
- velune/tools/base/tool.py +63 -0
- velune/tools/code/navigate.py +116 -0
- velune/tools/code/search.py +123 -0
- velune/tools/filesystem/read.py +75 -0
- velune/tools/filesystem/search.py +136 -0
- velune/tools/filesystem/write.py +163 -0
- velune/tools/git/history.py +177 -0
- velune/tools/git/operations.py +122 -0
- velune/tools/git/state.py +121 -0
- velune/tools/module.py +81 -0
- velune/tools/terminal/execute.py +72 -0
- velune/tools/terminal/history.py +47 -0
- velune/tools/web/fetch.py +55 -0
- velune/tools/web/validator.py +122 -0
- velune_cli-0.9.0.dist-info/METADATA +518 -0
- velune_cli-0.9.0.dist-info/RECORD +279 -0
- velune_cli-0.9.0.dist-info/WHEEL +4 -0
- velune_cli-0.9.0.dist-info/entry_points.txt +2 -0
- velune_cli-0.9.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger("velune.retrieval.hybrid")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from velune.retrieval.graph import GraphRetriever
|
|
9
|
+
from velune.retrieval.keyword import BM25Retriever
|
|
10
|
+
from velune.retrieval.reranker import CrossEncoderReranker
|
|
11
|
+
from velune.retrieval.schemas import RetrievalHit, RetrievalQuery, RetrievalResult
|
|
12
|
+
from velune.retrieval.vector import VectorRetriever
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HybridRetriever:
|
|
16
|
+
"""Orchestrates fusion retrieval, combining Lexical, Vector, and Graph traversals.
|
|
17
|
+
|
|
18
|
+
Primary interface: await retrieve(). search() is sync-only.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
location: str = ":memory:",
|
|
24
|
+
client: Any | None = None,
|
|
25
|
+
client_provider: Any | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.vector_retriever = VectorRetriever(
|
|
28
|
+
location=location, client=client, client_provider=client_provider
|
|
29
|
+
)
|
|
30
|
+
self.lexical_retriever = BM25Retriever()
|
|
31
|
+
self.graph_retriever = GraphRetriever()
|
|
32
|
+
self.reranker = CrossEncoderReranker()
|
|
33
|
+
|
|
34
|
+
def add_documents(self, docs: list[Any]) -> None:
|
|
35
|
+
"""Adds and indexes documents in both vector and lexical subsystems.
|
|
36
|
+
|
|
37
|
+
All documents must have a pre-computed embedding.
|
|
38
|
+
"""
|
|
39
|
+
# Index in Lexical (BM25)
|
|
40
|
+
self.lexical_retriever.add_documents(docs)
|
|
41
|
+
|
|
42
|
+
# Index in Vector (Qdrant)
|
|
43
|
+
for doc in docs:
|
|
44
|
+
# Require embedding to be pre-computed (make embedding field required, not optional)
|
|
45
|
+
if not doc.embedding:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Document {doc.id} must have a pre-computed embedding. "
|
|
48
|
+
"All callers of add_documents() must pre-compute embeddings using await before calling."
|
|
49
|
+
)
|
|
50
|
+
self.vector_retriever.upsert(doc)
|
|
51
|
+
|
|
52
|
+
async def retrieve(self, query: RetrievalQuery) -> RetrievalResult:
|
|
53
|
+
"""Performs full hybrid retrieval, merges candidate pools, and reranks."""
|
|
54
|
+
lexical_hits: list[RetrievalHit] = []
|
|
55
|
+
vector_hits: list[RetrievalHit] = []
|
|
56
|
+
graph_hits: list[RetrievalHit] = []
|
|
57
|
+
|
|
58
|
+
# 1. Execute Lexical search (BM25)
|
|
59
|
+
if query.lexical_weight > 0.0:
|
|
60
|
+
try:
|
|
61
|
+
lexical_hits = self.lexical_retriever.retrieve(
|
|
62
|
+
query.text, top_k=query.top_k, namespace=query.namespace
|
|
63
|
+
)
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
# 2. Execute Vector search (Qdrant)
|
|
68
|
+
if query.vector_weight > 0.0:
|
|
69
|
+
try:
|
|
70
|
+
# Generate embedding for the query
|
|
71
|
+
emb = await self._generate_embedding_async(query.text)
|
|
72
|
+
if emb is not None: # Only do vector search if real embedding
|
|
73
|
+
vector_hits = self.vector_retriever.retrieve(
|
|
74
|
+
emb, top_k=query.top_k, namespace=query.namespace
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
logger.info("Vector retrieval skipped — no embedding available")
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
# 3. Execute Graph traversal search
|
|
82
|
+
# If we have hits from lexical or vector search, traverse neighboring file links
|
|
83
|
+
if query.graph_weight > 0.0:
|
|
84
|
+
seed_nodes = []
|
|
85
|
+
# Gather file path candidates
|
|
86
|
+
for hit in lexical_hits[:3] + vector_hits[:3]:
|
|
87
|
+
path = hit.document.metadata.get("path")
|
|
88
|
+
if path:
|
|
89
|
+
seed_nodes.append(path)
|
|
90
|
+
name = hit.document.metadata.get("name")
|
|
91
|
+
if name:
|
|
92
|
+
seed_nodes.append(name)
|
|
93
|
+
|
|
94
|
+
for node in set(seed_nodes):
|
|
95
|
+
try:
|
|
96
|
+
gh = self.graph_retriever.retrieve(node, depth=1, top_k=5)
|
|
97
|
+
graph_hits.extend(gh)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
# 4. Fusion and Deduplication
|
|
102
|
+
merged_hits_map: dict[str, RetrievalHit] = {}
|
|
103
|
+
|
|
104
|
+
# Helper to blend weights into score
|
|
105
|
+
def merge_hit(hit: RetrievalHit, weight: float) -> None:
|
|
106
|
+
doc_id = hit.document.id
|
|
107
|
+
weighted_score = hit.score * weight
|
|
108
|
+
|
|
109
|
+
if doc_id in merged_hits_map:
|
|
110
|
+
# Combine scores from multiple search strategies
|
|
111
|
+
existing = merged_hits_map[doc_id]
|
|
112
|
+
existing.score += weighted_score
|
|
113
|
+
else:
|
|
114
|
+
hit.score = weighted_score
|
|
115
|
+
merged_hits_map[doc_id] = hit
|
|
116
|
+
|
|
117
|
+
for h in lexical_hits:
|
|
118
|
+
merge_hit(h, query.lexical_weight)
|
|
119
|
+
for h in vector_hits:
|
|
120
|
+
merge_hit(h, query.vector_weight)
|
|
121
|
+
for h in graph_hits:
|
|
122
|
+
merge_hit(h, query.graph_weight)
|
|
123
|
+
|
|
124
|
+
all_hits = list(merged_hits_map.values())
|
|
125
|
+
|
|
126
|
+
# 5. Rerank final combined candidates
|
|
127
|
+
reranked_hits = self.reranker.rerank(all_hits, query.text)
|
|
128
|
+
|
|
129
|
+
return RetrievalResult(
|
|
130
|
+
query=query, hits=reranked_hits[: query.top_k], strategy="hybrid-fusion-reranked"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def search_sync(self, query: RetrievalQuery) -> RetrievalResult:
|
|
134
|
+
"""Synchronous retrieval. DEPRECATED: use await retrieve() in async contexts.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
RuntimeError: If called from within a running event loop.
|
|
138
|
+
"""
|
|
139
|
+
import asyncio
|
|
140
|
+
import warnings
|
|
141
|
+
|
|
142
|
+
warnings.warn(
|
|
143
|
+
"HybridRetriever.search_sync() is deprecated and will be removed in a future version. "
|
|
144
|
+
"Use 'await retriever.retrieve(query)' instead.",
|
|
145
|
+
DeprecationWarning,
|
|
146
|
+
stacklevel=2,
|
|
147
|
+
)
|
|
148
|
+
try:
|
|
149
|
+
asyncio.get_running_loop()
|
|
150
|
+
raise RuntimeError(
|
|
151
|
+
"HybridRetriever.search_sync() cannot be called from an async context. "
|
|
152
|
+
"Use 'await retriever.retrieve(query)' instead."
|
|
153
|
+
)
|
|
154
|
+
except RuntimeError as e:
|
|
155
|
+
if "cannot be called" in str(e):
|
|
156
|
+
raise
|
|
157
|
+
# No running loop — safe to delegate to run_async().
|
|
158
|
+
|
|
159
|
+
from velune.kernel.entrypoint import run_async
|
|
160
|
+
|
|
161
|
+
return run_async(self.retrieve(query))
|
|
162
|
+
|
|
163
|
+
def search(self, query: RetrievalQuery) -> RetrievalResult:
|
|
164
|
+
"""Synchronous interface. Do NOT call from within a running event loop.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
RuntimeError: If called from within a running event loop.
|
|
168
|
+
"""
|
|
169
|
+
import asyncio
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
asyncio.get_running_loop()
|
|
173
|
+
raise RuntimeError(
|
|
174
|
+
"HybridRetriever.search() cannot be called from an async context. "
|
|
175
|
+
"Use 'await retriever.retrieve()' instead."
|
|
176
|
+
)
|
|
177
|
+
except RuntimeError as e:
|
|
178
|
+
if "cannot be called" in str(e):
|
|
179
|
+
raise
|
|
180
|
+
# No running loop — safe to proceed.
|
|
181
|
+
|
|
182
|
+
from velune.kernel.entrypoint import run_async
|
|
183
|
+
|
|
184
|
+
return run_async(self.retrieve(query))
|
|
185
|
+
|
|
186
|
+
async def check_embedding_available(self) -> bool:
|
|
187
|
+
"""Returns True if a real embedding provider is available."""
|
|
188
|
+
try:
|
|
189
|
+
test_emb = await self._generate_embedding_async("test")
|
|
190
|
+
return test_emb is not None
|
|
191
|
+
except Exception:
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
def _deterministic_fallback_embedding(self, text: str) -> list[float]:
|
|
195
|
+
"""Sophisticated deterministic fallback embedding vector."""
|
|
196
|
+
res = [0.0] * 1536
|
|
197
|
+
for idx, char in enumerate(text[:300]):
|
|
198
|
+
res[idx % 1536] += ord(char) / 256.0
|
|
199
|
+
return res
|
|
200
|
+
|
|
201
|
+
async def _generate_embedding_async(self, text: str) -> list[float] | None:
|
|
202
|
+
"""Generates embedding asynchronously using the registered ModelProvider, or falls back to a deterministic vector.
|
|
203
|
+
|
|
204
|
+
INTERNAL ONLY.
|
|
205
|
+
"""
|
|
206
|
+
try:
|
|
207
|
+
from velune.kernel.registry import get_container
|
|
208
|
+
|
|
209
|
+
container = get_container()
|
|
210
|
+
if container.has("runtime.provider_registry"):
|
|
211
|
+
provider_registry = container.get("runtime.provider_registry")
|
|
212
|
+
config = (
|
|
213
|
+
container.get("runtime.config") if container.has("runtime.config") else None
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
provider_name = "openai"
|
|
217
|
+
if config and hasattr(config, "providers") and config.providers:
|
|
218
|
+
provider_name = config.providers.default_provider
|
|
219
|
+
|
|
220
|
+
provider = provider_registry.get(provider_name)
|
|
221
|
+
if provider:
|
|
222
|
+
try:
|
|
223
|
+
caps = provider.get_capabilities()
|
|
224
|
+
if not caps.supports_embeddings:
|
|
225
|
+
logger.info(
|
|
226
|
+
"Provider %s does not support embeddings. Skipping vector embedding generation.",
|
|
227
|
+
provider_name,
|
|
228
|
+
)
|
|
229
|
+
return None
|
|
230
|
+
except Exception as e:
|
|
231
|
+
logger.warning(
|
|
232
|
+
"Could not query capabilities for provider %s: %s", provider_name, e
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
model_id = "text-embedding-3-small"
|
|
236
|
+
if provider_name == "ollama":
|
|
237
|
+
model_id = "nomic-embed-text"
|
|
238
|
+
|
|
239
|
+
res = await provider.embed([text], model_id=model_id)
|
|
240
|
+
emb = res[0] if res else None
|
|
241
|
+
if emb:
|
|
242
|
+
logger.debug(
|
|
243
|
+
"Generated embedding: dim=%d, provider=%s", len(emb), provider_name
|
|
244
|
+
)
|
|
245
|
+
return emb
|
|
246
|
+
except Exception as e:
|
|
247
|
+
import logging
|
|
248
|
+
|
|
249
|
+
logging.getLogger("velune.retrieval.hybrid").warning(
|
|
250
|
+
"Failed to generate embedding using ModelProvider: %s. Falling back to deterministic embedding.",
|
|
251
|
+
e,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# No provider available
|
|
255
|
+
allow_fallback = (
|
|
256
|
+
os.environ.get("VELUNE_ALLOW_FALLBACK_EMBEDDING", "false").lower() == "true"
|
|
257
|
+
)
|
|
258
|
+
if allow_fallback:
|
|
259
|
+
logger.warning(
|
|
260
|
+
"Using character-frequency fallback embedding. "
|
|
261
|
+
"Semantic retrieval results will be degraded. "
|
|
262
|
+
"Install an embedding model (e.g., ollama pull nomic-embed-text) "
|
|
263
|
+
"or set OPENAI_API_KEY to enable real embeddings."
|
|
264
|
+
)
|
|
265
|
+
return self._deterministic_fallback_embedding(text)
|
|
266
|
+
|
|
267
|
+
logger.warning(
|
|
268
|
+
"No embedding provider available. Vector retrieval disabled. "
|
|
269
|
+
"Set VELUNE_ALLOW_FALLBACK_EMBEDDING=true to enable degraded mode."
|
|
270
|
+
)
|
|
271
|
+
return None
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""BM25 Lexical retrieval layer for exact keyword matches."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from rank_bm25 import BM25Okapi
|
|
9
|
+
|
|
10
|
+
from velune.retrieval.schemas import RetrievalDocument, RetrievalHit, RetrievalSource
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("velune.retrieval.keyword")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BM25Retriever:
|
|
16
|
+
"""Retrieves context using BM25 exact keyword lexical indexing."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self.documents: list[RetrievalDocument] = []
|
|
20
|
+
self.corpus: list[list[str]] = []
|
|
21
|
+
self.bm25: BM25Okapi | None = None
|
|
22
|
+
self._dirty: bool = False # True when corpus needs rebuild
|
|
23
|
+
self._rebuild_lock = threading.Lock()
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def index_size(self) -> int:
|
|
27
|
+
return len(self.documents)
|
|
28
|
+
|
|
29
|
+
def add_documents(self, docs: list[RetrievalDocument]) -> None:
|
|
30
|
+
"""Appends new documents to the lexical index corpus and marks the index as dirty."""
|
|
31
|
+
for doc in docs:
|
|
32
|
+
self.documents.append(doc)
|
|
33
|
+
self.corpus.append(self._tokenize(doc.content))
|
|
34
|
+
self._dirty = True
|
|
35
|
+
self.bm25 = None # Invalidate current index
|
|
36
|
+
logger.debug("BM25 index marked dirty: %d total documents", len(self.documents))
|
|
37
|
+
|
|
38
|
+
def add_documents_batch(self, docs: list[RetrievalDocument]) -> None:
|
|
39
|
+
"""More efficient batch add — tokenizes new documents and appends them to corpus."""
|
|
40
|
+
new_tokens = [self._tokenize(doc.content) for doc in docs]
|
|
41
|
+
self.documents.extend(docs)
|
|
42
|
+
self.corpus.extend(new_tokens)
|
|
43
|
+
self._dirty = True
|
|
44
|
+
self.bm25 = None
|
|
45
|
+
logger.debug("BM25 index marked dirty: %d total documents", len(self.documents))
|
|
46
|
+
|
|
47
|
+
def _ensure_index(self) -> None:
|
|
48
|
+
"""Rebuild BM25 index if dirty. Thread-safe."""
|
|
49
|
+
if not self._dirty or not self.corpus:
|
|
50
|
+
return
|
|
51
|
+
with self._rebuild_lock:
|
|
52
|
+
if not self._dirty: # Double-check after acquiring lock
|
|
53
|
+
return
|
|
54
|
+
start_time = time.time()
|
|
55
|
+
self.bm25 = BM25Okapi(self.corpus)
|
|
56
|
+
self._dirty = False
|
|
57
|
+
elapsed = time.time() - start_time
|
|
58
|
+
logger.debug("BM25 index rebuilt: %d documents, %.3fs", len(self.documents), elapsed)
|
|
59
|
+
|
|
60
|
+
def retrieve(
|
|
61
|
+
self, query: str, top_k: int = 10, namespace: str | None = None
|
|
62
|
+
) -> list[RetrievalHit]:
|
|
63
|
+
"""Queries the BM25 lexical index and scores candidates."""
|
|
64
|
+
if not self.documents:
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
if self._dirty:
|
|
68
|
+
self._ensure_index()
|
|
69
|
+
|
|
70
|
+
if not self.bm25:
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
tokens = self._tokenize(query)
|
|
74
|
+
scores = self.bm25.get_scores(tokens)
|
|
75
|
+
|
|
76
|
+
# Pair scores with documents and index ranks
|
|
77
|
+
hits: list[RetrievalHit] = []
|
|
78
|
+
for i, score in enumerate(scores):
|
|
79
|
+
doc = self.documents[i]
|
|
80
|
+
|
|
81
|
+
# Match namespace filter if provided
|
|
82
|
+
if namespace and doc.namespace != namespace:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
if score > 0.0: # Only capture positive keyword matches
|
|
86
|
+
hits.append(
|
|
87
|
+
RetrievalHit(
|
|
88
|
+
document=doc, score=float(score), source=RetrievalSource.LEXICAL, rank=0
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Sort and return top candidates
|
|
93
|
+
hits.sort(key=lambda x: x.score, reverse=True)
|
|
94
|
+
|
|
95
|
+
# Trim list and apply proper sequential ranks
|
|
96
|
+
final_hits = hits[:top_k]
|
|
97
|
+
for idx, h in enumerate(final_hits):
|
|
98
|
+
h.rank = idx + 1
|
|
99
|
+
|
|
100
|
+
return final_hits
|
|
101
|
+
|
|
102
|
+
def _tokenize(self, text: str) -> list[str]:
|
|
103
|
+
"""Simplistic and quick alphanumeric tokenization ignoring standard case mappings."""
|
|
104
|
+
# Lowercase and split on non-alphanumeric boundaries
|
|
105
|
+
words = re.findall(r"\w+", text.lower())
|
|
106
|
+
|
|
107
|
+
# Remove extremely common short English stop words to filter noise
|
|
108
|
+
stop_words = {
|
|
109
|
+
"a",
|
|
110
|
+
"an",
|
|
111
|
+
"the",
|
|
112
|
+
"and",
|
|
113
|
+
"or",
|
|
114
|
+
"but",
|
|
115
|
+
"if",
|
|
116
|
+
"then",
|
|
117
|
+
"else",
|
|
118
|
+
"to",
|
|
119
|
+
"of",
|
|
120
|
+
"in",
|
|
121
|
+
"for",
|
|
122
|
+
"on",
|
|
123
|
+
"with",
|
|
124
|
+
"at",
|
|
125
|
+
"by",
|
|
126
|
+
"from",
|
|
127
|
+
"is",
|
|
128
|
+
"this",
|
|
129
|
+
"that",
|
|
130
|
+
}
|
|
131
|
+
return [w for w in words if w not in stop_words and len(w) > 1]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from velune.kernel.bootstrap import RuntimeEnvironment, SubsystemModule
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _create_hybrid_retriever(env: RuntimeEnvironment):
|
|
5
|
+
from velune.core.paths import qdrant_store_path
|
|
6
|
+
from velune.retrieval.hybrid import HybridRetriever
|
|
7
|
+
|
|
8
|
+
vector_path = str(qdrant_store_path(env.workspace))
|
|
9
|
+
semantic_tier = env.container.get("runtime.semantic_memory")
|
|
10
|
+
# Share the semantic tier's single Qdrant connection, but resolve it lazily
|
|
11
|
+
# via a provider so wiring retrieval at bootstrap does not open the store.
|
|
12
|
+
return HybridRetriever(
|
|
13
|
+
location=vector_path,
|
|
14
|
+
client_provider=lambda: semantic_tier.client,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
RETRIEVAL_MODULES = [
|
|
19
|
+
SubsystemModule(
|
|
20
|
+
name="retrieval",
|
|
21
|
+
factory=_create_hybrid_retriever,
|
|
22
|
+
container_key="runtime.retrieval",
|
|
23
|
+
lifecycle_key="retrieval",
|
|
24
|
+
dependencies=["runtime.semantic_memory"],
|
|
25
|
+
)
|
|
26
|
+
]
|