092_agent_api / pdf_rag.py
anhkhoiphan's picture
Thay đổi logic tool RAG
dd53ab9
Raw
History Blame Contribute Delete
12.7 kB
"""
PDF RAG — chunk, embed, index to Qdrant, hybrid search.
Hybrid search = dense (OpenAI text-embedding-3-small, cosine similarity)
+ sparse (BM25 via fastembed Qdrant/bm25, dot product)
merged via Reciprocal Rank Fusion (RRF, k=60).
Sau RRF, mỗi chunk được mở rộng sang các chunk lân cận (N-3 đến N+3) trong cùng
PDF để đưa vào context đầy đủ hơn. Không dùng overlap vì neighbor expansion
đã đảm bảo không mất context tại ranh giới chunk.
"""
import logging
import uuid
from typing import Optional
from fastembed import SparseTextEmbedding
from openai import OpenAI
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance,
FieldCondition,
Filter,
MatchAny,
MatchValue,
PointStruct,
SparseVector,
SparseVectorParams,
VectorParams,
)
from src.config import OPENAI_API_KEY, QDRANT_API_KEY, QDRANT_URL
from src.pdf_processing import pdf_to_markdown
logger = logging.getLogger(__name__)
# Collection v2: named vectors (dense + sparse). Xóa collection cũ "pdf_chunks" nếu còn.
_PDF_COLLECTION = "pdf_chunks_v2"
_EMBED_MODEL = "text-embedding-3-small"
_EMBED_DIMS = 1536
_BM25_MODEL = "Qdrant/bm25"
_CHUNK_SIZE = 1000 # ký tự / chunk
_CHUNK_OVERLAP = 0 # không cần overlap — neighbor expansion xử lý ranh giới
_EMBED_BATCH = 32 # số chunk embed song song mỗi lần
_RRF_K = 60 # hằng số RRF chuẩn
_NEIGHBOR_WINDOW = 3 # fetch N-3 đến N+3 quanh mỗi chunk được retrieve
_qdrant: Optional[QdrantClient] = None
_openai: Optional[OpenAI] = None
_bm25: Optional[SparseTextEmbedding] = None
# ── Client / model helpers ────────────────────────────────────────────────────
def _get_qdrant() -> QdrantClient:
global _qdrant
if _qdrant is None:
if not QDRANT_URL:
raise RuntimeError("QDRANT_URL chưa được cấu hình.")
_qdrant = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)
_ensure_collection(_qdrant)
return _qdrant
def _get_openai() -> OpenAI:
global _openai
if _openai is None:
if not OPENAI_API_KEY:
raise RuntimeError("OPENAI_API_KEY chưa được cấu hình.")
_openai = OpenAI(api_key=OPENAI_API_KEY)
return _openai
def _get_bm25() -> SparseTextEmbedding:
global _bm25
if _bm25 is None:
_bm25 = SparseTextEmbedding(model_name=_BM25_MODEL)
return _bm25
def _ensure_collection(client: QdrantClient) -> None:
existing = {c.name for c in client.get_collections().collections}
if _PDF_COLLECTION not in existing:
client.create_collection(
collection_name=_PDF_COLLECTION,
vectors_config={
"dense": VectorParams(size=_EMBED_DIMS, distance=Distance.COSINE),
},
sparse_vectors_config={
"sparse": SparseVectorParams(),
},
)
logger.info("Qdrant: collection '%s' created.", _PDF_COLLECTION)
# Payload indexes — idempotent, an toàn gọi mỗi lần khởi động.
for field in ("conversation_id", "pdf_name"):
client.create_payload_index(
collection_name=_PDF_COLLECTION,
field_name=field,
field_schema="keyword",
)
client.create_payload_index(
collection_name=_PDF_COLLECTION,
field_name="chunk_index",
field_schema="integer",
)
# ── Chunking ──────────────────────────────────────────────────────────────────
def _chunk_text(text: str) -> list[str]:
if len(text) <= _CHUNK_SIZE:
return [text.strip()] if text.strip() else []
chunks: list[str] = []
start = 0
while start < len(text):
end = min(start + _CHUNK_SIZE, len(text))
if end < len(text):
for boundary in ('\n\n', '\n', '.', '!', '?'):
pos = text.rfind(boundary, start + _CHUNK_SIZE // 2, end)
if pos != -1:
end = pos + len(boundary)
break
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
# _CHUNK_OVERLAP = 0, nhưng giữ công thức chung để dễ điều chỉnh sau
next_start = end - _CHUNK_OVERLAP
if next_start <= start:
next_start = end
start = next_start
return chunks
# ── Embedding ─────────────────────────────────────────────────────────────────
def _embed_batch(texts: list[str]) -> list[list[float]]:
response = _get_openai().embeddings.create(model=_EMBED_MODEL, input=texts)
return [item.embedding for item in response.data]
def _embed_one(text: str) -> list[float]:
return _embed_batch([text])[0]
def _bm25_batch(texts: list[str]) -> list[SparseVector]:
embeddings = list(_get_bm25().embed(texts))
return [
SparseVector(indices=e.indices.tolist(), values=e.values.tolist())
for e in embeddings
]
def _bm25_one(text: str) -> SparseVector:
return _bm25_batch([text])[0]
# ── Neighbor expansion ────────────────────────────────────────────────────────
def _expand_chunks(
client: QdrantClient,
conversation_id: str,
hits: list[tuple[str, int]], # (pdf_name, chunk_index)
window: int = _NEIGHBOR_WINDOW,
) -> list[str]:
"""
Với mỗi (pdf_name, chunk_index) được retrieve, fetch thêm chunk N-window đến N+window
từ cùng PDF. Các cửa sổ chồng lấp được merge thành một đoạn liên tục để
tránh đưa nội dung trùng lặp vào context.
Returns:
Danh sách đoạn văn bản, mỗi đoạn là một cửa sổ liên tục (đã merge nếu chồng lấp).
"""
# Gom tất cả chunk_index cần fetch theo từng pdf_name
pdf_needed: dict[str, set[int]] = {}
for pdf_name, chunk_index in hits:
indices = set(range(max(0, chunk_index - window), chunk_index + window + 1))
pdf_needed.setdefault(pdf_name, set()).update(indices)
results: list[str] = []
for pdf_name, needed in pdf_needed.items():
fetch_filter = Filter(must=[
FieldCondition(key="conversation_id", match=MatchValue(value=conversation_id)),
FieldCondition(key="pdf_name", match=MatchValue(value=pdf_name)),
FieldCondition(key="chunk_index", match=MatchAny(any=sorted(needed))),
])
fetched, _ = client.scroll(
collection_name=_PDF_COLLECTION,
scroll_filter=fetch_filter,
limit=len(needed) + 5,
with_payload=True,
with_vectors=False,
)
# Map chunk_index → text, rồi sort
chunk_map = {
p.payload["chunk_index"]: p.payload.get("chunk_text", "")
for p in fetched
if "chunk_index" in p.payload
}
sorted_indices = sorted(chunk_map)
if not sorted_indices:
continue
# Gom các chunk_index liên tiếp thành từng run (merge overlapping windows)
runs: list[list[int]] = []
current: list[int] = [sorted_indices[0]]
for idx in sorted_indices[1:]:
if idx == current[-1] + 1:
current.append(idx)
else:
runs.append(current)
current = [idx]
runs.append(current)
for run in runs:
text = "\n\n".join(chunk_map[i] for i in run)
if text.strip():
results.append(text)
return results
# ── Public API ────────────────────────────────────────────────────────────────
def index_pdf(pdf_path: str, pdf_name: str, conversation_id: str) -> int:
"""
Đọc PDF, chunk, embed (dense + sparse) và upsert vào Qdrant.
UUID v5 làm point ID đảm bảo idempotent — gửi lại cùng file không tạo duplicate.
Returns:
Số chunk đã index.
"""
text = pdf_to_markdown(pdf_path)
chunks = _chunk_text(text)
if not chunks:
logger.warning("PDF '%s' không có nội dung để index.", pdf_name)
return 0
client = _get_qdrant()
indexed = 0
for batch_start in range(0, len(chunks), _EMBED_BATCH):
batch = chunks[batch_start : batch_start + _EMBED_BATCH]
dense_vecs = _embed_batch(batch)
sparse_vecs = _bm25_batch(batch)
points = [
PointStruct(
id=str(uuid.uuid5(
uuid.NAMESPACE_DNS,
f"{conversation_id}::{pdf_name}::{batch_start + i}",
)),
vector={
"dense": dense_vecs[i],
"sparse": sparse_vecs[i],
},
payload={
"conversation_id": conversation_id,
"pdf_name": pdf_name,
"chunk_index": batch_start + i,
"chunk_text": batch[i],
},
)
for i in range(len(batch))
]
client.upsert(collection_name=_PDF_COLLECTION, points=points)
indexed += len(points)
logger.info(
"Đã index %d chunks từ '%s' cho conversation '%s'.",
indexed, pdf_name, conversation_id,
)
return indexed
def hybrid_search(query: str, conversation_id: str, top_k: int = 5) -> list[str]:
"""
Hybrid search:
Dense — OpenAI cosine similarity, Qdrant trả cosine score.
Sparse — BM25 dot product, Qdrant trả BM25 score.
Merge — RRF: score = 1/(k + rank_dense) + 1/(k + rank_sparse).
Sau RRF, mỗi chunk được mở rộng sang N-3 đến N+3 trong cùng PDF.
Các cửa sổ chồng lấp tự động được merge thành đoạn liên tục.
"""
client = _get_qdrant()
conv_filter = Filter(must=[
FieldCondition(key="conversation_id", match=MatchValue(value=conversation_id)),
])
# ── Dense search ──────────────────────────────────────────────────────────
dense_hits = client.query_points(
collection_name=_PDF_COLLECTION,
query=_embed_one(query),
using="dense",
query_filter=conv_filter,
limit=top_k * 3,
with_payload=True,
).points
# ── Sparse (BM25) search ──────────────────────────────────────────────────
bm25_vec = _bm25_one(query)
sparse_hits = client.query_points(
collection_name=_PDF_COLLECTION,
query=SparseVector(indices=bm25_vec.indices, values=bm25_vec.values),
using="sparse",
query_filter=conv_filter,
limit=top_k * 3,
with_payload=True,
).points
# ── RRF merge ─────────────────────────────────────────────────────────────
scores: dict[str, float] = {}
payloads: dict[str, dict] = {}
for rank, hit in enumerate(dense_hits):
sid = str(hit.id)
scores[sid] = scores.get(sid, 0.0) + 1.0 / (rank + _RRF_K)
payloads[sid] = hit.payload
for rank, hit in enumerate(sparse_hits):
sid = str(hit.id)
scores[sid] = scores.get(sid, 0.0) + 1.0 / (rank + _RRF_K)
if sid not in payloads:
payloads[sid] = hit.payload
top_ids = sorted(scores, key=scores.__getitem__, reverse=True)[:top_k]
# ── Neighbor expansion ────────────────────────────────────────────────────
hits_meta = [
(payloads[sid].get("pdf_name", ""), payloads[sid].get("chunk_index", 0))
for sid in top_ids
if sid in payloads
]
return _expand_chunks(client, conversation_id, hits_meta)