Retrieval policies¶
Modules: zettelforge.intent_classifier, zettelforge.vector_retriever, zettelforge.graph_retriever, zettelforge.blended_retriever
When you call mm.recall(), ZettelForge classifies your query's intent, routes it through parallel vector and graph retrievers, and blends the results using intent-specific policy weights. This page documents every parameter and formula in that pipeline for ZettelForge 2.7.0.
Intent types¶
from zettelforge.intent_classifier import QueryIntent
class QueryIntent(Enum):
FACTUAL = "factual"
TEMPORAL = "temporal"
RELATIONAL = "relational"
CAUSAL = "causal"
EXPLORATORY = "exploratory"
UNKNOWN = "unknown"
| Intent | Description | Example query |
|---|---|---|
FACTUAL |
Entity lookup, direct fact retrieval | "What CVE was used in the SolarWinds attack?" |
TEMPORAL |
Time-based queries, timeline reconstruction | "What changed since the last incident?" |
RELATIONAL |
Graph traversal, relationship mapping | "Who uses Cobalt Strike?" |
CAUSAL |
Cause-effect chains | "Why did the attacker pivot to the domain controller?" |
EXPLORATORY |
General exploration, broad context | "Tell me about APT28" |
UNKNOWN |
Ambiguous or unclassifiable | Fallback when no keywords match |
Intent classification¶
Keyword matching¶
The classifier counts keyword hits against the query (lowercased) for each intent.
FACTUAL keywords
"cve-" "cve " "vulnerability" "exploit" "threat"
"what is" "what was" "who is" "name" "identify"
TEMPORAL keywords
"when" "timeline" "since" "before" "after" "changed"
"history" "previously" "earlier" "latest" "recent"
RELATIONAL keywords
"who uses" "who targets" "who conducts" "what tools does" "what malware does"
"what technique" "what cve does" "used by" "attributed to" "related to"
"connected to" "associated with" "linked to" "uses tool" "between"
"relationship" "connection" "link" "which actor" "which group" "which apt"
"what does"
CAUSAL keywords
"why" "because" "caused by" "enables" "leads to"
"results in" "due to" "reason for"
EXPLORATORY keywords
"tell me about" "explain" "describe" "overview" "information on"
"details about" "context" "summarize" "what do we know" "brief"
Classification decision tree¶
1. Score each intent: count keyword hits in lowercased query.
2. best_score = max score across all intents.
competing = count of other intents with score > 0.
3. If best_score >= 2:
→ return best intent, confidence = min(1.0, best_score / 4), method = "keyword"
4. If best_score == 1 AND competing == 0:
→ return best intent, confidence = 0.6, method = "keyword_unambiguous"
(clear single-keyword match with no competing signal)
5. If use_llm_fallback is True:
→ call LLM, return intent, confidence = 0.8, method = "llm"
(on LLM failure: EXPLORATORY, confidence = 0.5, method = "llm_fallback")
6. Default fallback:
→ EXPLORATORY, confidence = 0.3, method = "default"
The global IntentClassifier instance (returned by get_intent_classifier()) is created with use_llm_fallback=False. The LLM fallback is opt-in: create your own instance with IntentClassifier(use_llm_fallback=True) to enable it. When enabled, the LLM call uses max_tokens=20 and temperature=0.1.
Policy weights¶
Each intent maps to a policy dict that controls how results from each retrieval source are weighted.
| Intent | vector |
entity_index |
graph |
temporal |
top_k |
|---|---|---|---|---|---|
FACTUAL |
0.3 | 0.7 | 0.2 | 0.0 | 3 |
TEMPORAL |
0.2 | 0.1 | 0.2 | 0.5 | 5 |
RELATIONAL |
0.2 | 0.2 | 0.5 | 0.1 | 10 |
CAUSAL |
0.1 | 0.1 | 0.6 | 0.2 | 10 |
EXPLORATORY |
0.5 | 0.2 | 0.2 | 0.1 | 10 |
UNKNOWN |
0.4 | 0.2 | 0.2 | 0.2 | 5 |
Weights do not sum to 1.0. They control relative contribution to the blended score after per-source normalization. top_k is the suggested result count; the caller may override it.
The FACTUAL graph weight (0.2) is non-zero even though FACTUAL is an entity lookup. Many factual CTI questions require a single graph hop to answer — for example, "What CVE does APT28 exploit?" traverses a targets edge.
VectorRetriever¶
Module: zettelforge.vector_retriever
Constructor defaults¶
| Parameter | Default | Description |
|---|---|---|
similarity_threshold |
0.15 |
Minimum cosine similarity to include a result |
entity_boost |
2.5 |
Per-overlapping-entity score multiplier |
exact_match_boost |
1.0 |
Multiplier when a query entity appears verbatim in note content (default has no effect) |
regenerate_invalid_embeddings |
True |
Re-embed notes with invalid vectors before scoring |
Score calculation¶
Cosine similarity (module-level function):
def cosine_similarity(a: list[float], b: list[float]) -> float:
dot = np.dot(a, b)
return float(dot / (np.linalg.norm(a) * np.linalg.norm(b)))
# Returns 0.0 if either norm is zero
Embedding model: nomic-embed-text-v1.5-Q, 768 dimensions, generated in-process via fastembed ONNX.
Entity boost (applied post-retrieval):
boost = entity_boost ** overlap_count # default 2.5 per overlapping entity
boost *= exact_match_boost # default 1.0 — no effective change
score = cosine_similarity * boost
Where overlap_count is the number of query entities that also appear in the note's entity set.
Similarity threshold: notes scoring below similarity_threshold (default 0.15) are excluded.
Link expansion¶
After scoring, VectorRetriever adds directly linked notes with a decayed score:
linked_note_score = parent_score * 0.5
This "hop decay" lets a strong match pull in its immediate neighbors. Maximum expanded results: k * 2.
LanceDB vs. in-memory¶
| Mode | Condition | Score origin |
|---|---|---|
| LanceDB | use_lancedb=True and store.lancedb is not None |
similarity = 1.0 - _distance (LanceDB returns L2 distance) |
| In-memory | LanceDB unavailable or use_lancedb=False |
Direct cosine_similarity(query_vector, note_vector) |
The entity boost and link expansion are applied in both modes.
Embedding validation¶
Before scoring, VectorRetriever checks each note's embedding:
| Check | Condition | Action |
|---|---|---|
| Null vector | vector is None |
Regenerate |
| Wrong dimensions | len(vector) != 768 |
Regenerate |
| All zeros | all(v == 0.0 for v in vector) |
Regenerate |
| Low variance | np.var(vector) < 0.001 |
Regenerate |
Regeneration calls get_embedding(note.content.raw[:1000]). Notes that fail regeneration are skipped.
Return type¶
retrieve() accepts return_scores: bool = False:
False(default): returnslist[MemoryNote]True: returnslist[tuple[MemoryNote, float]]— required byBlendedRetriever.blend()
GraphRetriever¶
Module: zettelforge.graph_retriever
Graph source¶
GraphRetriever accepts any object implementing the GraphSource protocol:
class GraphSource(Protocol):
def get_node(self, entity_type: str, entity_value: str) -> dict | None: ...
def get_node_by_id(self, node_id: str) -> dict | None: ...
def get_outgoing_edges(self, node_id: str) -> list[dict]: ...
The concrete implementation used in production is StoreGraphSource, which wraps a StorageBackend and reads a per-store scoped knowledge graph. Using per-store scoping prevents phantom note IDs from cross-store graph data.
ScoredResult¶
@dataclass
class ScoredResult:
note_id: str
score: float
hops: int
path: list[str] # e.g. ["actor:APT28", "tool:Mimikatz", "note:note_20240315_..."]
BFS traversal¶
retrieve_note_ids(query_entities, max_depth=2) accepts query_entities as dict[str, list[str]] (entity type to list of values). It starts a BFS from each entity node and scores every note node it reaches:
score = 1.0 / (1.0 + hop_distance)
| Hops | Score |
|---|---|
| 0 | 1.000 |
| 1 | 0.500 |
| 2 | 0.333 |
| 3 | 0.250 |
When multiple paths reach the same note, the path with the highest score (fewest hops) wins. Results are returned sorted by score descending.
BlendedRetriever¶
Module: zettelforge.blended_retriever (v2.3.1)
The v2.3.1 update replaced positional scoring with normalized score fusion.
blend() — normalized score fusion¶
def blend(
self,
vector_results: list[tuple], # List[Tuple[MemoryNote, float]] — actual similarity scores
graph_results: list[ScoredResult],
policy: dict[str, float],
note_lookup: Callable[[str], MemoryNote | None],
k: int = 10,
) -> list[MemoryNote]
Algorithm:
1. Normalize vector scores to [0, 1] via min-max normalization.
If all scores are equal: assign uniform 0.5.
2. Normalize graph scores to [0, 1] via min-max normalization.
3. Vector signal:
For each (note, norm_score) in normalized vector results:
blended = norm_score * policy["vector"]
scores[note.id] = (blended, note)
4. Graph signal:
For each (note_id, norm_score) in normalized graph results:
graph_score = norm_score * policy["graph"]
If note_id already in scores:
scores[note_id] = (existing_score + graph_score, existing_note) ← both-source bonus
Else:
note = note_lookup(note_id)
If found: scores[note_id] = (graph_score, note)
5. Sort by blended score descending, return top-k notes.
Notes found by both vector and graph retrieval receive additive scores from both signals. This "both-source bonus" ranks notes that are semantically similar and graph-proximate above notes from only one source.
Min-max normalization formula:
normalized = (score - min_score) / (max_score - min_score)
blend_rrf() — Reciprocal Rank Fusion¶
An alternative merge method that is robust to score scale differences across retrievers:
def blend_rrf(
self,
vector_results: list[tuple], # List[Tuple[MemoryNote, float]]
graph_results: list[ScoredResult],
note_lookup: Callable[[str], MemoryNote | None],
k: int = 10,
rrf_k: int = 60, # standard RRF constant
) -> list[MemoryNote]
RRF score for each note: sum(1 / (rrf_k + rank)) across all signals in which the note appears. A rank-1 vector result at default rrf_k=60 scores 1/61 ≈ 0.016. This is the fusion method used by production retrieval systems (Elasticsearch, Vespa). Notes found in both signals still receive an additive bonus.
Full pipeline¶
Query
|
v
IntentClassifier.classify(query)
|
+-- QueryIntent + confidence + method
|
v
IntentClassifier.get_traversal_policy(intent)
|
+-- policy weights dict
|
v
[Parallel]
|
+-- VectorRetriever.retrieve(query, domain, k, return_scores=True)
| cosine similarity (LanceDB or in-memory)
| → entity boost (2.5x per overlapping entity)
| → link expansion (0.5x score decay for linked notes)
| → filter below similarity_threshold (0.15)
| → List[Tuple[MemoryNote, float]]
|
+-- GraphRetriever.retrieve_note_ids(query_entities, max_depth=2)
| BFS from resolved entity nodes via StoreGraphSource
| score = 1/(1+hops), best score per note wins
| → List[ScoredResult]
|
v
BlendedRetriever.blend(vector_results, graph_results, policy, note_lookup, k)
|
+-- Min-max normalize vector scores
+-- Min-max normalize graph scores
+-- Weighted additive fusion (both-source bonus for notes found by both)
+-- Sort descending, truncate to top-k
|
v
List[MemoryNote]
Configuration reference¶
These config keys control retrieval pipeline behavior. Full defaults and env var overrides are in the Configuration reference.
| Config key | Default | Effect |
|---|---|---|
retrieval.similarity_threshold |
0.25 |
Config-level threshold (VectorRetriever constructor overrides to 0.15) |
retrieval.entity_boost |
2.5 |
Score multiplier per overlapping entity |
retrieval.max_graph_depth |
2 |
Max BFS hops in GraphRetriever |
retrieval.max_results |
10 |
Default k passed to retrieval calls |
retrieval.use_lancedb |
true |
Prefer LanceDB when available |