Skip to content

Commit f4a2fed

Browse files
committed
feat(wazuh_decoder_rule_tool): ground the RAG store in verified log samples
The RAG store indexed decoder metadata only — `decoder:… regex:… order:…` — while retrieval queries with a raw log line. That compared a log against OS_Regex syntax, so official decoders scored barely above unrelated feedback rows, and every official doc carried an empty log_example. Without a sample, a retrieved decoder can teach the model XML *style* but not the log -> regex mapping, which is the part that matters. ruleset/testing/tests/*.ini in the Wazuh repo already holds the samples the project itself uses as ground truth (1986 of them, with expected decoder and rule). They were never present locally because the repo cache pins its sparse checkout to ruleset/decoders. scripts/harvest_log_samples.py fetches that path, pushes every sample through wazuh-logtest in batches, and keeps only pairs logtest confirms — recording the decoder that actually fired and the fields it actually extracted. 1616 of 1986 verify. Two traps worth naming: JSON logs emit no `full event:` line at all, so results are aligned positionally rather than keyed on the echoed event (keying lost 504 samples), and a repeated `log 1 pass` key collapses into one newline-joined value, so each line is split back out (another ~300). The sample now leads each document's embedding text and populates log_example: 1089/1330 official docs (81%) carry a verified sample, 1472/1713 overall. Measured with scripts/eval_rag_retrieval.py on held-out samples — any sample indexed as a doc's log_example is excluded, since querying with a string that is verbatim in the store measures memorisation, not retrieval. On logs that need a real text decoder (n=578; builtin-json logs are excluded because no XML decoder is the right answer for them): metadata only (before) p@1 48.8% recall@3 55.2% + verified log example p@1 58.7% recall@3 60.0% + dedup (production path) p@1 66.6% recall@3 69.6% Also fixed two latent bugs this surfaced: * retrieve() could take down a request. `fields` metadata was truncated with json.dumps(...)[:500], which cuts mid-element and leaves `["a", "bc` for json.loads to raise on. Now truncated by dropping whole elements, with a defensive parse for stores already written that way. * /api/rag/status reported ready=false/count=0 in any worker that had not yet served a retrieval, and kept reporting it after an out-of-process rebuild invalidated the cached handle. It now lazy-attaches like retrieve() does. Sibling decoders share a log sample, so top_k=3 returned the same log three times — paying for three examples and teaching one. retrieve() over-fetches and keeps the best-scoring doc per distinct sample.
1 parent 3781f0f commit f4a2fed

4 files changed

Lines changed: 2281 additions & 12 deletions

File tree

integrations/wazuh_decoder_rule_tool/app/rag_engine.py

Lines changed: 173 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
_TRAIN_JSONL = _BASE / "data" / "datasets" / "train.jsonl"
3333
_RAG_STORE_DIR = _BASE / "data" / "rag_store"
3434
_SBERT_MODEL_DIR = _BASE / "data" / "models" / "decoder-sbert" / "final"
35+
# Real log samples harvested from the Wazuh ruleset test suite and confirmed
36+
# against wazuh-logtest. Produced by scripts/harvest_log_samples.py.
37+
_VERIFIED_SAMPLES = _BASE / "data" / "verified_log_samples.jsonl"
3538

3639
# ---------------------------------------------------------------------------
3740
# Globals
@@ -75,9 +78,18 @@ def _get_embedding_function():
7578
# ---------------------------------------------------------------------------
7679

7780
def _build_decoder_text(name: str, parent: str, prematch: str,
78-
program_name: str, regex: str, order: str) -> str:
79-
"""Produce a flat text representation for embedding."""
81+
program_name: str, regex: str, order: str,
82+
log_example: str = "") -> str:
83+
"""Produce a flat text representation for embedding.
84+
85+
The log sample leads, because retrieval queries with a raw log line —
86+
embedding only decoder metadata (regex/order/prematch) meant comparing a
87+
log against OS_Regex syntax, which is why official decoders scored barely
88+
above unrelated feedback rows.
89+
"""
8090
parts = []
91+
if log_example:
92+
parts.append(log_example)
8193
if name:
8294
parts.append(f"decoder:{name}")
8395
if parent:
@@ -93,6 +105,87 @@ def _build_decoder_text(name: str, parent: str, prematch: str,
93105
return " ".join(parts)
94106

95107

108+
_verified_samples_cache: Optional[Dict[str, List[Dict[str, Any]]]] = None
109+
110+
111+
def _load_verified_samples() -> Dict[str, List[Dict[str, Any]]]:
112+
"""Index verified log samples by the decoder name that claimed them.
113+
114+
A sample is filed under both its own decoder and its parent, because an
115+
official doc is keyed on the child decoder in some files and the parent in
116+
others. Returns {} when the corpus hasn't been harvested yet, which just
117+
means docs keep their previous (empty) log_example.
118+
"""
119+
global _verified_samples_cache
120+
if _verified_samples_cache is not None:
121+
return _verified_samples_cache
122+
123+
index: Dict[str, List[Dict[str, Any]]] = {}
124+
if not _VERIFIED_SAMPLES.exists():
125+
logger.info(
126+
"RAG: %s absent — indexing decoders without log examples. "
127+
"Run scripts/harvest_log_samples.py to generate it.",
128+
_VERIFIED_SAMPLES.name,
129+
)
130+
_verified_samples_cache = index
131+
return index
132+
133+
count = 0
134+
with _VERIFIED_SAMPLES.open(encoding="utf-8") as fh:
135+
for line in fh:
136+
line = line.strip()
137+
if not line:
138+
continue
139+
try:
140+
row = json.loads(line)
141+
except json.JSONDecodeError:
142+
continue
143+
log = (row.get("log") or "").strip()
144+
if not log:
145+
continue
146+
entry = {"log": log, "field_names": set(row.get("field_names") or [])}
147+
for key in {row.get("decoder"), row.get("parent")}:
148+
if key:
149+
index.setdefault(key, []).append(entry)
150+
count += 1
151+
152+
logger.info(f"RAG: loaded {count} verified log samples covering {len(index)} decoder names")
153+
_verified_samples_cache = index
154+
return index
155+
156+
157+
def _pick_log_example(child_name: str, parent_name: str, fields: List[str]) -> str:
158+
"""Best verified sample for one parent+child decoder pair.
159+
160+
Sibling decoders share a name, so a name-only match would attach the same
161+
log to every variant in a file. logtest told us which fields each sample
162+
actually produced, so prefer the sample whose extracted fields overlap this
163+
decoder's <order> — that picks the variant the log really exercises.
164+
"""
165+
index = _load_verified_samples()
166+
candidates: List[Dict[str, Any]] = []
167+
for key in (child_name, parent_name):
168+
if key:
169+
candidates.extend(index.get(key, []))
170+
if not candidates:
171+
return ""
172+
173+
wanted = {f.strip() for f in fields if f.strip()}
174+
if not wanted:
175+
# No <order> to discriminate on (e.g. a prematch-only decoder); any
176+
# sample that reached this decoder is a fair illustration.
177+
return candidates[0]["log"]
178+
179+
def overlap(entry: Dict[str, Any]) -> Tuple[int, int]:
180+
common = wanted & entry["field_names"]
181+
# Tie-break toward the sample with the fewest extra fields, so the
182+
# example stays close to what this decoder alone is responsible for.
183+
return len(common), -len(entry["field_names"] - wanted)
184+
185+
best = max(candidates, key=overlap)
186+
return best["log"] if (wanted & best["field_names"]) else candidates[0]["log"]
187+
188+
96189
def _parse_decoder_xml_file(xml_path: Path) -> List[Dict[str, Any]]:
97190
"""Parse one XML file and return a list of decoder document dicts."""
98191
docs: List[Dict[str, Any]] = []
@@ -163,24 +256,44 @@ def _parse_decoder_xml_file(xml_path: Path) -> List[Dict[str, Any]]:
163256
child_xml += "</decoder>"
164257

165258
full_xml = parent_xml + "\n\n" + child_xml
259+
fields = [f.strip() for f in child["order"].split(",") if f.strip()]
260+
log_example = _pick_log_example(child["name"], child["parent"], fields)
166261
embed_text = _build_decoder_text(
167262
name=child["name"],
168263
parent=child["parent"],
169264
prematch=pinfo.get("prematch", ""),
170265
program_name=pinfo.get("program_name", ""),
171266
regex=child["regex"],
172267
order=child["order"],
268+
log_example=log_example,
173269
)
174270
docs.append({
175271
"id": doc_id,
176272
"text": embed_text,
177273
"decoder_xml": full_xml,
178-
"fields": [f.strip() for f in child["order"].split(",") if f.strip()],
274+
"fields": fields,
275+
"log_example": log_example,
179276
"source": f"official:{xml_path.name}",
180277
})
181278
return docs
182279

183280

281+
def _encode_fields(fields: List[str], limit: int = 500) -> str:
282+
"""JSON-encode a field list so it still parses after the size cap.
283+
284+
Slicing the encoded string (the previous approach) could cut mid-element and
285+
leave `["a", "bc` behind, which made json.loads raise inside retrieve() and
286+
took the whole request down. Drop whole elements instead.
287+
"""
288+
kept = list(fields)
289+
while kept:
290+
encoded = json.dumps(kept)
291+
if len(encoded) <= limit:
292+
return encoded
293+
kept.pop()
294+
return "[]"
295+
296+
184297
def _parse_feedback_jsonl(jsonl_path: Path) -> List[Dict[str, Any]]:
185298
"""Parse feedback.jsonl / train.jsonl and return document dicts."""
186299
docs: List[Dict[str, Any]] = []
@@ -348,7 +461,7 @@ def build_store(force: bool = False) -> Dict[str, Any]:
348461
metadatas=[
349462
{
350463
"decoder_xml": d["decoder_xml"][:MAX_XML_CHARS],
351-
"fields": json.dumps(d.get("fields", []))[:500],
464+
"fields": _encode_fields(d.get("fields", [])),
352465
"log_example": d.get("log_example", "")[:300],
353466
"source": d.get("source", "")[:100],
354467
}
@@ -367,17 +480,39 @@ def build_store(force: bool = False) -> Dict[str, Any]:
367480

368481

369482
def get_status() -> Dict[str, Any]:
370-
"""Return the current status of the RAG store."""
371-
if _collection is None:
372-
return {"ready": False, "count": 0, "store_dir": str(_RAG_STORE_DIR)}
373-
try:
374-
count = _collection.count()
483+
"""Return the current status of the RAG store.
484+
485+
Lazily attaches to the store, the same way retrieve() does. Without this,
486+
the endpoint reported ready=False/count=0 in any worker that hadn't served a
487+
retrieval yet, and kept reporting it after an out-of-process rebuild
488+
invalidated the cached handle -- so status disagreed with what retrieval
489+
would actually return.
490+
"""
491+
global _collection
492+
493+
def _describe(count: int) -> Dict[str, Any]:
375494
return {
376495
"ready": count > 0,
377496
"count": count,
378497
"store_dir": str(_RAG_STORE_DIR),
379498
"model": str(_SBERT_MODEL_DIR) if _SBERT_MODEL_DIR.exists() else "all-MiniLM-L6-v2",
380499
}
500+
501+
try:
502+
if _collection is not None:
503+
return _describe(_collection.count())
504+
except Exception as exc:
505+
# A rebuild elsewhere can leave this handle pointing at a dropped
506+
# collection; fall through and re-attach rather than reporting empty.
507+
logger.info(f"RAG: cached collection handle stale ({exc}); re-attaching")
508+
_collection = None
509+
510+
result = build_store(force=False)
511+
if result.get("status") != "ok" or _collection is None:
512+
return {"ready": False, "count": 0, "store_dir": str(_RAG_STORE_DIR),
513+
"error": result.get("message", "store unavailable")}
514+
try:
515+
return _describe(_collection.count())
381516
except Exception as e:
382517
return {"ready": False, "count": 0, "error": str(e)}
383518

@@ -418,10 +553,16 @@ def retrieve(
418553
query_parts.append("fields:" + " ".join(fields))
419554
query = " ".join(query_parts)
420555

556+
# Sibling decoders in one file share a log sample, so a raw top_k often
557+
# comes back as the same log three times with fragmentary <order> lists —
558+
# the prompt pays for three examples and teaches one. Over-fetch, then keep
559+
# the best-scoring doc per distinct log sample.
560+
fetch_k = min(max(top_k * 6, top_k), _collection.count())
561+
421562
try:
422563
results = _collection.query(
423564
query_texts=[query],
424-
n_results=min(top_k, _collection.count()),
565+
n_results=fetch_k,
425566
include=["metadatas", "distances"],
426567
)
427568
except Exception as e:
@@ -432,17 +573,37 @@ def retrieve(
432573
metadatas = results.get("metadatas", [[]])[0]
433574
distances = results.get("distances", [[]])[0]
434575

576+
seen_examples: set = set()
435577
for meta, dist in zip(metadatas, distances):
436578
decoder_xml = meta.get("decoder_xml", "")
437579
if not decoder_xml:
438580
continue
581+
582+
log_example = meta.get("log_example", "")
583+
# Only dedupe when there IS a sample to dedupe on; several docs with no
584+
# example are still distinct decoders and shouldn't collapse into one.
585+
if log_example:
586+
if log_example in seen_examples:
587+
continue
588+
seen_examples.add(log_example)
589+
590+
# A store written before _encode_fields existed can still hold a
591+
# truncated array; a malformed field list is not worth failing the
592+
# whole retrieval over.
593+
try:
594+
doc_fields = json.loads(meta.get("fields", "[]"))
595+
except (json.JSONDecodeError, TypeError):
596+
doc_fields = []
597+
439598
docs.append({
440599
"decoder_xml": decoder_xml,
441-
"log_example": meta.get("log_example", ""),
442-
"fields": json.loads(meta.get("fields", "[]")),
600+
"log_example": log_example,
601+
"fields": doc_fields,
443602
"source": meta.get("source", ""),
444603
"score": round(1.0 - float(dist), 3), # convert distance to similarity
445604
})
605+
if len(docs) >= top_k:
606+
break
446607

447608
return docs
448609

0 commit comments

Comments
 (0)