Spaces:
Sleeping
Sleeping
Vineetiitg commited on
Commit ·
2d34555
1
Parent(s): 73ee76f
feat: add multi-format ingestion and index lifecycle
Browse files- app/engine/chunking.py +19 -0
- app/engine/document_registry.py +61 -0
- app/engine/indexer.py +87 -0
- app/engine/ingestion.py +124 -37
- app/engine/loaders.py +81 -0
- requirements.txt +4 -0
app/engine/chunking.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
|
| 3 |
+
from langchain_core.documents import Document
|
| 4 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 5 |
+
|
| 6 |
+
from app.core.config import settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def chunk_documents(documents: list[Document]) -> list[Document]:
|
| 10 |
+
splitter = RecursiveCharacterTextSplitter(
|
| 11 |
+
chunk_size=settings.CHUNK_SIZE,
|
| 12 |
+
chunk_overlap=settings.CHUNK_OVERLAP,
|
| 13 |
+
)
|
| 14 |
+
chunks = splitter.split_documents(documents)
|
| 15 |
+
for index, chunk in enumerate(chunks):
|
| 16 |
+
source = chunk.metadata.get("source", "unknown")
|
| 17 |
+
chunk.metadata["chunk_index"] = index
|
| 18 |
+
chunk.metadata["chunk_id"] = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{source}:{index}:{chunk.page_content[:80]}"))
|
| 19 |
+
return chunks
|
app/engine/document_registry.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import json
|
| 3 |
+
from dataclasses import asdict, dataclass
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
REGISTRY_PATH = Path("data/document_registry.json")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@dataclass
|
| 12 |
+
class DocumentRecord:
|
| 13 |
+
doc_id: str
|
| 14 |
+
source: str
|
| 15 |
+
source_path: str
|
| 16 |
+
content_hash: str
|
| 17 |
+
chunk_count: int
|
| 18 |
+
ingested_at: str
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def file_hash(path: Path) -> str:
|
| 22 |
+
digest = hashlib.sha256()
|
| 23 |
+
with path.open("rb") as handle:
|
| 24 |
+
for block in iter(lambda: handle.read(1024 * 1024), b""):
|
| 25 |
+
digest.update(block)
|
| 26 |
+
return digest.hexdigest()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def load_registry(path: Path = REGISTRY_PATH) -> dict[str, dict]:
|
| 30 |
+
if not path.exists():
|
| 31 |
+
return {}
|
| 32 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def save_registry(registry: dict[str, dict], path: Path = REGISTRY_PATH) -> None:
|
| 36 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 37 |
+
path.write_text(json.dumps(registry, indent=2, sort_keys=True), encoding="utf-8")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def hash_exists(content_hash: str, registry: dict[str, dict]) -> bool:
|
| 41 |
+
return any(record["content_hash"] == content_hash for record in registry.values())
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def upsert_record(
|
| 45 |
+
doc_id: str,
|
| 46 |
+
source: str,
|
| 47 |
+
source_path: str,
|
| 48 |
+
content_hash: str,
|
| 49 |
+
chunk_count: int,
|
| 50 |
+
registry: dict[str, dict],
|
| 51 |
+
) -> None:
|
| 52 |
+
registry[doc_id] = asdict(
|
| 53 |
+
DocumentRecord(
|
| 54 |
+
doc_id=doc_id,
|
| 55 |
+
source=source,
|
| 56 |
+
source_path=source_path,
|
| 57 |
+
content_hash=content_hash,
|
| 58 |
+
chunk_count=chunk_count,
|
| 59 |
+
ingested_at=datetime.now(timezone.utc).isoformat(),
|
| 60 |
+
)
|
| 61 |
+
)
|
app/engine/indexer.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_community.embeddings import FastEmbedEmbeddings
|
| 2 |
+
from langchain_qdrant import FastEmbedSparse, QdrantVectorStore, RetrievalMode
|
| 3 |
+
from qdrant_client import models
|
| 4 |
+
|
| 5 |
+
from app.core.config import settings
|
| 6 |
+
from app.core.dependencies import get_qdrant_client
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def qdrant_store_options() -> dict:
|
| 10 |
+
if settings.QDRANT_URL:
|
| 11 |
+
return {"url": settings.QDRANT_URL}
|
| 12 |
+
return {"path": settings.QDRANT_LOCATION}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def retrieval_mode() -> RetrievalMode:
|
| 16 |
+
mode = settings.RETRIEVAL_MODE.lower()
|
| 17 |
+
if mode == "dense":
|
| 18 |
+
return RetrievalMode.DENSE
|
| 19 |
+
if mode == "sparse":
|
| 20 |
+
return RetrievalMode.SPARSE
|
| 21 |
+
return RetrievalMode.HYBRID
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def dense_embeddings() -> FastEmbedEmbeddings:
|
| 25 |
+
return FastEmbedEmbeddings(model_name=settings.DENSE_EMBEDDING_MODEL)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def sparse_embeddings() -> FastEmbedSparse:
|
| 29 |
+
return FastEmbedSparse(model_name=settings.SPARSE_EMBEDDING_MODEL)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def collection_exists() -> bool:
|
| 33 |
+
client = get_qdrant_client()
|
| 34 |
+
return any(collection.name == settings.COLLECTION_NAME for collection in client.get_collections().collections)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def open_vector_store(validate_collection_config: bool = True) -> QdrantVectorStore:
|
| 38 |
+
return QdrantVectorStore(
|
| 39 |
+
client=get_qdrant_client(),
|
| 40 |
+
collection_name=settings.COLLECTION_NAME,
|
| 41 |
+
embedding=dense_embeddings(),
|
| 42 |
+
sparse_embedding=sparse_embeddings(),
|
| 43 |
+
retrieval_mode=retrieval_mode(),
|
| 44 |
+
validate_collection_config=validate_collection_config,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def index_documents(documents, force_recreate: bool = False) -> None:
|
| 49 |
+
if force_recreate or not collection_exists():
|
| 50 |
+
QdrantVectorStore.from_documents(
|
| 51 |
+
documents,
|
| 52 |
+
embedding=dense_embeddings(),
|
| 53 |
+
sparse_embedding=sparse_embeddings(),
|
| 54 |
+
collection_name=settings.COLLECTION_NAME,
|
| 55 |
+
retrieval_mode=retrieval_mode(),
|
| 56 |
+
force_recreate=force_recreate,
|
| 57 |
+
**qdrant_store_options(),
|
| 58 |
+
)
|
| 59 |
+
return
|
| 60 |
+
|
| 61 |
+
store = open_vector_store()
|
| 62 |
+
store.add_documents(documents)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def reset_collection() -> None:
|
| 66 |
+
client = get_qdrant_client()
|
| 67 |
+
if collection_exists():
|
| 68 |
+
client.delete_collection(settings.COLLECTION_NAME)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def delete_document(doc_id: str) -> None:
|
| 72 |
+
client = get_qdrant_client()
|
| 73 |
+
if not collection_exists():
|
| 74 |
+
return
|
| 75 |
+
client.delete(
|
| 76 |
+
collection_name=settings.COLLECTION_NAME,
|
| 77 |
+
points_selector=models.FilterSelector(
|
| 78 |
+
filter=models.Filter(
|
| 79 |
+
must=[
|
| 80 |
+
models.FieldCondition(
|
| 81 |
+
key="metadata.doc_id",
|
| 82 |
+
match=models.MatchValue(value=doc_id),
|
| 83 |
+
)
|
| 84 |
+
]
|
| 85 |
+
)
|
| 86 |
+
),
|
| 87 |
+
)
|
app/engine/ingestion.py
CHANGED
|
@@ -1,46 +1,133 @@
|
|
| 1 |
-
import
|
| 2 |
-
|
| 3 |
-
from
|
| 4 |
-
from
|
| 5 |
-
from langchain_qdrant import FastEmbedSparse, QdrantVectorStore, RetrievalMode
|
| 6 |
|
| 7 |
-
from
|
| 8 |
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
print(f"Loading documents from {data_dir}...")
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
return
|
| 20 |
|
| 21 |
-
|
| 22 |
-
chunks = text_splitter.split_documents(documents)
|
| 23 |
print(f"Split documents into {len(chunks)} chunks.")
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
if __name__ == "__main__":
|
| 46 |
-
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import uuid
|
| 3 |
+
from collections import Counter
|
| 4 |
+
from pathlib import Path
|
|
|
|
| 5 |
|
| 6 |
+
from langchain_core.documents import Document
|
| 7 |
|
| 8 |
+
from app.engine.chunking import chunk_documents
|
| 9 |
+
from app.engine.document_registry import (
|
| 10 |
+
file_hash,
|
| 11 |
+
hash_exists,
|
| 12 |
+
load_registry,
|
| 13 |
+
save_registry,
|
| 14 |
+
upsert_record,
|
| 15 |
+
)
|
| 16 |
+
from app.engine.indexer import delete_document, index_documents, reset_collection
|
| 17 |
+
from app.engine.loaders import SUPPORTED_EXTENSIONS, load_document
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def ingest_documents(data_dir: str = "data/docs", force: bool = False) -> None:
|
| 21 |
print(f"Loading documents from {data_dir}...")
|
| 22 |
+
root = Path(data_dir)
|
| 23 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 24 |
+
|
| 25 |
+
registry = {} if force else load_registry()
|
| 26 |
+
source_documents: list[Document] = []
|
| 27 |
+
doc_id_by_source: dict[str, str] = {}
|
| 28 |
+
|
| 29 |
+
if force:
|
| 30 |
+
reset_collection()
|
| 31 |
+
|
| 32 |
+
for path in sorted(root.rglob("*")):
|
| 33 |
+
if not path.is_file() or path.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
| 34 |
+
continue
|
| 35 |
+
|
| 36 |
+
content_hash = file_hash(path)
|
| 37 |
+
if not force and hash_exists(content_hash, registry):
|
| 38 |
+
print(f"Skipping unchanged document: {path.name}")
|
| 39 |
+
continue
|
| 40 |
+
|
| 41 |
+
doc_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{path.name}:{content_hash}"))
|
| 42 |
+
loaded = load_document(path)
|
| 43 |
+
for document in loaded:
|
| 44 |
+
document.metadata["doc_id"] = doc_id
|
| 45 |
+
document.metadata["content_hash"] = content_hash
|
| 46 |
+
source_documents.extend(loaded)
|
| 47 |
+
doc_id_by_source[str(path)] = doc_id
|
| 48 |
+
|
| 49 |
+
if not source_documents:
|
| 50 |
+
print("No new documents found.")
|
| 51 |
return
|
| 52 |
|
| 53 |
+
chunks = chunk_documents(source_documents)
|
|
|
|
| 54 |
print(f"Split documents into {len(chunks)} chunks.")
|
| 55 |
|
| 56 |
+
index_documents(chunks, force_recreate=force)
|
| 57 |
+
|
| 58 |
+
chunk_counts = Counter(chunk.metadata["doc_id"] for chunk in chunks)
|
| 59 |
+
for source_path, doc_id in doc_id_by_source.items():
|
| 60 |
+
path = Path(source_path)
|
| 61 |
+
upsert_record(
|
| 62 |
+
doc_id=doc_id,
|
| 63 |
+
source=path.name,
|
| 64 |
+
source_path=str(path),
|
| 65 |
+
content_hash=file_hash(path),
|
| 66 |
+
chunk_count=chunk_counts[doc_id],
|
| 67 |
+
registry=registry,
|
| 68 |
+
)
|
| 69 |
+
save_registry(registry)
|
| 70 |
+
print("Ingestion complete. Hybrid index is built.")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def list_documents() -> None:
|
| 74 |
+
registry = load_registry()
|
| 75 |
+
if not registry:
|
| 76 |
+
print("No indexed documents found.")
|
| 77 |
+
return
|
| 78 |
+
for record in registry.values():
|
| 79 |
+
print(f"{record['doc_id']} | {record['source']} | chunks={record['chunk_count']}")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def delete_indexed_document(doc_id: str) -> None:
|
| 83 |
+
registry = load_registry()
|
| 84 |
+
if doc_id not in registry:
|
| 85 |
+
print(f"Document not found: {doc_id}")
|
| 86 |
+
return
|
| 87 |
+
delete_document(doc_id)
|
| 88 |
+
del registry[doc_id]
|
| 89 |
+
save_registry(registry)
|
| 90 |
+
print(f"Deleted document: {doc_id}")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def reset_index() -> None:
|
| 94 |
+
reset_collection()
|
| 95 |
+
save_registry({})
|
| 96 |
+
print("Vector collection and document registry reset.")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def build_parser() -> argparse.ArgumentParser:
|
| 100 |
+
parser = argparse.ArgumentParser(description="Manage support document ingestion.")
|
| 101 |
+
subparsers = parser.add_subparsers(dest="command")
|
| 102 |
+
|
| 103 |
+
ingest_parser = subparsers.add_parser("ingest", help="Ingest documents into Qdrant.")
|
| 104 |
+
ingest_parser.add_argument("--data-dir", default="data/docs")
|
| 105 |
+
ingest_parser.add_argument("--force", action="store_true", help="Recreate the collection before ingesting.")
|
| 106 |
+
|
| 107 |
+
subparsers.add_parser("list", help="List indexed documents.")
|
| 108 |
+
|
| 109 |
+
delete_parser = subparsers.add_parser("delete", help="Delete one indexed document.")
|
| 110 |
+
delete_parser.add_argument("--doc-id", required=True)
|
| 111 |
+
|
| 112 |
+
subparsers.add_parser("reset", help="Delete the vector collection and registry.")
|
| 113 |
+
return parser
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def main() -> None:
|
| 117 |
+
parser = build_parser()
|
| 118 |
+
args = parser.parse_args()
|
| 119 |
+
|
| 120 |
+
if args.command in {None, "ingest"}:
|
| 121 |
+
ingest_documents(data_dir=getattr(args, "data_dir", "data/docs"), force=getattr(args, "force", False))
|
| 122 |
+
elif args.command == "list":
|
| 123 |
+
list_documents()
|
| 124 |
+
elif args.command == "delete":
|
| 125 |
+
delete_indexed_document(args.doc_id)
|
| 126 |
+
elif args.command == "reset":
|
| 127 |
+
reset_index()
|
| 128 |
+
else:
|
| 129 |
+
parser.print_help()
|
| 130 |
+
|
| 131 |
|
| 132 |
if __name__ == "__main__":
|
| 133 |
+
main()
|
app/engine/loaders.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from langchain_core.documents import Document
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf", ".docx", ".html", ".htm"}
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def load_documents(data_dir: str) -> list[Document]:
|
| 10 |
+
root = Path(data_dir)
|
| 11 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 12 |
+
|
| 13 |
+
documents: list[Document] = []
|
| 14 |
+
for path in sorted(root.rglob("*")):
|
| 15 |
+
if not path.is_file() or path.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
| 16 |
+
continue
|
| 17 |
+
documents.extend(load_document(path))
|
| 18 |
+
return documents
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def load_document(path: Path) -> list[Document]:
|
| 22 |
+
suffix = path.suffix.lower()
|
| 23 |
+
if suffix in {".txt", ".md"}:
|
| 24 |
+
return [_text_document(path)]
|
| 25 |
+
if suffix == ".pdf":
|
| 26 |
+
return _pdf_documents(path)
|
| 27 |
+
if suffix == ".docx":
|
| 28 |
+
return [_docx_document(path)]
|
| 29 |
+
if suffix in {".html", ".htm"}:
|
| 30 |
+
return [_html_document(path)]
|
| 31 |
+
return []
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _base_metadata(path: Path) -> dict:
|
| 35 |
+
return {
|
| 36 |
+
"source": path.name,
|
| 37 |
+
"source_path": str(path),
|
| 38 |
+
"file_type": path.suffix.lower().lstrip("."),
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _text_document(path: Path) -> Document:
|
| 43 |
+
return Document(page_content=path.read_text(encoding="utf-8", errors="ignore"), metadata=_base_metadata(path))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _pdf_documents(path: Path) -> list[Document]:
|
| 47 |
+
try:
|
| 48 |
+
from pypdf import PdfReader
|
| 49 |
+
except ImportError as exc:
|
| 50 |
+
raise RuntimeError("Install pypdf to ingest PDF files.") from exc
|
| 51 |
+
|
| 52 |
+
reader = PdfReader(str(path))
|
| 53 |
+
documents: list[Document] = []
|
| 54 |
+
for index, page in enumerate(reader.pages, start=1):
|
| 55 |
+
metadata = _base_metadata(path)
|
| 56 |
+
metadata["page"] = index
|
| 57 |
+
documents.append(Document(page_content=page.extract_text() or "", metadata=metadata))
|
| 58 |
+
return documents
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _docx_document(path: Path) -> Document:
|
| 62 |
+
try:
|
| 63 |
+
from docx import Document as DocxDocument
|
| 64 |
+
except ImportError as exc:
|
| 65 |
+
raise RuntimeError("Install python-docx to ingest DOCX files.") from exc
|
| 66 |
+
|
| 67 |
+
doc = DocxDocument(str(path))
|
| 68 |
+
text = "\n".join(paragraph.text for paragraph in doc.paragraphs if paragraph.text.strip())
|
| 69 |
+
return Document(page_content=text, metadata=_base_metadata(path))
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _html_document(path: Path) -> Document:
|
| 73 |
+
try:
|
| 74 |
+
from bs4 import BeautifulSoup
|
| 75 |
+
except ImportError as exc:
|
| 76 |
+
raise RuntimeError("Install beautifulsoup4 to ingest HTML files.") from exc
|
| 77 |
+
|
| 78 |
+
soup = BeautifulSoup(path.read_text(encoding="utf-8", errors="ignore"), "html.parser")
|
| 79 |
+
for tag in soup(["script", "style"]):
|
| 80 |
+
tag.decompose()
|
| 81 |
+
return Document(page_content=soup.get_text("\n", strip=True), metadata=_base_metadata(path))
|
requirements.txt
CHANGED
|
@@ -19,3 +19,7 @@ jinja2==3.1.3
|
|
| 19 |
tabulate==0.9.0
|
| 20 |
streamlit==1.32.2
|
| 21 |
requests==2.34.2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
tabulate==0.9.0
|
| 20 |
streamlit==1.32.2
|
| 21 |
requests==2.34.2
|
| 22 |
+
pypdf==4.3.1
|
| 23 |
+
python-docx==1.1.2
|
| 24 |
+
beautifulsoup4==4.12.3
|
| 25 |
+
pytest==8.2.2
|