Files
poimen-memory/scripts/compact_knowledge.py
T
rock a412237095 fix: gitignore log/ dir, remove tracked JSONL from repo
Event logs are runtime data, not source code.
Also adds mem compact command and browser-use + memory-service knowledge.
2026-08-29 22:48:00 -07:00

227 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""
mem compact — Deduplicate and merge similar knowledge chunks.
Flow:
1. Read all chunks from JSONL event log
2. Embed each chunk via embedding API
3. Compute cosine similarity matrix
4. Group chunks with similarity > threshold
5. Send each group to reasoning model to merge into one
6. Write compacted JSONL + update markdown files
Usage:
python3 scripts/compact_knowledge.py [--threshold 0.80] [--dry-run] [--project knowledge]
"""
import argparse
import json
import os
import sys
import time
from pathlib import Path
import requests
import numpy as np
API_BASE = os.environ.get("MEM_API_BASE", "https://api.riotpiao.com/v1")
EMBED_MODEL = "nomic-ai/nomic-embed-text-v2-moe"
REASON_MODEL = "reasoning"
def embed_batch(texts: list[str], batch_size: int = 32) -> list[list[float]]:
"""Embed texts in batches."""
all_embeddings = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
resp = requests.post(
f"{API_BASE}/embeddings",
json={"model": EMBED_MODEL, "input": batch},
)
resp.raise_for_status()
data = resp.json()["data"]
# Sort by index to maintain order
data.sort(key=lambda x: x["index"])
all_embeddings.extend([d["embedding"] for d in data])
if i + batch_size < len(texts):
sys.stderr.write(f" Embedded {i + batch_size}/{len(texts)}...\n")
return all_embeddings
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Cosine similarity between two vectors."""
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-10))
def find_similar_groups(
chunks: list[dict], embeddings: list[list[float]], threshold: float
) -> list[list[int]]:
"""Group chunk indices by cosine similarity > threshold."""
n = len(chunks)
vecs = np.array(embeddings)
# Normalize
norms = np.linalg.norm(vecs, axis=1, keepdims=True)
vecs_norm = vecs / (norms + 1e-10)
# Similarity matrix
sim_matrix = vecs_norm @ vecs_norm.T
visited = set()
groups = []
for i in range(n):
if i in visited:
continue
group = [i]
visited.add(i)
for j in range(i + 1, n):
if j in visited:
continue
if sim_matrix[i, j] > threshold:
group.append(j)
visited.add(j)
if len(group) > 1:
groups.append(group)
return groups
def merge_with_llm(chunks_text: list[str], source_files: list[str]) -> str:
"""Send similar chunks to reasoning model for merging."""
numbered = "\n\n".join(
f"[Chunk {i+1} from {src}]:\n{text}"
for i, (text, src) in enumerate(zip(chunks_text, source_files))
)
resp = requests.post(
f"{API_BASE}/chat/completions",
json={
"model": REASON_MODEL,
"messages": [
{
"role": "system",
"content": (
"You are a knowledge compactor. Merge the following similar chunks "
"into ONE concise chunk. Keep ALL unique facts. Remove redundancy. "
"Keep markdown formatting. Output ONLY the merged text, no explanation. "
"No meta-commentary. No 'Here is the merged version'. Just the content."
),
},
{
"role": "user",
"content": f"Merge these {len(chunks_text)} similar chunks:\n\n{numbered}",
},
],
"temperature": 0.1,
"max_tokens": 1500,
},
)
resp.raise_for_status()
import re
result = resp.json()["choices"][0]["message"]["content"]
# Strip <think> tags from reasoning model
result = re.sub(r"<think>.*?</think>", "", result, flags=re.DOTALL).strip()
return result
def main():
parser = argparse.ArgumentParser(description="Compact knowledge chunks")
parser.add_argument(
"--threshold",
type=float,
default=0.82,
help="Cosine similarity threshold for grouping (default: 0.82)",
)
parser.add_argument("--dry-run", action="store_true", help="Show groups without merging")
parser.add_argument("--project", default="knowledge", help="Project name")
parser.add_argument(
"--log-file",
default="log/knowledge/learn/latest.jsonl",
help="Path to JSONL log",
)
args = parser.parse_args()
# 1. Read chunks
log_path = Path(args.log_file)
if not log_path.exists():
print(f"No log file at {log_path}")
sys.exit(1)
records = []
with open(log_path) as f:
for line in f:
records.append(json.loads(line))
texts = [r["data"]["text"] for r in records]
sources = [r["data"].get("source", "unknown") for r in records]
print(f"Loaded {len(records)} chunks from {log_path}")
# 2. Embed all chunks
print("Embedding chunks...")
embeddings = embed_batch(texts)
print(f"Embedded {len(embeddings)} chunks ({len(embeddings[0])} dims)")
# 3. Find similar groups
print(f"Finding groups with similarity > {args.threshold}...")
groups = find_similar_groups(records, embeddings, args.threshold)
if not groups:
print("✓ No similar chunks found. Knowledge is already compact.")
sys.exit(0)
# 4. Report
total_mergeable = sum(len(g) for g in groups)
savings = total_mergeable - len(groups)
print(f"\nFound {len(groups)} groups ({total_mergeable} chunks → {len(groups)} merged)")
print(f"Estimated savings: {savings} chunks removed\n")
for gi, group in enumerate(groups):
group_texts = [texts[i] for i in group]
group_sources = [sources[i] for i in group]
max_sim = 0
for a in range(len(group)):
for b in range(a + 1, len(group)):
s = cosine_similarity(
np.array(embeddings[group[a]]), np.array(embeddings[group[b]])
)
max_sim = max(max_sim, s)
print(f"Group {gi+1} (sim={max_sim:.3f}, {len(group)} chunks):")
for idx in group:
preview = texts[idx][:80].replace("\n", " ")
src = Path(sources[idx]).stem
print(f" [{src}] {preview}...")
if not args.dry_run:
print(f" → Merging with {REASON_MODEL}...")
merged = merge_with_llm(group_texts, group_sources)
print(f" → Merged: {len(merged)} chars (was {sum(len(t) for t in group_texts)} chars)")
# Replace first chunk with merged, mark others for removal
records[group[0]]["data"]["text"] = merged
records[group[0]]["data"]["merged_from"] = len(group)
for idx in group[1:]:
records[idx] = None # Mark for removal
print()
if args.dry_run:
print("(dry run — no changes written)")
sys.exit(0)
# 5. Write compacted log
compacted = [r for r in records if r is not None]
backup = log_path.with_suffix(".jsonl.bak")
log_path.rename(backup)
with open(log_path, "w") as f:
for r in compacted:
f.write(json.dumps(r) + "\n")
print(f"{'─' * 50}")
print(f"Before: {len(records)} chunks")
print(f"After: {len(compacted)} chunks (-{len(records) - len(compacted)})")
print(f"Backup: {backup}")
print(f"Written: {log_path}")
if __name__ == "__main__":
main()