Spaces:
Running
Running
File size: 13,134 Bytes
0017280 9093d8f 0017280 7a17e0a 6ccd595 0017280 9815e49 0017280 9815e49 0017280 9815e49 0017280 9815e49 0017280 9815e49 0017280 9815e49 0017280 9815e49 0017280 112b4f4 0017280 83087ed 0017280 9815e49 0017280 5044f4c 6ccd595 0017280 83087ed 0017280 9815e49 0017280 91410f8 fbd18c8 b199600 fbd18c8 91410f8 0017280 9093d8f 0017280 1330e5c 0017280 1330e5c 0017280 01b9f34 0017280 aba1e48 9093d8f aba1e48 0017280 01b9f34 9815e49 aba1e48 d3dee93 aba1e48 9093d8f d3dee93 9093d8f 0017280 3653d68 8f35c1b b199600 8f35c1b fbd18c8 8f35c1b 0017280 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 | """Document ingestion pipeline: files -> chunks -> embeddings -> ChromaDB."""
import hashlib
import os
from pathlib import Path
import chromadb
from chromadb.utils import embedding_functions
# from sentence_transformers import SentenceTransformer
from tqdm import tqdm
from src.readers import read_file, READERS
from src.config_loader import load_config, get_api_key
MAX_CHUNK_CHARS = 6000
def split_text_recursive(text: str, chunk_size: int, chunk_overlap: int,
_sep_index: int = 0) -> list[str]:
"""Split text into overlapping chunks using recursive separators."""
separators = ["\n\n", "\n", ". ", " "]
if len(text) <= chunk_size:
return [text] if text.strip() else []
if _sep_index >= len(separators):
# Last resort: character split
step = max(1, chunk_size - chunk_overlap)
chunks = []
for i in range(0, len(text), step):
chunk = text[i:i + chunk_size]
if chunk.strip():
chunks.append(chunk.strip())
return chunks
sep = separators[_sep_index]
if sep not in text:
return split_text_recursive(text, chunk_size, chunk_overlap, _sep_index + 1)
parts = text.split(sep)
merged = []
current = ""
for part in parts:
candidate = current + sep + part if current else part
if len(candidate) > chunk_size and current:
merged.append(current)
overlap_text = current[-chunk_overlap:] if chunk_overlap else ""
current = overlap_text + sep + part if overlap_text else part
else:
current = candidate
if current.strip():
merged.append(current)
# Recursively split any chunks that are still too large
result = []
for chunk in merged:
if len(chunk) > chunk_size:
result.extend(split_text_recursive(chunk, chunk_size, chunk_overlap,
_sep_index + 1))
elif chunk.strip():
result.append(chunk.strip())
return result
def chunk_documents(pages: list[dict], source_name: str, dataset_name: str,
chunk_size: int = 1000, chunk_overlap: int = 100) -> list[dict]:
"""Split pages into smaller chunks with metadata."""
chunks = []
global_chunk_index = 0
for page_info in pages:
text = page_info.get("text", "")
if not text.strip():
continue
if len(text) > MAX_CHUNK_CHARS:
splits = split_text_recursive(text, MAX_CHUNK_CHARS, chunk_overlap)
else:
splits = split_text_recursive(text, chunk_size, chunk_overlap)
for split in splits:
if len(split) > MAX_CHUNK_CHARS:
split = split[:MAX_CHUNK_CHARS]
chunks.append({
"text": split,
"metadata": {
"source": source_name,
"dataset": dataset_name,
"page": str(page_info.get("page", "?")),
"chunk_index": global_chunk_index,
},
})
global_chunk_index += 1
return chunks
def discover_files(documents_dir: str) -> list[tuple[Path, str]]:
"""Recursively discover all supported files.
Returns list of (file_path, dataset_name) tuples.
Dataset name is the immediate subfolder name, or "general" for root files.
"""
root = Path(documents_dir)
supported = set(READERS.keys())
files = []
for file_path in sorted(root.rglob("*")):
if not file_path.is_file():
continue
if file_path.suffix.lower() not in supported:
continue
if file_path.name.startswith(".") or file_path.name.startswith("~$"):
continue
rel = file_path.relative_to(root)
dataset_name = rel.parts[0] if len(rel.parts) > 1 else "general"
files.append((file_path, dataset_name))
return files
def get_chroma_collection(cfg: dict):
"""Get or create the ChromaDB collection with embeddings."""
db_path = cfg.get("paths", {}).get("vector_db", "chroma_db")
if not os.path.isabs(db_path):
project_root = Path(__file__).resolve().parent.parent
db_path = os.path.join(str(project_root), db_path)
client = chromadb.PersistentClient(path=db_path)
embed_provider = cfg.get("embeddings", {}).get("provider", "local")
if embed_provider == "openai":
api_key = get_api_key(cfg, "openai")
model = cfg.get("embeddings", {}).get("openai_model", "text-embedding-3-small")
ef = embedding_functions.OpenAIEmbeddingFunction(
api_key=api_key, model_name=model,
)
elif embed_provider == "meta-llama":
api_key = get_api_key(cfg, "meta-llama")
model = cfg.get("embeddings", {}).get("emb_model", "sentence-transformers/all-mpnet-base-v2")
ef = embedding_functions.SentenceTransformerEmbeddingFunction(model_name=model)
else:
ef = embedding_functions.DefaultEmbeddingFunction()
collection = client.get_or_create_collection(
name="knowledge_base",
embedding_function=ef,
metadata={"hnsw:space": "cosine"},
)
return collection
def ingest_documents(cfg: dict = None, documents_dir: str = None) -> int:
"""Ingest all supported files from knowledge_base/ into ChromaDB.
Returns the number of chunks ingested.
"""
if cfg is None:
cfg = load_config()
if documents_dir is None:
documents_dir = cfg.get("paths", {}).get("knowledge_base", "knowledge_base")
if not os.path.isabs(documents_dir):
project_root = Path(__file__).resolve().parent.parent
documents_dir = os.path.join(str(project_root), documents_dir)
files = discover_files(documents_dir)
if not files:
print(f"No supported files found in {documents_dir}")
print(f"Supported types: {', '.join(sorted(READERS.keys()))}")
# Clear stale data from previous ingestions
try:
collection = get_chroma_collection(cfg)
existing = collection.count()
if existing > 0:
print(f"Clearing {existing} stale chunks from previous ingestion...")
all_ids = collection.get().get("ids", [])
if all_ids:
for ci in range(0, len(all_ids), 5000):
collection.delete(ids=all_ids[ci:ci + 5000])
except Exception as e:
print(f"Warning: Could not clear vector DB: {e}")
# Clear SQL database
sql_enabled = cfg.get("sql", {}).get("enabled", True)
if sql_enabled:
try:
sql_db_dir = cfg.get("paths", {}).get("sql_db", "sql_db")
if not os.path.isabs(sql_db_dir):
project_root = Path(__file__).resolve().parent.parent
sql_db_dir = os.path.join(str(project_root), sql_db_dir)
db_file = os.path.join(sql_db_dir, "knowledge_base.db")
schema_file = os.path.join(sql_db_dir, "sql_schemas.json")
if os.path.exists(db_file):
os.remove(db_file)
print("Cleared stale SQL database.")
if os.path.exists(schema_file):
os.remove(schema_file)
except Exception as e:
print(f"Warning: Could not clear SQL database: {e}")
# Clear KB meta
try:
db_path = cfg.get("paths", {}).get("vector_db", "chroma_db")
if not os.path.isabs(db_path):
project_root = Path(__file__).resolve().parent.parent
db_path = os.path.join(str(project_root), db_path)
cleared_any = False
for meta_name in ("kb_meta.txt", "kb_meta_brief.txt"):
meta_file = os.path.join(db_path, meta_name)
if os.path.exists(meta_file):
os.remove(meta_file)
cleared_any = True
if cleared_any:
print("Cleared stale KB overview.")
except Exception as e:
print(f"Warning: Could not clear KB meta: {e}")
return 0
# Summarize
datasets = {}
for f, ds in files:
datasets.setdefault(ds, []).append(f)
print(f"Found {len(files)} files across {len(datasets)} dataset(s):\n")
for ds_name, ds_files in sorted(datasets.items()):
exts = [f.suffix for f in ds_files]
print(f" {ds_name}: {len(ds_files)} files ({', '.join(sorted(set(exts)))})")
print()
chunk_size = cfg.get("retrieval", {}).get("chunk_size", 1000)
chunk_overlap = cfg.get("retrieval", {}).get("chunk_overlap", 100)
# Guard: overlap >= 80% of chunk_size would cause near-duplicate chunks
max_overlap = int(chunk_size * 0.8)
if chunk_overlap >= max_overlap:
print(f"Warning: chunk_overlap ({chunk_overlap}) >= 80% of chunk_size ({chunk_size}). Capping at {max_overlap}.")
chunk_overlap = max_overlap
collection = get_chroma_collection(cfg)
# Defer clearing until first successful chunk generation (prevents data loss
# if all file reads fail after clearing)
needs_clear = True
total_chunks = 0
for file_path, dataset_name in files:
rel_path = file_path.relative_to(documents_dir)
print(f"Processing: {rel_path}")
try:
pages = read_file(str(file_path))
except Exception as e:
print(f" Error reading {file_path.name}: {e}")
continue
if not pages:
print(f" No text extracted, skipping.")
continue
source_name = file_path.relative_to(Path(documents_dir)).as_posix()
chunks = chunk_documents(pages, source_name, dataset_name, chunk_size, chunk_overlap)
print(f" -> {len(chunks)} chunks")
batch_size = 5000
safe_id_prefix = hashlib.md5(source_name.encode()).hexdigest()[:12]
file_chunks_added = 0
for i in range(0, len(chunks), batch_size):
batch = chunks[i:i + batch_size]
ids = [f"{safe_id_prefix}_{i + j}" for j in range(len(batch))]
documents = [c.get("text", "") for c in batch]
metadatas = [c.get("metadata", {}) for c in batch]
# emb_model = SentenceTransformer("all-MiniLM-L6-v2")
# # print("computing embeddings")
# precomputed_embeddings = emb_model.encode(documents, show_progress_bar=True).tolist()
# print("adding docs")
try:
# Clear existing data only on first successful add
if needs_clear:
existing = collection.count()
if existing > 0:
print(f"Clearing {existing} existing chunks...\n")
all_ids = collection.get().get("ids", [])
if all_ids:
for ci in range(0, len(all_ids), 5000):
collection.delete(ids=all_ids[ci:ci + 5000])
needs_clear = False
collection.add(ids=ids, documents=documents, metadatas=metadatas)
file_chunks_added += len(batch)
except Exception as e:
print(f" Warning: Failed to embed batch for {source_name}: {e}")
total_chunks += file_chunks_added
# ββ SQL ingestion for tabular files ββββββββββββββββββββββββββββββ
sql_enabled = cfg.get("sql", {}).get("enabled", True)
if sql_enabled:
from src.sql_ingest import ingest_to_sql, SQL_EXTENSIONS
tabular_count = sum(1 for f, _ in files if f.suffix.lower() in SQL_EXTENSIONS)
if tabular_count > 0:
print(f"\nIngesting {tabular_count} tabular file(s) into SQLite...")
try:
schema = ingest_to_sql(files, documents_dir, cfg)
print(f"SQL ingestion complete: {len(schema)} table(s).")
except Exception as e:
print(f"SQL ingestion error (non-fatal): {e}")
# ββ KB meta overview (LLM-generated) ββββββββββββββββββββββββββββ
_db_path = cfg.get("paths", {}).get("vector_db", "chroma_db")
if not os.path.isabs(_db_path):
_db_path = os.path.join(str(Path(__file__).resolve().parent.parent), _db_path)
print("\nGenerating knowledge base overview...")
try:
from src.kb_meta import build_and_store_overview
# build_and_store_overview overwrites meta files via write_text();
# no pre-deletion needed β old files stay intact if generation fails.
overview = build_and_store_overview(collection, cfg)
if overview:
print("KB overview generated and indexed.")
else:
print("KB overview: nothing to summarize.")
except Exception as e:
print(f"KB overview generation failed (non-fatal): {e}")
print(f"\nIngestion complete: {total_chunks} chunks from {len(files)} files.")
return total_chunks
|