mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-05 07:12:02 +08:00
feat(plugins): consolidate official plugins with independent releases (LANG-180) (#2797)
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
d099033d79
commit
3a77306c3c
@@ -0,0 +1,17 @@
|
||||
"""Knowledge engine package exports.
|
||||
|
||||
Keep the LangRAG import lazy so importing helper modules such as
|
||||
``components.knowledge_engine.query_rewrite`` does not require optional parser
|
||||
dependencies.
|
||||
"""
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "LangRAG":
|
||||
from .langrag import LangRAG
|
||||
|
||||
return LangRAG
|
||||
raise AttributeError(name)
|
||||
|
||||
|
||||
__all__ = ["LangRAG"]
|
||||
@@ -0,0 +1,400 @@
|
||||
"""Text chunking utilities using recursive character splitting.
|
||||
|
||||
Extracted from langrag.py to allow reuse across different index strategies.
|
||||
Also provides section-aware chunking that preserves document structure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_CHUNK_SIZE = 512
|
||||
DEFAULT_CHUNK_OVERLAP = 50
|
||||
|
||||
# Separators in order of semantic significance (paragraph → sentence → word → char)
|
||||
_SEPARATORS = [
|
||||
"\n\n", # Paragraphs
|
||||
"\n", # Lines
|
||||
". ", # Sentences
|
||||
"。", # Chinese/Japanese sentence end
|
||||
"! ", # Exclamations
|
||||
"? ", # Questions
|
||||
"; ", # Semicolons
|
||||
";", # Chinese semicolon
|
||||
",", # Chinese comma
|
||||
", ", # Commas
|
||||
" ", # Spaces (words)
|
||||
"", # Characters (fallback)
|
||||
]
|
||||
|
||||
|
||||
def chunk_text(text: str, chunk_size: int, chunk_overlap: int) -> list[str]:
|
||||
"""Split text into overlapping chunks using recursive character splitting.
|
||||
|
||||
Attempts to split at natural semantic boundaries (paragraphs, sentences,
|
||||
words) before falling back to character-level splitting. This preserves
|
||||
readability and context compared to a naive sliding-window approach.
|
||||
|
||||
Args:
|
||||
text: The text to split.
|
||||
chunk_size: Maximum characters per chunk.
|
||||
chunk_overlap: Number of overlapping characters between consecutive chunks.
|
||||
|
||||
Returns:
|
||||
List of text chunks.
|
||||
"""
|
||||
if not text:
|
||||
return []
|
||||
|
||||
if chunk_overlap >= chunk_size:
|
||||
logger.warning(
|
||||
f"chunk_overlap ({chunk_overlap}) >= chunk_size ({chunk_size}), "
|
||||
"clamping overlap to chunk_size - 1"
|
||||
)
|
||||
chunk_overlap = chunk_size - 1
|
||||
|
||||
return _split_recursive(text, _SEPARATORS, chunk_size, chunk_overlap)
|
||||
|
||||
|
||||
def _split_recursive(
|
||||
text: str,
|
||||
separators: list[str],
|
||||
chunk_size: int,
|
||||
chunk_overlap: int,
|
||||
) -> list[str]:
|
||||
"""Recursively split *text* trying each separator in priority order."""
|
||||
final_chunks: list[str] = []
|
||||
separator = separators[0] if separators else ""
|
||||
remaining_separators = separators[1:] if len(separators) > 1 else []
|
||||
|
||||
splits = _split_by_separator(text, separator)
|
||||
|
||||
current_parts: list[str] = []
|
||||
current_len = 0
|
||||
|
||||
for part in splits:
|
||||
part_len = len(part)
|
||||
|
||||
# Single piece already exceeds limit → recurse with finer separator
|
||||
if part_len > chunk_size:
|
||||
# Flush accumulated parts first
|
||||
if current_parts:
|
||||
final_chunks.append("".join(current_parts))
|
||||
current_parts = _overlap_parts(current_parts, chunk_overlap)
|
||||
current_len = sum(len(p) for p in current_parts)
|
||||
|
||||
if remaining_separators:
|
||||
final_chunks.extend(
|
||||
_split_recursive(part, remaining_separators, chunk_size, chunk_overlap)
|
||||
)
|
||||
else:
|
||||
final_chunks.extend(_split_by_char(part, chunk_size, chunk_overlap))
|
||||
continue
|
||||
|
||||
# Would adding this part exceed the limit?
|
||||
if current_parts and current_len + part_len > chunk_size:
|
||||
final_chunks.append("".join(current_parts))
|
||||
current_parts = _overlap_parts(current_parts, chunk_overlap)
|
||||
current_len = sum(len(p) for p in current_parts)
|
||||
|
||||
current_parts.append(part)
|
||||
current_len += part_len
|
||||
|
||||
if current_parts:
|
||||
final_chunks.append("".join(current_parts))
|
||||
|
||||
return final_chunks
|
||||
|
||||
|
||||
def _split_by_separator(text: str, separator: str) -> list[str]:
|
||||
"""Split *text* by *separator*, keeping the separator attached to the left piece."""
|
||||
if separator == "":
|
||||
return list(text)
|
||||
|
||||
parts = text.split(separator)
|
||||
result: list[str] = []
|
||||
for piece in parts[:-1]:
|
||||
if piece or separator.strip():
|
||||
result.append(piece + separator)
|
||||
if parts[-1]:
|
||||
result.append(parts[-1])
|
||||
return result
|
||||
|
||||
|
||||
def _overlap_parts(parts: list[str], overlap: int) -> list[str]:
|
||||
"""Return trailing *parts* whose combined length fits within *overlap*."""
|
||||
if overlap <= 0:
|
||||
return []
|
||||
|
||||
kept: list[str] = []
|
||||
total = 0
|
||||
for part in reversed(parts):
|
||||
if total + len(part) > overlap:
|
||||
break
|
||||
kept.insert(0, part)
|
||||
total += len(part)
|
||||
return kept
|
||||
|
||||
|
||||
def _split_by_char(text: str, chunk_size: int, overlap: int) -> list[str]:
|
||||
"""Last-resort character-level split."""
|
||||
chunks: list[str] = []
|
||||
start = 0
|
||||
while start < len(text):
|
||||
chunks.append(text[start : start + chunk_size])
|
||||
start += chunk_size - overlap
|
||||
return chunks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section-aware chunking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Regex matching a contiguous block of Markdown table rows (|...|)
|
||||
_TABLE_LINE_RE = re.compile(r"^(\|.+\|)\s*$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SectionChunk:
|
||||
"""A chunk derived from a structured section, carrying structural metadata."""
|
||||
|
||||
text: str
|
||||
heading: str | None = None
|
||||
level: int = 0
|
||||
page: int | None = None
|
||||
heading_path: str = ""
|
||||
has_table: bool = False
|
||||
section_index: int = 0
|
||||
|
||||
|
||||
def chunk_sections(
|
||||
sections: list,
|
||||
chunk_size: int,
|
||||
chunk_overlap: int,
|
||||
) -> list[SectionChunk]:
|
||||
"""Split structured sections into chunks while preserving heading/page metadata.
|
||||
|
||||
Each section is chunked independently. Short sections (≤ *chunk_size*)
|
||||
become a single chunk; long sections are split internally using
|
||||
table-aware logic that avoids breaking Markdown tables.
|
||||
|
||||
Args:
|
||||
sections: List of TextSection objects (from ParseResult).
|
||||
chunk_size: Maximum characters per chunk.
|
||||
chunk_overlap: Overlap between consecutive chunks within a section.
|
||||
|
||||
Returns:
|
||||
List of :class:`SectionChunk` instances.
|
||||
"""
|
||||
if not sections:
|
||||
return []
|
||||
|
||||
heading_paths = _compute_heading_paths(sections)
|
||||
result: list[SectionChunk] = []
|
||||
|
||||
for sec_idx, section in enumerate(sections):
|
||||
content = section.content
|
||||
if not content or not content.strip():
|
||||
continue
|
||||
|
||||
heading = getattr(section, "heading", None)
|
||||
level = getattr(section, "level", 0)
|
||||
page = getattr(section, "page", None)
|
||||
h_path = heading_paths[sec_idx] if sec_idx < len(heading_paths) else ""
|
||||
|
||||
sub_chunks = _split_section_content(content, chunk_size, chunk_overlap)
|
||||
|
||||
for sc_text, sc_has_table in sub_chunks:
|
||||
result.append(
|
||||
SectionChunk(
|
||||
text=sc_text,
|
||||
heading=heading,
|
||||
level=level,
|
||||
page=page,
|
||||
heading_path=h_path,
|
||||
has_table=sc_has_table,
|
||||
section_index=sec_idx,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _compute_heading_paths(sections: list) -> list[str]:
|
||||
"""Compute hierarchical heading paths for each section using a stack.
|
||||
|
||||
For example, if sections have headings at levels:
|
||||
[("Chapter 1", 1), ("1.1 Overview", 2), ("1.2 Details", 2), ("Chapter 2", 1)]
|
||||
the paths would be:
|
||||
["Chapter 1", "Chapter 1 > 1.1 Overview", "Chapter 1 > 1.2 Details", "Chapter 2"]
|
||||
"""
|
||||
stack: list[tuple[int, str]] = [] # (level, heading)
|
||||
paths: list[str] = []
|
||||
|
||||
for section in sections:
|
||||
heading = getattr(section, "heading", None) or ""
|
||||
level = getattr(section, "level", 0)
|
||||
|
||||
# Pop entries at same or deeper level
|
||||
while stack and stack[-1][0] >= level:
|
||||
stack.pop()
|
||||
|
||||
stack.append((level, heading))
|
||||
paths.append(" > ".join(h for _, h in stack if h))
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def _segment_text_and_tables(content: str) -> list[tuple[str, bool]]:
|
||||
"""Split content into alternating (text_block, is_table) segments.
|
||||
|
||||
Contiguous lines matching ``|...|`` are grouped as a single table segment.
|
||||
Everything else is grouped as a text segment.
|
||||
|
||||
Returns:
|
||||
List of ``(segment_text, is_table)`` tuples.
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
segments: list[tuple[str, bool]] = []
|
||||
current_lines: list[str] = []
|
||||
in_table = False
|
||||
|
||||
for line in lines:
|
||||
is_table_line = bool(_TABLE_LINE_RE.match(line))
|
||||
|
||||
if is_table_line != in_table:
|
||||
# Flush current segment
|
||||
if current_lines:
|
||||
text = "\n".join(current_lines)
|
||||
if text.strip():
|
||||
segments.append((text, in_table))
|
||||
current_lines = []
|
||||
in_table = is_table_line
|
||||
|
||||
current_lines.append(line)
|
||||
|
||||
# Flush last segment
|
||||
if current_lines:
|
||||
text = "\n".join(current_lines)
|
||||
if text.strip():
|
||||
segments.append((text, in_table))
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def _split_section_content(
|
||||
content: str, chunk_size: int, overlap: int
|
||||
) -> list[tuple[str, bool]]:
|
||||
"""Split a single section's content into chunks, keeping tables intact.
|
||||
|
||||
Returns:
|
||||
List of ``(chunk_text, has_table)`` tuples.
|
||||
"""
|
||||
# If the whole section fits, return it directly
|
||||
if len(content) <= chunk_size:
|
||||
has_table = bool(_TABLE_LINE_RE.search(content))
|
||||
return [(content, has_table)]
|
||||
|
||||
segments = _segment_text_and_tables(content)
|
||||
|
||||
# If only one segment (no tables or all-table), use simpler logic
|
||||
if len(segments) == 1:
|
||||
seg_text, is_table = segments[0]
|
||||
if is_table:
|
||||
return [(t, True) for t in _split_table_by_rows(seg_text, chunk_size)]
|
||||
else:
|
||||
return [(t, False) for t in chunk_text(seg_text, chunk_size, overlap)]
|
||||
|
||||
# Multiple segments: try to pack segments into chunks respecting chunk_size
|
||||
result: list[tuple[str, bool]] = []
|
||||
current_parts: list[str] = []
|
||||
current_len = 0
|
||||
current_has_table = False
|
||||
|
||||
for seg_text, is_table in segments:
|
||||
seg_len = len(seg_text)
|
||||
|
||||
if is_table and seg_len > chunk_size:
|
||||
# Flush accumulated text first
|
||||
if current_parts:
|
||||
result.append(("\n".join(current_parts), current_has_table))
|
||||
current_parts = []
|
||||
current_len = 0
|
||||
current_has_table = False
|
||||
# Split the oversized table
|
||||
for t in _split_table_by_rows(seg_text, chunk_size):
|
||||
result.append((t, True))
|
||||
continue
|
||||
|
||||
if not is_table and seg_len > chunk_size:
|
||||
# Flush accumulated text first
|
||||
if current_parts:
|
||||
result.append(("\n".join(current_parts), current_has_table))
|
||||
current_parts = []
|
||||
current_len = 0
|
||||
current_has_table = False
|
||||
# Split the oversized text
|
||||
for t in chunk_text(seg_text, chunk_size, overlap):
|
||||
result.append((t, False))
|
||||
continue
|
||||
|
||||
# Would adding this segment exceed chunk_size?
|
||||
added_len = seg_len + (1 if current_parts else 0) # newline joiner
|
||||
if current_parts and current_len + added_len > chunk_size:
|
||||
result.append(("\n".join(current_parts), current_has_table))
|
||||
current_parts = []
|
||||
current_len = 0
|
||||
current_has_table = False
|
||||
|
||||
current_parts.append(seg_text)
|
||||
current_len += added_len
|
||||
if is_table:
|
||||
current_has_table = True
|
||||
|
||||
if current_parts:
|
||||
result.append(("\n".join(current_parts), current_has_table))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _split_table_by_rows(table_text: str, chunk_size: int) -> list[str]:
|
||||
"""Split an oversized Markdown table by rows, preserving the header.
|
||||
|
||||
Keeps the first row (header) and the second row (separator ``|---|``)
|
||||
at the top of every chunk so that each chunk is a valid table fragment.
|
||||
"""
|
||||
lines = table_text.split("\n")
|
||||
if len(lines) <= 2:
|
||||
return [table_text]
|
||||
|
||||
# Identify header: first two lines (header + separator)
|
||||
header_lines = lines[:2]
|
||||
header_text = "\n".join(header_lines)
|
||||
header_len = len(header_text)
|
||||
|
||||
data_lines = lines[2:]
|
||||
if not data_lines:
|
||||
return [table_text]
|
||||
|
||||
chunks: list[str] = []
|
||||
current_rows: list[str] = []
|
||||
current_len = header_len
|
||||
|
||||
for row in data_lines:
|
||||
row_len = len(row) + 1 # +1 for newline
|
||||
if current_rows and current_len + row_len > chunk_size:
|
||||
chunks.append(header_text + "\n" + "\n".join(current_rows))
|
||||
current_rows = []
|
||||
current_len = header_len
|
||||
|
||||
current_rows.append(row)
|
||||
current_len += row_len
|
||||
|
||||
if current_rows:
|
||||
chunks.append(header_text + "\n" + "\n".join(current_rows))
|
||||
|
||||
return chunks if chunks else [table_text]
|
||||
@@ -0,0 +1,964 @@
|
||||
import logging
|
||||
from collections.abc import Awaitable, Iterable
|
||||
from components.shared_state import SerialState, serialized
|
||||
|
||||
from langbot_plugin.api.definition.components.knowledge_engine import (
|
||||
KnowledgeEngine,
|
||||
KnowledgeEngineCapability,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.rag import (
|
||||
IngestionContext,
|
||||
IngestionResult,
|
||||
RetrievalContext,
|
||||
RetrievalResponse,
|
||||
RetrievalResultEntry,
|
||||
DocumentStatus,
|
||||
SearchType,
|
||||
)
|
||||
|
||||
from .parser import FileParser
|
||||
from .query_rewrite import retrieve_with_rewrite
|
||||
from .rerank import llm_rerank, model_rerank
|
||||
from .strategies import get_strategy
|
||||
from components.observability.telemetry import add_stage_duration, hash_text, telemetry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Batch size for embedding API calls.
|
||||
# Larger batches = fewer round-trips. Keep under ~64 to avoid IPC response
|
||||
# timeouts.
|
||||
EMBEDDING_BATCH_SIZE = 32
|
||||
PARSED_TEXT_LIMIT_BYTES = 4 * 1024 * 1024
|
||||
|
||||
|
||||
def _validate_parsed_text_budget(parts: Iterable[str]) -> None:
|
||||
"""Bound aggregate UTF-8 content before strategy work, without joining it."""
|
||||
total_bytes = 0
|
||||
for text in parts:
|
||||
# Encode bounded slices so even a rejected input cannot require a
|
||||
# second, arbitrarily large allocation just to measure its byte size.
|
||||
for offset in range(0, len(text), 64 * 1024):
|
||||
total_bytes += len(text[offset:offset + 64 * 1024].encode("utf-8"))
|
||||
if total_bytes > PARSED_TEXT_LIMIT_BYTES:
|
||||
raise ValueError("Parsed text exceeds the 4 MiB UTF-8 byte limit")
|
||||
|
||||
|
||||
def _query_log_ref(query: str | None) -> str:
|
||||
"""Return a privacy-preserving query reference for logs."""
|
||||
return f"hash={hash_text(query)}, length={len(query or '')}"
|
||||
|
||||
|
||||
def _trace_spans(trace_id: str, stage_durations: dict[str, float]) -> list[dict]:
|
||||
"""Build LangRAG-local spans from recorded stage timings."""
|
||||
return [
|
||||
{
|
||||
"trace_id": trace_id,
|
||||
"span_id": f"{trace_id}:{stage}",
|
||||
"name": stage,
|
||||
"kind": "langrag.stage",
|
||||
"status": "completed",
|
||||
"duration_ms": duration,
|
||||
}
|
||||
for stage, duration in stage_durations.items()
|
||||
]
|
||||
|
||||
|
||||
class LangRAG(KnowledgeEngine):
|
||||
"""Simple Knowledge Engine implementation using Plugin IPC.
|
||||
|
||||
Provides:
|
||||
- Document ingestion with parsing, chunking, embedding, and vector storage
|
||||
- Vector-based retrieval
|
||||
- Full integration with Host's embedding models and vector database
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._state = SerialState()
|
||||
|
||||
async def initialize(self) -> None:
|
||||
await super().initialize()
|
||||
# The runtime dispatches installation revocation to the plugin instance,
|
||||
# which holds no back-reference to its components; hand it this
|
||||
# component's state so the revocation hook can drop per-binding locks and
|
||||
# fences. Without the hook (langbot-plugin 0.7.4) this attribute is simply
|
||||
# never read.
|
||||
plugin = getattr(self, 'plugin', None)
|
||||
if plugin is not None:
|
||||
plugin.knowledge_engine_serial_state = self._state
|
||||
|
||||
@classmethod
|
||||
def get_capabilities(cls) -> list[str]:
|
||||
"""Declare supported capabilities."""
|
||||
return [
|
||||
KnowledgeEngineCapability.DOC_INGESTION,
|
||||
KnowledgeEngineCapability.DOC_PARSING,
|
||||
]
|
||||
|
||||
# ========== Lifecycle Hooks ==========
|
||||
|
||||
async def on_knowledge_base_create(self, kb_id: str, config: dict) -> None:
|
||||
logger.info(f"Knowledge base created: {kb_id}")
|
||||
|
||||
async def on_knowledge_base_delete(self, kb_id: str) -> None:
|
||||
logger.info(f"Knowledge base deleted: {kb_id}")
|
||||
# Deleting a knowledge base resolves its pending ambiguity, so this is the
|
||||
# operator path out of a fence; a fresh knowledge base starts unfenced.
|
||||
await self._state.clear_fence(self.plugin, kb_id)
|
||||
|
||||
# ========== Helpers ==========
|
||||
|
||||
async def _embed_and_upsert(
|
||||
self,
|
||||
collection_id: str,
|
||||
embedding_model_uuid: str,
|
||||
texts: list[str],
|
||||
ids: list[str],
|
||||
metas: list[dict],
|
||||
trace_id: str | None = None,
|
||||
parent_stage_durations: dict[str, float] | None = None,
|
||||
) -> int:
|
||||
"""Embed a batch of texts and upsert into the vector store.
|
||||
|
||||
Returns the number of vectors stored.
|
||||
"""
|
||||
telemetry = self.plugin.telemetry
|
||||
started_at = telemetry.start_timer()
|
||||
status = "failed"
|
||||
error = None
|
||||
vectors_stored = 0
|
||||
stage_durations: dict[str, float] = {}
|
||||
try:
|
||||
stage_started = telemetry.start_timer()
|
||||
vectors = await self.plugin.invoke_embedding(embedding_model_uuid, texts)
|
||||
add_stage_duration(
|
||||
stage_durations,
|
||||
"embedding",
|
||||
telemetry.elapsed_ms(stage_started),
|
||||
)
|
||||
if parent_stage_durations is not None:
|
||||
add_stage_duration(
|
||||
parent_stage_durations,
|
||||
"embedding",
|
||||
stage_durations["embedding"],
|
||||
)
|
||||
stage_started = telemetry.start_timer()
|
||||
await self._state.mutate(
|
||||
self.plugin,
|
||||
collection_id,
|
||||
lambda: self.plugin.vector_upsert(
|
||||
collection_id=collection_id,
|
||||
vectors=vectors,
|
||||
ids=ids,
|
||||
metadata=metas,
|
||||
documents=texts,
|
||||
),
|
||||
'Host vector_upsert outcome unknown',
|
||||
)
|
||||
add_stage_duration(
|
||||
stage_durations,
|
||||
"vector_upsert",
|
||||
telemetry.elapsed_ms(stage_started),
|
||||
)
|
||||
if parent_stage_durations is not None:
|
||||
add_stage_duration(
|
||||
parent_stage_durations,
|
||||
"vector_upsert",
|
||||
stage_durations["vector_upsert"],
|
||||
)
|
||||
vectors_stored = len(texts)
|
||||
status = "completed"
|
||||
return vectors_stored
|
||||
except Exception as e:
|
||||
error = e
|
||||
raise
|
||||
finally:
|
||||
await self._record_diagnostic(
|
||||
"record_embedding_batch",
|
||||
telemetry.record_embedding_batch(
|
||||
collection_id=collection_id,
|
||||
ids=ids,
|
||||
metas=metas,
|
||||
texts=texts,
|
||||
status=status,
|
||||
duration_ms=telemetry.elapsed_ms(started_at),
|
||||
vectors_stored=vectors_stored,
|
||||
stage_durations_ms=stage_durations,
|
||||
trace_id=trace_id,
|
||||
error=error,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _record_diagnostic(label: str, awaitable: Awaitable[None]) -> None:
|
||||
"""Record one diagnostic best effort.
|
||||
|
||||
Telemetry is observability, never a precondition: a fenced or unreachable
|
||||
telemetry state must not fail the operation it describes, and it must not
|
||||
replace the error the caller is about to see.
|
||||
"""
|
||||
try:
|
||||
await awaitable
|
||||
except Exception:
|
||||
logger.warning("LangRAG telemetry %s failed", label, exc_info=True)
|
||||
|
||||
@staticmethod
|
||||
def _metadata_int(value) -> int | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str) and value.isdigit():
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _neighbor_id(meta: dict, offset: int) -> str | None:
|
||||
"""Build the vector ID for an adjacent retrieval unit.
|
||||
|
||||
The three index strategies use different vector ID schemes. Context
|
||||
expansion only needs one representative vector per adjacent unit
|
||||
because the stored metadata ``text`` field carries the display context.
|
||||
"""
|
||||
doc_id = meta.get("document_id", "")
|
||||
index_type = meta.get("index_type", "chunk")
|
||||
|
||||
if index_type == "parent_child":
|
||||
parent_idx = LangRAG._metadata_int(meta.get("parent_index"))
|
||||
if doc_id and parent_idx is not None and parent_idx + offset >= 0:
|
||||
return f"{doc_id}_p{parent_idx + offset}_c0"
|
||||
return None
|
||||
|
||||
if index_type == "qa":
|
||||
chunk_idx = LangRAG._metadata_int(meta.get("chunk_index"))
|
||||
if doc_id and chunk_idx is not None and chunk_idx + offset >= 0:
|
||||
return f"{doc_id}_{chunk_idx + offset}_qa0"
|
||||
return None
|
||||
|
||||
chunk_idx = LangRAG._metadata_int(meta.get("chunk_index"))
|
||||
if doc_id and chunk_idx is not None and chunk_idx + offset >= 0:
|
||||
return f"{doc_id}_{chunk_idx + offset}"
|
||||
return None
|
||||
|
||||
async def _expand_context(
|
||||
self,
|
||||
results: list[dict],
|
||||
collection_id: str,
|
||||
window: int,
|
||||
) -> None:
|
||||
"""Expand each result with adjacent chunks from the same document.
|
||||
|
||||
For each hit, looks up adjacent retrieval units from the same document
|
||||
and appends their text to the result's metadata as
|
||||
``context_before`` and ``context_after``.
|
||||
|
||||
Requires the vector store to support ``vector_get_by_ids``. If the
|
||||
method is unavailable or fails, this is a no-op.
|
||||
"""
|
||||
get_by_ids = getattr(self.plugin, "vector_get_by_ids", None)
|
||||
if not callable(get_by_ids):
|
||||
return
|
||||
|
||||
ids_to_fetch: set[str] = set()
|
||||
# Map result → adjacent IDs needed
|
||||
for res in results:
|
||||
meta = res.get("metadata", {})
|
||||
for offset in range(1, window + 1):
|
||||
before_id = self._neighbor_id(meta, -offset)
|
||||
after_id = self._neighbor_id(meta, offset)
|
||||
if before_id:
|
||||
ids_to_fetch.add(before_id)
|
||||
if after_id:
|
||||
ids_to_fetch.add(after_id)
|
||||
|
||||
if not ids_to_fetch:
|
||||
return
|
||||
|
||||
adjacent = await get_by_ids(
|
||||
collection_id=collection_id, ids=list(ids_to_fetch)
|
||||
)
|
||||
# Build lookup: id → text
|
||||
adj_map: dict[str, str] = {}
|
||||
for item in adjacent:
|
||||
item_id = item.get("id", "")
|
||||
item_text = item.get("metadata", {}).get("text", "")
|
||||
if item_id and item_text:
|
||||
adj_map[item_id] = item_text
|
||||
|
||||
# Attach context to results
|
||||
for res in results:
|
||||
meta = res.get("metadata", {})
|
||||
before_parts = []
|
||||
after_parts = []
|
||||
for offset in range(1, window + 1):
|
||||
before_id = self._neighbor_id(meta, -offset)
|
||||
after_id = self._neighbor_id(meta, offset)
|
||||
if before_id in adj_map:
|
||||
before_parts.insert(0, adj_map[before_id])
|
||||
if after_id in adj_map:
|
||||
after_parts.append(adj_map[after_id])
|
||||
if before_parts:
|
||||
meta["context_before"] = "\n".join(before_parts)
|
||||
if after_parts:
|
||||
meta["context_after"] = "\n".join(after_parts)
|
||||
|
||||
@staticmethod
|
||||
async def _timed_batches(generator, stage_durations: dict[str, float]):
|
||||
"""Yield strategy batches while accumulating generator runtime."""
|
||||
iterator = generator.__aiter__()
|
||||
while True:
|
||||
stage_started = telemetry.start_timer()
|
||||
try:
|
||||
batch = await iterator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
else:
|
||||
add_stage_duration(
|
||||
stage_durations,
|
||||
"chunking",
|
||||
telemetry.elapsed_ms(stage_started),
|
||||
)
|
||||
yield batch
|
||||
|
||||
# ========== Core Methods ==========
|
||||
|
||||
@serialized
|
||||
async def ingest(self, context: IngestionContext) -> IngestionResult:
|
||||
"""Handle document ingestion: Read -> Parse -> Chunk -> Embed -> Store.
|
||||
|
||||
Uses an async generator pipeline: the strategy yields batches
|
||||
incrementally, and each batch is embedded and upserted as soon as it
|
||||
is ready. This ensures partial results are persisted early.
|
||||
"""
|
||||
telemetry = self.plugin.telemetry
|
||||
started_at = telemetry.start_timer()
|
||||
trace_id = telemetry.new_trace_id("ingest")
|
||||
stage_durations: dict[str, float] = {}
|
||||
doc_id = context.file_object.metadata.document_id
|
||||
filename = context.file_object.metadata.filename
|
||||
file_size = getattr(context.file_object.metadata, "file_size", None)
|
||||
metadata_extra = getattr(context.file_object.metadata, "extra", {}) or {}
|
||||
collection_id = context.get_collection_id()
|
||||
index_type = context.creation_settings.get("index_type") or "chunk"
|
||||
telemetry_status = DocumentStatus.FAILED
|
||||
telemetry_error = None
|
||||
telemetry_chunks_created = 0
|
||||
telemetry_text_length = None
|
||||
telemetry_content_hash = None
|
||||
telemetry_sections_count = None
|
||||
parser_source = None
|
||||
correlation = telemetry.correlation_summary(
|
||||
context.creation_settings,
|
||||
metadata_extra,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Ingesting file: {filename} (doc={doc_id}) into collection: {collection_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
# 1. Parse file content (prefer pre-parsed content from external Parser plugin)
|
||||
sections = None
|
||||
doc_metadata = None
|
||||
if context.parsed_content and context.parsed_content.text:
|
||||
stage_started = telemetry.start_timer()
|
||||
text_content = context.parsed_content.text
|
||||
parser_source = "external"
|
||||
logger.info(
|
||||
f"Using pre-parsed content from external parser for {filename}"
|
||||
)
|
||||
# Extract structured sections and metadata if available
|
||||
if context.parsed_content.sections:
|
||||
sections = context.parsed_content.sections
|
||||
telemetry_sections_count = len(sections)
|
||||
logger.info(
|
||||
f"Found {len(sections)} structured sections from parser"
|
||||
)
|
||||
if context.parsed_content.metadata:
|
||||
doc_metadata = context.parsed_content.metadata
|
||||
correlation.update(
|
||||
telemetry.correlation_summary(doc_metadata, trace_id=trace_id)
|
||||
)
|
||||
logger.info(
|
||||
f"Found document metadata from parser: "
|
||||
f"{[k for k in doc_metadata if k != 'images']}"
|
||||
)
|
||||
add_stage_duration(
|
||||
stage_durations,
|
||||
"parser_external",
|
||||
telemetry.elapsed_ms(stage_started),
|
||||
)
|
||||
else:
|
||||
parser_source = "internal"
|
||||
logger.warning(
|
||||
f"No external parser content for {filename}; "
|
||||
"falling back to internal FileParser. Consider configuring an "
|
||||
"external parser (e.g. GeneralParsers) for better results."
|
||||
)
|
||||
try:
|
||||
stage_started = telemetry.start_timer()
|
||||
content_bytes = await self.plugin.get_knowledge_file_stream(
|
||||
context.file_object.storage_path
|
||||
)
|
||||
add_stage_duration(
|
||||
stage_durations,
|
||||
"file_read",
|
||||
telemetry.elapsed_ms(stage_started),
|
||||
)
|
||||
except Exception as e:
|
||||
telemetry_error = e
|
||||
telemetry_status = DocumentStatus.FAILED
|
||||
logger.error(f"Failed to get file content: {e}")
|
||||
return IngestionResult(
|
||||
document_id=doc_id,
|
||||
status=DocumentStatus.FAILED,
|
||||
error_message=f"Could not read file: {e}",
|
||||
)
|
||||
parser = FileParser(self.plugin.offload)
|
||||
stage_started = telemetry.start_timer()
|
||||
text_content = await parser.parse(content_bytes, filename)
|
||||
add_stage_duration(
|
||||
stage_durations,
|
||||
"parser_internal",
|
||||
telemetry.elapsed_ms(stage_started),
|
||||
)
|
||||
|
||||
# Bound both representations independently: strategies consume the
|
||||
# sections when present, not necessarily the parser's flat text.
|
||||
_validate_parsed_text_budget((text_content or "",))
|
||||
if sections:
|
||||
_validate_parsed_text_budget(section.content for section in sections)
|
||||
telemetry_text_length = len(text_content) if text_content else 0
|
||||
telemetry_content_hash = hash_text(text_content)
|
||||
|
||||
if not text_content:
|
||||
telemetry_status = DocumentStatus.COMPLETED
|
||||
logger.warning(f"No text content extracted from file: {filename}")
|
||||
return IngestionResult(
|
||||
document_id=doc_id,
|
||||
status=DocumentStatus.COMPLETED,
|
||||
chunks_created=0,
|
||||
)
|
||||
|
||||
# 3. Build chunks via strategy (async generator)
|
||||
strategy = get_strategy(index_type)
|
||||
embedding_model_uuid = context.creation_settings.get(
|
||||
"embedding_model_uuid", ""
|
||||
)
|
||||
logger.info(f"Strategy: {index_type} ({strategy.__class__.__name__})")
|
||||
|
||||
# 4. Progressive ingest: consume generator → accumulate → embed+upsert
|
||||
# when a full batch is ready. Embedding happens between generator
|
||||
# yields (sequential, not concurrent) to avoid IPC timeout issues.
|
||||
pending_texts: list[str] = []
|
||||
pending_ids: list[str] = []
|
||||
pending_metas: list[dict] = []
|
||||
total_stored = 0
|
||||
|
||||
async for (
|
||||
batch_texts,
|
||||
batch_ids,
|
||||
batch_metas,
|
||||
) in self._timed_batches(
|
||||
strategy.build_chunks_and_metadata(
|
||||
text_content,
|
||||
doc_id,
|
||||
filename,
|
||||
context.creation_settings,
|
||||
plugin=self.plugin,
|
||||
sections=sections,
|
||||
doc_metadata=doc_metadata,
|
||||
),
|
||||
stage_durations,
|
||||
):
|
||||
pending_texts.extend(batch_texts)
|
||||
pending_ids.extend(batch_ids)
|
||||
pending_metas.extend(batch_metas)
|
||||
|
||||
# Flush full batches immediately
|
||||
while len(pending_texts) >= EMBEDDING_BATCH_SIZE:
|
||||
t = pending_texts[:EMBEDDING_BATCH_SIZE]
|
||||
i = pending_ids[:EMBEDDING_BATCH_SIZE]
|
||||
m = pending_metas[:EMBEDDING_BATCH_SIZE]
|
||||
pending_texts = pending_texts[EMBEDDING_BATCH_SIZE:]
|
||||
pending_ids = pending_ids[EMBEDDING_BATCH_SIZE:]
|
||||
pending_metas = pending_metas[EMBEDDING_BATCH_SIZE:]
|
||||
total_stored += await self._embed_and_upsert(
|
||||
collection_id,
|
||||
embedding_model_uuid,
|
||||
t,
|
||||
i,
|
||||
m,
|
||||
trace_id=trace_id,
|
||||
parent_stage_durations=stage_durations,
|
||||
)
|
||||
telemetry_chunks_created = total_stored
|
||||
|
||||
# Flush remaining
|
||||
if pending_texts:
|
||||
total_stored += await self._embed_and_upsert(
|
||||
collection_id,
|
||||
embedding_model_uuid,
|
||||
pending_texts,
|
||||
pending_ids,
|
||||
pending_metas,
|
||||
trace_id=trace_id,
|
||||
parent_stage_durations=stage_durations,
|
||||
)
|
||||
telemetry_chunks_created = total_stored
|
||||
|
||||
if total_stored:
|
||||
unit = "Q&A pairs" if index_type == "qa" else "chunks"
|
||||
logger.info(f"Ingestion complete: {total_stored} {unit} stored")
|
||||
|
||||
telemetry_status = DocumentStatus.COMPLETED
|
||||
telemetry_chunks_created = total_stored
|
||||
return IngestionResult(
|
||||
document_id=doc_id,
|
||||
status=DocumentStatus.COMPLETED,
|
||||
chunks_created=total_stored,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
telemetry_error = e
|
||||
telemetry_status = DocumentStatus.FAILED
|
||||
logger.error(f"Ingestion failed for {filename}: {e}")
|
||||
return IngestionResult(
|
||||
document_id=doc_id,
|
||||
status=DocumentStatus.FAILED,
|
||||
error_message=str(e),
|
||||
)
|
||||
finally:
|
||||
await self._record_diagnostic(
|
||||
"record_ingest",
|
||||
telemetry.record_ingest(
|
||||
document_id=doc_id,
|
||||
filename=filename,
|
||||
collection_id=collection_id,
|
||||
status=telemetry_status,
|
||||
duration_ms=telemetry.elapsed_ms(started_at),
|
||||
index_type=index_type,
|
||||
chunks_created=telemetry_chunks_created,
|
||||
file_size=file_size,
|
||||
text_length=telemetry_text_length,
|
||||
content_hash=telemetry_content_hash,
|
||||
sections_count=telemetry_sections_count,
|
||||
settings=context.creation_settings,
|
||||
stage_durations_ms=stage_durations,
|
||||
parser_source=parser_source,
|
||||
knowledge_base_id=context.knowledge_base_id,
|
||||
trace_id=trace_id,
|
||||
correlation=correlation,
|
||||
error=telemetry_error,
|
||||
),
|
||||
)
|
||||
|
||||
async def retrieve(self, context: RetrievalContext) -> RetrievalResponse:
|
||||
"""Retrieve relevant content with support for vector, full-text, and hybrid search."""
|
||||
telemetry = self.plugin.telemetry
|
||||
started_at = telemetry.start_timer()
|
||||
trace_id = telemetry.new_trace_id("retrieval")
|
||||
stage_durations: dict[str, float] = {}
|
||||
query = context.query
|
||||
top_k = context.retrieval_settings.get("top_k", 5)
|
||||
collection_id = context.get_collection_id()
|
||||
search_type = context.retrieval_settings.get("search_type", SearchType.VECTOR)
|
||||
raw_count = None
|
||||
result_count = None
|
||||
fetch_k = None
|
||||
reranked = False
|
||||
telemetry_status = "failed"
|
||||
telemetry_error = None
|
||||
reference_count = None
|
||||
heading_count = None
|
||||
context_expanded_count = None
|
||||
distance_min = None
|
||||
distance_avg = None
|
||||
distance_max = None
|
||||
correlation = telemetry.correlation_summary(
|
||||
context.filters,
|
||||
context.creation_settings,
|
||||
context.retrieval_settings,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
try:
|
||||
# Read hybrid fusion weight (YAML default 0.7; vdb fallback handles None)
|
||||
vector_weight = float(context.retrieval_settings.get("vector_weight", 0.7))
|
||||
|
||||
# Determine strategy for post-processing
|
||||
index_type = context.creation_settings.get("index_type") or "chunk"
|
||||
strategy = get_strategy(index_type)
|
||||
logger.info(
|
||||
f"Retrieve: strategy={index_type}, top_k={top_k}, "
|
||||
f"query_rewrite={context.retrieval_settings.get('query_rewrite', 'off')}, "
|
||||
f"query_ref=({_query_log_ref(query)})"
|
||||
)
|
||||
logger.info(
|
||||
f"Retrieve search config: search_type={search_type}, "
|
||||
f"vector_weight={vector_weight}"
|
||||
)
|
||||
|
||||
rerank_mode = context.retrieval_settings.get("rerank", "off")
|
||||
rerank_llm = context.retrieval_settings.get("rerank_llm_model_uuid", "")
|
||||
rerank_model = context.retrieval_settings.get("rerank_model_uuid", "")
|
||||
rerank_enabled = (
|
||||
rerank_mode == "llm"
|
||||
and bool(rerank_llm)
|
||||
) or (
|
||||
rerank_mode in ("rerank_model", "model")
|
||||
and bool(rerank_model)
|
||||
)
|
||||
|
||||
# For parent_child/qa, over-fetch to allow dedup to still yield top_k.
|
||||
# When reranking is enabled, keep a larger candidate pool for rerankers.
|
||||
if index_type in ("parent_child", "qa") or rerank_enabled:
|
||||
fetch_k = top_k * 3
|
||||
else:
|
||||
fetch_k = top_k
|
||||
|
||||
# Check query rewrite settings
|
||||
query_rewrite = context.retrieval_settings.get("query_rewrite", "off")
|
||||
rewrite_llm = context.retrieval_settings.get("rewrite_llm_model_uuid", "")
|
||||
|
||||
if query_rewrite != "off" and rewrite_llm:
|
||||
logger.info(f"Query rewrite enabled: strategy={query_rewrite}")
|
||||
stage_started = telemetry.start_timer()
|
||||
results = await retrieve_with_rewrite(
|
||||
plugin=self.plugin,
|
||||
query=query,
|
||||
query_rewrite=query_rewrite,
|
||||
rewrite_llm=rewrite_llm,
|
||||
collection_id=collection_id,
|
||||
embedding_model_uuid=context.creation_settings.get(
|
||||
"embedding_model_uuid", ""
|
||||
),
|
||||
fetch_k=fetch_k,
|
||||
filters=context.filters or None,
|
||||
search_type=search_type,
|
||||
vector_weight=vector_weight,
|
||||
stage_durations=stage_durations,
|
||||
)
|
||||
add_stage_duration(
|
||||
stage_durations,
|
||||
f"rewrite_{query_rewrite}",
|
||||
telemetry.elapsed_ms(stage_started),
|
||||
)
|
||||
else:
|
||||
# Original logic: embed query → vector_search
|
||||
query_vector: list[float] = []
|
||||
if search_type != SearchType.FULL_TEXT:
|
||||
embedding_model_uuid = context.creation_settings.get(
|
||||
"embedding_model_uuid", ""
|
||||
)
|
||||
stage_started = telemetry.start_timer()
|
||||
query_vectors = await self.plugin.invoke_embedding(
|
||||
embedding_model_uuid, [query]
|
||||
)
|
||||
query_vector = query_vectors[0]
|
||||
add_stage_duration(
|
||||
stage_durations,
|
||||
"query_embedding",
|
||||
telemetry.elapsed_ms(stage_started),
|
||||
)
|
||||
|
||||
stage_started = telemetry.start_timer()
|
||||
results = await self.plugin.vector_search(
|
||||
collection_id=collection_id,
|
||||
query_vector=query_vector,
|
||||
top_k=fetch_k,
|
||||
filters=context.filters or None,
|
||||
search_type=search_type,
|
||||
query_text=query,
|
||||
vector_weight=vector_weight,
|
||||
)
|
||||
add_stage_duration(
|
||||
stage_durations,
|
||||
"vector_search",
|
||||
telemetry.elapsed_ms(stage_started),
|
||||
)
|
||||
|
||||
# Post-process (strategy may deduplicate). If reranking is enabled,
|
||||
# preserve the over-fetched pool and let the reranker apply top_k.
|
||||
raw_count = len(results)
|
||||
postprocess_k = fetch_k if rerank_enabled else top_k
|
||||
stage_started = telemetry.start_timer()
|
||||
results = strategy.postprocess_results(results, postprocess_k)
|
||||
add_stage_duration(
|
||||
stage_durations,
|
||||
"postprocess",
|
||||
telemetry.elapsed_ms(stage_started),
|
||||
)
|
||||
logger.info(
|
||||
f"Retrieve post-process: {raw_count} raw → {len(results)} after dedup "
|
||||
f"(fetch_k={fetch_k}, postprocess_k={postprocess_k})"
|
||||
)
|
||||
|
||||
if rerank_mode in ("rerank_model", "model") and rerank_model:
|
||||
logger.info("[Rerank] Host rerank model enabled")
|
||||
stage_started = telemetry.start_timer()
|
||||
results = await model_rerank(
|
||||
plugin=self.plugin,
|
||||
rerank_model_uuid=rerank_model,
|
||||
query=query,
|
||||
results=results,
|
||||
top_k=top_k,
|
||||
)
|
||||
add_stage_duration(
|
||||
stage_durations,
|
||||
"rerank_model",
|
||||
telemetry.elapsed_ms(stage_started),
|
||||
)
|
||||
reranked = True
|
||||
elif rerank_mode == "llm" and rerank_llm:
|
||||
logger.info("[Rerank] LLM reranking enabled")
|
||||
stage_started = telemetry.start_timer()
|
||||
results = await llm_rerank(
|
||||
plugin=self.plugin,
|
||||
llm_uuid=rerank_llm,
|
||||
query=query,
|
||||
results=results,
|
||||
top_k=top_k,
|
||||
)
|
||||
add_stage_duration(
|
||||
stage_durations,
|
||||
"rerank_llm",
|
||||
telemetry.elapsed_ms(stage_started),
|
||||
)
|
||||
reranked = True
|
||||
|
||||
# C1: Heading hit weighting — boost results whose heading_path
|
||||
# contains query keywords. Skipped when LLM reranking is active
|
||||
# because the LLM already understands heading relevance.
|
||||
if not reranked:
|
||||
stage_started = telemetry.start_timer()
|
||||
query_keywords = [w for w in query.lower().split() if len(w) >= 2]
|
||||
if query_keywords:
|
||||
for res in results:
|
||||
heading_path = (
|
||||
res.get("metadata", {}).get("heading_path", "") or ""
|
||||
).lower()
|
||||
if not heading_path:
|
||||
continue
|
||||
distance = res.get("distance")
|
||||
if distance is not None and isinstance(distance, (int, float)):
|
||||
for kw in query_keywords:
|
||||
if kw in heading_path:
|
||||
distance *= 0.9
|
||||
res["distance"] = distance
|
||||
# Re-sort by distance (lower is better) after weighting
|
||||
results.sort(
|
||||
key=lambda r: (
|
||||
r.get("distance")
|
||||
if isinstance(r.get("distance"), (int, float))
|
||||
else float("inf")
|
||||
)
|
||||
)
|
||||
add_stage_duration(
|
||||
stage_durations,
|
||||
"heading_weight",
|
||||
telemetry.elapsed_ms(stage_started),
|
||||
)
|
||||
|
||||
# C2: Context window — attempt to fetch adjacent chunks from the same
|
||||
# document to provide surrounding context. Only works when the vector
|
||||
# store supports ``vector_get_by_ids`` (gracefully skipped otherwise).
|
||||
context_window = context.retrieval_settings.get("context_window", 0)
|
||||
if context_window and context_window > 0:
|
||||
try:
|
||||
stage_started = telemetry.start_timer()
|
||||
await self._expand_context(results, collection_id, context_window)
|
||||
add_stage_duration(
|
||||
stage_durations,
|
||||
"context_expansion",
|
||||
telemetry.elapsed_ms(stage_started),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Context window expansion skipped (vector_get_by_ids "
|
||||
f"not supported or failed): {e}"
|
||||
)
|
||||
|
||||
# Format results
|
||||
stage_started = telemetry.start_timer()
|
||||
distances = [
|
||||
float(res["distance"])
|
||||
for res in results
|
||||
if isinstance(res.get("distance"), (int, float))
|
||||
]
|
||||
if distances:
|
||||
distance_min = round(min(distances), 6)
|
||||
distance_avg = round(sum(distances) / len(distances), 6)
|
||||
distance_max = round(max(distances), 6)
|
||||
entries: list[RetrievalResultEntry] = []
|
||||
for res in results:
|
||||
meta = res.get("metadata", {})
|
||||
content_text = meta.get("text", "")
|
||||
raw_score = res.get("score")
|
||||
distance = res.get("distance")
|
||||
if distance is None and raw_score is not None:
|
||||
# Compatibility with older hosts that incorrectly returned
|
||||
# distance under the score field.
|
||||
distance = raw_score
|
||||
|
||||
doc_name = meta.get("document_name", "")
|
||||
page = meta.get("page")
|
||||
heading_path = meta.get("heading_path", "")
|
||||
|
||||
# Build structured reference string
|
||||
ref_parts = [doc_name]
|
||||
if page is not None:
|
||||
ref_parts.append(f"p.{page}")
|
||||
if heading_path:
|
||||
ref_parts.append(f'"{heading_path}"')
|
||||
reference = "[" + ", ".join(ref_parts) + "]" if ref_parts else ""
|
||||
|
||||
content_entry: dict = {
|
||||
"type": "text",
|
||||
"text": content_text,
|
||||
"file_name": doc_name,
|
||||
}
|
||||
if page is not None:
|
||||
content_entry["page"] = page
|
||||
if heading_path:
|
||||
content_entry["heading_path"] = heading_path
|
||||
if reference:
|
||||
content_entry["reference"] = reference
|
||||
|
||||
entries.append(
|
||||
RetrievalResultEntry(
|
||||
id=res["id"],
|
||||
content=[content_entry],
|
||||
metadata=meta,
|
||||
score=raw_score,
|
||||
distance=distance,
|
||||
)
|
||||
)
|
||||
|
||||
result_count = len(entries)
|
||||
reference_count = sum(
|
||||
1
|
||||
for entry in entries
|
||||
for item in entry.content
|
||||
if isinstance(item, dict) and item.get("reference")
|
||||
)
|
||||
heading_count = sum(
|
||||
1
|
||||
for entry in entries
|
||||
for item in entry.content
|
||||
if isinstance(item, dict) and item.get("heading_path")
|
||||
)
|
||||
context_expanded_count = sum(
|
||||
1
|
||||
for entry in entries
|
||||
if entry.metadata.get("context_before")
|
||||
or entry.metadata.get("context_after")
|
||||
)
|
||||
add_stage_duration(
|
||||
stage_durations,
|
||||
"format_response",
|
||||
telemetry.elapsed_ms(stage_started),
|
||||
)
|
||||
telemetry_status = "completed"
|
||||
trace_spans = _trace_spans(trace_id, stage_durations)
|
||||
return RetrievalResponse(
|
||||
results=entries,
|
||||
total_found=len(entries),
|
||||
metadata={
|
||||
"trace_id": trace_id,
|
||||
"raw_count": raw_count,
|
||||
"stage_durations_ms": stage_durations,
|
||||
"trace_spans": trace_spans,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
telemetry_error = e
|
||||
telemetry_status = "failed"
|
||||
raise
|
||||
finally:
|
||||
await self._record_diagnostic(
|
||||
"record_retrieval",
|
||||
telemetry.record_retrieval(
|
||||
query=query,
|
||||
collection_id=collection_id,
|
||||
status=telemetry_status,
|
||||
duration_ms=telemetry.elapsed_ms(started_at),
|
||||
index_type=context.creation_settings.get("index_type") or "chunk",
|
||||
search_type=search_type,
|
||||
top_k=top_k,
|
||||
fetch_k=fetch_k,
|
||||
raw_count=raw_count,
|
||||
result_count=result_count,
|
||||
reference_count=reference_count,
|
||||
heading_count=heading_count,
|
||||
context_expanded_count=context_expanded_count,
|
||||
distance_min=distance_min,
|
||||
distance_avg=distance_avg,
|
||||
distance_max=distance_max,
|
||||
filters=context.filters,
|
||||
creation_settings=context.creation_settings,
|
||||
retrieval_settings=context.retrieval_settings,
|
||||
stage_durations_ms=stage_durations,
|
||||
trace_spans=_trace_spans(trace_id, stage_durations),
|
||||
reranked=reranked,
|
||||
knowledge_base_id=context.knowledge_base_id,
|
||||
trace_id=trace_id,
|
||||
correlation=correlation,
|
||||
error=telemetry_error,
|
||||
),
|
||||
)
|
||||
|
||||
@serialized
|
||||
async def delete_document(self, kb_id: str, document_id: str) -> bool:
|
||||
"""Delete a document's vectors by file_id."""
|
||||
telemetry = self.plugin.telemetry
|
||||
started_at = telemetry.start_timer()
|
||||
trace_id = telemetry.new_trace_id("delete")
|
||||
status = "failed"
|
||||
error = None
|
||||
deleted = None
|
||||
count = None
|
||||
try:
|
||||
result_count = await self._state.mutate(
|
||||
self.plugin,
|
||||
kb_id,
|
||||
lambda: self.plugin.vector_delete(
|
||||
collection_id=kb_id,
|
||||
file_ids=[document_id],
|
||||
),
|
||||
'Host vector_delete outcome unknown',
|
||||
)
|
||||
# The Host answered, but a malformed count does not say what happened
|
||||
# to the rows: the vector store's state is unknown, so this knowledge
|
||||
# base has to be reconciled before it accepts another mutation.
|
||||
if type(result_count) is not int or result_count < 0:
|
||||
await self._state.fence(
|
||||
self.plugin,
|
||||
kb_id,
|
||||
'Host vector_delete returned a malformed count',
|
||||
)
|
||||
raise ValueError("Host vector_delete must return a nonnegative integer count")
|
||||
count = result_count
|
||||
# An authoritative no-op also confirms absence (empty ingest or retry).
|
||||
deleted = True
|
||||
status = "completed"
|
||||
return deleted
|
||||
except Exception as e:
|
||||
error = e
|
||||
raise
|
||||
finally:
|
||||
await self._record_diagnostic(
|
||||
"record_delete",
|
||||
telemetry.record_delete(
|
||||
collection_id=kb_id,
|
||||
document_id=document_id,
|
||||
status=status,
|
||||
duration_ms=telemetry.elapsed_ms(started_at),
|
||||
deleted=deleted,
|
||||
vectors_deleted=count,
|
||||
knowledge_base_id=kb_id,
|
||||
trace_id=trace_id,
|
||||
error=error,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,252 @@
|
||||
apiVersion: v1
|
||||
kind: KnowledgeEngine
|
||||
metadata:
|
||||
name: langrag
|
||||
label:
|
||||
en_US: LangRAG
|
||||
zh_Hans: LangRAG
|
||||
description:
|
||||
en_US: Simple Knowledge Engine with document chunking, vector, full-text, and hybrid retrieval
|
||||
zh_Hans: 简单 RAG 引擎,支持文档分块及向量、全文、混合检索
|
||||
spec:
|
||||
creation_schema:
|
||||
- name: embedding_model_uuid
|
||||
label:
|
||||
en_US: Embedding Model
|
||||
zh_Hans: 嵌入模型
|
||||
description:
|
||||
en_US: Select embedding model for text vectorization
|
||||
zh_Hans: 选择用于文本向量化的嵌入模型
|
||||
type: embedding-model-selector
|
||||
required: true
|
||||
default: ''
|
||||
- name: index_type
|
||||
label:
|
||||
en_US: Index Type
|
||||
zh_Hans: 索引类型
|
||||
description:
|
||||
en_US: 'Chunk: flat chunking; Parent-Child: two-level chunking for richer context; QA: LLM-generated question-answer pairs for better query matching'
|
||||
zh_Hans: 'Chunk:平坦分块;Parent-Child:双层分块,检索返回更丰富上下文;QA:LLM 生成问答对,提升查询匹配质量'
|
||||
type: select
|
||||
required: false
|
||||
default: chunk
|
||||
options:
|
||||
- name: chunk
|
||||
label:
|
||||
en_US: Chunk
|
||||
zh_Hans: 分块
|
||||
- name: parent_child
|
||||
label:
|
||||
en_US: Parent-Child
|
||||
zh_Hans: 父子索引
|
||||
- name: qa
|
||||
label:
|
||||
en_US: QA Index
|
||||
zh_Hans: 问答索引
|
||||
- name: chunk_size
|
||||
label:
|
||||
en_US: Chunk Size
|
||||
zh_Hans: 分块大小
|
||||
type: integer
|
||||
required: false
|
||||
default: 512
|
||||
- name: overlap
|
||||
label:
|
||||
en_US: Chunk Overlap
|
||||
zh_Hans: 分块重叠
|
||||
type: integer
|
||||
required: false
|
||||
default: 50
|
||||
- name: parent_chunk_size
|
||||
label:
|
||||
en_US: Parent Chunk Size
|
||||
zh_Hans: 父块大小
|
||||
description:
|
||||
en_US: Size of parent chunks (only used with Parent-Child index type)
|
||||
zh_Hans: 父块大小(仅在 Parent-Child 索引类型下生效)
|
||||
type: integer
|
||||
required: false
|
||||
default: 2048
|
||||
show_if:
|
||||
field: index_type
|
||||
operator: eq
|
||||
value: parent_child
|
||||
- name: child_chunk_size
|
||||
label:
|
||||
en_US: Child Chunk Size
|
||||
zh_Hans: 子块大小
|
||||
description:
|
||||
en_US: Size of child chunks (only used with Parent-Child index type)
|
||||
zh_Hans: 子块大小(仅在 Parent-Child 索引类型下生效)
|
||||
type: integer
|
||||
required: false
|
||||
default: 256
|
||||
show_if:
|
||||
field: index_type
|
||||
operator: eq
|
||||
value: parent_child
|
||||
- name: qa_llm_model_uuid
|
||||
label:
|
||||
en_US: QA Generation LLM
|
||||
zh_Hans: QA 生成用 LLM
|
||||
description:
|
||||
en_US: LLM model for generating Q&A pairs (required when index type is QA)
|
||||
zh_Hans: 用于生成问答对的 LLM 模型(索引类型为 QA 时需要)
|
||||
type: llm-model-selector
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: index_type
|
||||
operator: eq
|
||||
value: qa
|
||||
- name: questions_per_chunk
|
||||
label:
|
||||
en_US: Questions Per Chunk
|
||||
zh_Hans: 每块生成问题数
|
||||
description:
|
||||
en_US: Number of Q&A pairs to generate per chunk (only used with QA index type)
|
||||
zh_Hans: 每个文本块生成的问答对数量(仅在 QA 索引类型下生效)
|
||||
type: integer
|
||||
required: false
|
||||
default: 1
|
||||
show_if:
|
||||
field: index_type
|
||||
operator: eq
|
||||
value: qa
|
||||
retrieval_schema:
|
||||
- name: top_k
|
||||
label:
|
||||
en_US: Top K
|
||||
zh_Hans: 召回数量
|
||||
type: integer
|
||||
required: false
|
||||
default: 5
|
||||
- name: search_type
|
||||
label:
|
||||
en_US: Search Type
|
||||
zh_Hans: 检索类型
|
||||
description:
|
||||
en_US: 'Vector: semantic similarity; Full-text: keyword matching; Hybrid: combined'
|
||||
zh_Hans: 'Vector:语义相似度;Full-text:关键词匹配;Hybrid:混合检索'
|
||||
type: select
|
||||
required: false
|
||||
default: vector
|
||||
options:
|
||||
- name: vector
|
||||
label:
|
||||
en_US: Vector
|
||||
zh_Hans: 向量检索
|
||||
- name: full_text
|
||||
label:
|
||||
en_US: Full-text
|
||||
zh_Hans: 全文检索
|
||||
- name: hybrid
|
||||
label:
|
||||
en_US: Hybrid
|
||||
zh_Hans: 混合检索
|
||||
- name: vector_weight
|
||||
label:
|
||||
en_US: Vector Search Weight
|
||||
zh_Hans: 向量搜索权重
|
||||
description:
|
||||
en_US: 'Balance between vector and keyword search in hybrid mode (0.0 = keyword only, 1.0 = vector only)'
|
||||
zh_Hans: '混合检索中向量搜索的权重(0.0 = 仅关键词,1.0 = 仅向量)'
|
||||
type: float
|
||||
required: false
|
||||
default: 0.7
|
||||
show_if:
|
||||
field: search_type
|
||||
operator: eq
|
||||
value: hybrid
|
||||
- name: query_rewrite
|
||||
label:
|
||||
en_US: Query Rewriting
|
||||
zh_Hans: 查询重写
|
||||
description:
|
||||
en_US: 'Off: direct search; HyDE: hypothetical document; Multi-Query: query expansion; Step-Back: abstract generalization'
|
||||
zh_Hans: 'Off:直接检索;HyDE:假设文档嵌入;Multi-Query:多查询扩展;Step-Back:回退抽象提问'
|
||||
type: select
|
||||
required: false
|
||||
default: "off"
|
||||
options:
|
||||
- name: "off"
|
||||
label: { en_US: "Off", zh_Hans: "关闭" }
|
||||
- name: hyde
|
||||
label: { en_US: "HyDE", zh_Hans: "HyDE(假设文档)" }
|
||||
- name: multi_query
|
||||
label: { en_US: "Multi-Query", zh_Hans: "多查询扩展" }
|
||||
- name: step_back
|
||||
label: { en_US: "Step-Back", zh_Hans: "回退提问" }
|
||||
- name: rewrite_llm_model_uuid
|
||||
label:
|
||||
en_US: Query Rewrite LLM
|
||||
zh_Hans: 查询重写 LLM
|
||||
description:
|
||||
en_US: LLM model for query rewriting (required when query rewriting is enabled)
|
||||
zh_Hans: 用于查询重写的 LLM 模型(启用查询重写时需要)
|
||||
type: llm-model-selector
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: query_rewrite
|
||||
operator: neq
|
||||
value: "off"
|
||||
- name: rerank
|
||||
label:
|
||||
en_US: Reranking
|
||||
zh_Hans: 重排序
|
||||
description:
|
||||
en_US: 'Off: no reranking; Rerank Model: use a LangBot rerank model; LLM: use a language model to rerank candidates by relevance'
|
||||
zh_Hans: 'Off:不重排序;Rerank Model:使用 LangBot 重排序模型;LLM:使用大语言模型按相关性重排候选结果'
|
||||
type: select
|
||||
required: false
|
||||
default: "off"
|
||||
options:
|
||||
- name: "off"
|
||||
label: { en_US: "Off", zh_Hans: "关闭" }
|
||||
- name: "rerank_model"
|
||||
label: { en_US: "Rerank Model", zh_Hans: "重排序模型" }
|
||||
- name: "llm"
|
||||
label: { en_US: "LLM", zh_Hans: "LLM 重排序" }
|
||||
- name: rerank_model_uuid
|
||||
label:
|
||||
en_US: Rerank Model
|
||||
zh_Hans: 重排序模型
|
||||
description:
|
||||
en_US: LangBot rerank model for reranking retrieved candidates
|
||||
zh_Hans: 用于对检索候选结果重排序的 LangBot 重排序模型
|
||||
type: rerank-model-selector
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: rerank
|
||||
operator: eq
|
||||
value: "rerank_model"
|
||||
- name: rerank_llm_model_uuid
|
||||
label:
|
||||
en_US: Rerank LLM
|
||||
zh_Hans: 重排序 LLM
|
||||
description:
|
||||
en_US: LLM model for reranking retrieved candidates
|
||||
zh_Hans: 用于对检索候选结果重排序的 LLM 模型
|
||||
type: llm-model-selector
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: rerank
|
||||
operator: eq
|
||||
value: "llm"
|
||||
- name: context_window
|
||||
label:
|
||||
en_US: Context Window
|
||||
zh_Hans: 上下文窗口
|
||||
description:
|
||||
en_US: 'Number of adjacent chunks to include as context for each hit (0 = disabled). Requires vector store ID-based lookup support.'
|
||||
zh_Hans: '为每个命中 chunk 附加的相邻 chunk 数量(0 = 禁用)。需要向量库支持按 ID 查询。'
|
||||
type: integer
|
||||
required: false
|
||||
default: 0
|
||||
execution:
|
||||
python:
|
||||
path: langrag.py
|
||||
attr: LangRAG
|
||||
@@ -0,0 +1,230 @@
|
||||
"""DEPRECATED: Internal file parser — fallback only.
|
||||
|
||||
This parser provides basic text extraction for cases where no external Parser
|
||||
plugin (e.g. GeneralParsers) is configured. It lacks section splitting,
|
||||
metadata extraction, table-aware chunking, and many other features available
|
||||
in GeneralParsers.
|
||||
|
||||
New features should NOT be added here. Instead, invest in GeneralParsers
|
||||
or a dedicated parser plugin. This module will be removed in a future
|
||||
version once external parser adoption is widespread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from components.offload import BoundedOffload
|
||||
import logging
|
||||
from typing import Union, Callable, Any
|
||||
|
||||
import chardet
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileParser:
|
||||
"""
|
||||
A robust file parser class to extract text content from various document formats.
|
||||
It supports TXT, PDF, DOCX, Markdown, HTML files.
|
||||
|
||||
This version is designed to work in the Plugin environment, accepting file bytes
|
||||
directly rather than relying on Host's storage manager.
|
||||
"""
|
||||
|
||||
def __init__(self, offload=None):
|
||||
self.offload = offload or BoundedOffload()
|
||||
|
||||
async def _run_sync(self, sync_func: Callable, *args: Any, **kwargs: Any) -> Any:
|
||||
"""
|
||||
Runs a synchronous function in a separate thread to prevent blocking the event loop.
|
||||
"""
|
||||
try:
|
||||
return await self.offload.run(sync_func, *args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f'Error running synchronous function {sync_func.__name__}: {e}')
|
||||
raise
|
||||
|
||||
async def parse(self, file_bytes: bytes, filename: str) -> Union[str, None]:
|
||||
"""
|
||||
Parses the file based on its extension and returns the extracted text content.
|
||||
|
||||
Args:
|
||||
file_bytes: The raw bytes of the file content.
|
||||
filename: The name of the file (used to determine extension).
|
||||
|
||||
Returns:
|
||||
Union[str, None]: The extracted text content as a single string, or None if parsing fails.
|
||||
"""
|
||||
if len(file_bytes) > 16 * 1024 * 1024:
|
||||
raise ValueError("File exceeds the 16 MiB internal parser limit")
|
||||
# Extract extension from filename
|
||||
if '.' in filename:
|
||||
file_extension = filename.rsplit('.', 1)[-1].lower()
|
||||
else:
|
||||
file_extension = ''
|
||||
|
||||
parser_method = getattr(self, f'_parse_{file_extension}', None)
|
||||
|
||||
if parser_method is None:
|
||||
logger.warning(f'Unsupported file format: {file_extension} for file {filename}, trying as text')
|
||||
# Fallback: try to decode as text
|
||||
return await self._run_sync(self._decode_text, file_bytes)
|
||||
|
||||
try:
|
||||
return await parser_method(file_bytes, filename)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to parse {file_extension} file {filename}: {e}')
|
||||
return None
|
||||
|
||||
def _decode_text(self, file_bytes: bytes) -> str:
|
||||
"""Decode bytes to text with encoding detection."""
|
||||
detected = chardet.detect(file_bytes)
|
||||
encoding = detected['encoding'] or 'utf-8'
|
||||
return file_bytes.decode(encoding, errors='ignore')
|
||||
|
||||
# --- Specific Parser Methods ---
|
||||
|
||||
async def _parse_txt(self, file_bytes: bytes, filename: str) -> str:
|
||||
"""Parses a TXT file and returns its content."""
|
||||
logger.info(f'Parsing TXT file: {filename}')
|
||||
return await self._run_sync(self._decode_text, file_bytes)
|
||||
|
||||
async def _parse_pdf(self, file_bytes: bytes, filename: str) -> str:
|
||||
"""Parses a PDF file and returns its text content."""
|
||||
logger.info(f'Parsing PDF file: {filename}')
|
||||
|
||||
def _parse_pdf_sync():
|
||||
import PyPDF2
|
||||
|
||||
pdf_reader = PyPDF2.PdfReader(io.BytesIO(file_bytes))
|
||||
text_content = []
|
||||
for page in pdf_reader.pages:
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
text_content.append(text)
|
||||
return '\n'.join(text_content)
|
||||
|
||||
return await self._run_sync(_parse_pdf_sync)
|
||||
|
||||
async def _parse_docx(self, file_bytes: bytes, filename: str) -> str:
|
||||
"""Parses a DOCX file and returns its text content."""
|
||||
logger.info(f'Parsing DOCX file: {filename}')
|
||||
|
||||
def _parse_docx_sync():
|
||||
from docx import Document
|
||||
|
||||
doc = Document(io.BytesIO(file_bytes))
|
||||
text_content = [paragraph.text for paragraph in doc.paragraphs if paragraph.text.strip()]
|
||||
return '\n'.join(text_content)
|
||||
|
||||
return await self._run_sync(_parse_docx_sync)
|
||||
|
||||
async def _parse_doc(self, file_bytes: bytes, filename: str) -> str:
|
||||
"""Handles .doc files, explicitly stating lack of direct support."""
|
||||
logger.warning(f'Direct .doc parsing is not supported for {filename}. Please convert to .docx first.')
|
||||
raise NotImplementedError('Direct .doc parsing not supported. Please convert to .docx first.')
|
||||
|
||||
async def _parse_md(self, file_bytes: bytes, filename: str) -> str:
|
||||
"""Parses a Markdown file, converting it to structured plain text."""
|
||||
logger.info(f'Parsing Markdown file: {filename}')
|
||||
|
||||
def _parse_markdown_sync():
|
||||
import markdown
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
md_content = file_bytes.decode('utf-8', errors='ignore')
|
||||
html_content = markdown.markdown(
|
||||
md_content, extensions=['extra', 'codehilite', 'tables', 'toc', 'fenced_code']
|
||||
)
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
text_parts = []
|
||||
for element in soup.children:
|
||||
if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']:
|
||||
level = int(element.name[1])
|
||||
text_parts.append('#' * level + ' ' + element.get_text().strip())
|
||||
elif element.name == 'p':
|
||||
text = element.get_text().strip()
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
elif element.name in ['ul', 'ol']:
|
||||
for li in element.find_all('li'):
|
||||
text_parts.append(f'* {li.get_text().strip()}')
|
||||
elif element.name == 'pre':
|
||||
code_block = element.get_text().strip()
|
||||
if code_block:
|
||||
text_parts.append(f'```\n{code_block}\n```')
|
||||
elif element.name == 'table':
|
||||
table_str = self._extract_table_to_markdown_sync(element)
|
||||
if table_str:
|
||||
text_parts.append(table_str)
|
||||
elif element.name:
|
||||
text = element.get_text(separator=' ', strip=True)
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
cleaned_text = re.sub(r'\n\s*\n', '\n\n', '\n'.join(text_parts))
|
||||
return cleaned_text.strip()
|
||||
|
||||
return await self._run_sync(_parse_markdown_sync)
|
||||
|
||||
async def _parse_html(self, file_bytes: bytes, filename: str) -> str:
|
||||
"""Parses an HTML file, extracting structured plain text."""
|
||||
logger.info(f'Parsing HTML file: {filename}')
|
||||
|
||||
def _parse_html_sync():
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
html_content = file_bytes.decode('utf-8', errors='ignore')
|
||||
soup = BeautifulSoup(html_content, 'html.parser')
|
||||
for script_or_style in soup(['script', 'style']):
|
||||
script_or_style.decompose()
|
||||
text_parts = []
|
||||
for element in soup.body.children if soup.body else soup.children:
|
||||
if element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']:
|
||||
level = int(element.name[1])
|
||||
text_parts.append('#' * level + ' ' + element.get_text().strip())
|
||||
elif element.name == 'p':
|
||||
text = element.get_text().strip()
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
elif element.name in ['ul', 'ol']:
|
||||
for li in element.find_all('li'):
|
||||
text = li.get_text().strip()
|
||||
if text:
|
||||
text_parts.append(f'* {text}')
|
||||
elif element.name == 'table':
|
||||
table_str = self._extract_table_to_markdown_sync(element)
|
||||
if table_str:
|
||||
text_parts.append(table_str)
|
||||
elif element.name:
|
||||
text = element.get_text(separator=' ', strip=True)
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
cleaned_text = re.sub(r'\n\s*\n', '\n\n', '\n'.join(text_parts))
|
||||
return cleaned_text.strip()
|
||||
|
||||
return await self._run_sync(_parse_html_sync)
|
||||
|
||||
def _extract_table_to_markdown_sync(self, table_element) -> str:
|
||||
"""Helper to convert a BeautifulSoup table element into a Markdown table string."""
|
||||
headers = [th.get_text().strip() for th in table_element.find_all('th')]
|
||||
rows = []
|
||||
for tr in table_element.find_all('tr'):
|
||||
cells = [td.get_text().strip() for td in tr.find_all('td')]
|
||||
if cells:
|
||||
rows.append(cells)
|
||||
|
||||
if not headers and not rows:
|
||||
return ''
|
||||
|
||||
table_lines = []
|
||||
if headers:
|
||||
table_lines.append(' | '.join(headers))
|
||||
table_lines.append(' | '.join(['---'] * len(headers)))
|
||||
|
||||
for row_cells in rows:
|
||||
padded_cells = row_cells + [''] * (len(headers) - len(row_cells)) if headers else row_cells
|
||||
table_lines.append(' | '.join(padded_cells))
|
||||
|
||||
return '\n'.join(table_lines)
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Query rewriting strategies for retrieval augmentation.
|
||||
|
||||
Provides HyDE, Multi-Query, and Step-Back rewriting to improve recall quality.
|
||||
Each strategy rewrites/expands the user query before embedding and searching.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
from components.observability.telemetry import add_stage_duration, hash_text, telemetry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MULTI_QUERY_COUNT = 3
|
||||
|
||||
HYDE_PROMPT = """Please write a short passage that would answer the following question.
|
||||
Write as if you are writing a paragraph from a reference document.
|
||||
Do not say "I don't know" — just write a plausible answer.
|
||||
|
||||
Question: {query}
|
||||
|
||||
Passage:"""
|
||||
|
||||
MULTI_QUERY_PROMPT = """Given the following user question, generate {n} different search queries
|
||||
that could help find relevant information. Each query should approach the question from a
|
||||
different angle or use different terminology.
|
||||
|
||||
User question: {query}
|
||||
|
||||
Output exactly {n} queries, one per line (no numbering, no bullets):"""
|
||||
|
||||
STEP_BACK_PROMPT = """Given the following specific question, generate a more general/abstract
|
||||
question that would help retrieve broader background context.
|
||||
|
||||
Specific question: {query}
|
||||
|
||||
General question:"""
|
||||
|
||||
|
||||
def _extract_text(msg: Message) -> str:
|
||||
"""Extract plain text from an LLM response Message."""
|
||||
if isinstance(msg.content, str):
|
||||
return msg.content.strip()
|
||||
if isinstance(msg.content, list):
|
||||
return "".join(e.text for e in msg.content if e.type == "text").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _vector_result_sort_key(result: dict) -> tuple[int, float]:
|
||||
"""Prefer lower distance; fall back to legacy score-as-distance."""
|
||||
distance = result.get("distance")
|
||||
if isinstance(distance, (int, float)):
|
||||
return (0, float(distance))
|
||||
|
||||
score = result.get("score")
|
||||
if isinstance(score, (int, float)):
|
||||
return (1, float(score))
|
||||
|
||||
return (2, float("inf"))
|
||||
|
||||
|
||||
def _uses_vector(search_type) -> bool:
|
||||
"""Return whether this search mode needs an embedding vector."""
|
||||
return getattr(search_type, "value", search_type) != "full_text"
|
||||
|
||||
|
||||
def _text_log_ref(text: str | None) -> str:
|
||||
"""Return a privacy-preserving text reference for logs."""
|
||||
return f"hash={hash_text(text)}, length={len(text or '')}"
|
||||
|
||||
|
||||
async def _timed_call(stage_durations: dict[str, float] | None, stage: str, call):
|
||||
started_at = telemetry.start_timer()
|
||||
try:
|
||||
return await call()
|
||||
finally:
|
||||
if stage_durations is not None:
|
||||
add_stage_duration(stage_durations, stage, telemetry.elapsed_ms(started_at))
|
||||
|
||||
|
||||
async def retrieve_with_rewrite(
|
||||
plugin,
|
||||
query: str,
|
||||
query_rewrite: str,
|
||||
rewrite_llm: str,
|
||||
collection_id: str,
|
||||
embedding_model_uuid: str,
|
||||
fetch_k: int,
|
||||
filters,
|
||||
search_type,
|
||||
vector_weight=None,
|
||||
stage_durations: dict[str, float] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Route to the appropriate rewrite strategy and return raw search results."""
|
||||
if query_rewrite == "hyde":
|
||||
return await _retrieve_hyde(
|
||||
plugin,
|
||||
query,
|
||||
rewrite_llm,
|
||||
collection_id,
|
||||
embedding_model_uuid,
|
||||
fetch_k,
|
||||
filters,
|
||||
search_type,
|
||||
vector_weight,
|
||||
stage_durations,
|
||||
)
|
||||
elif query_rewrite == "multi_query":
|
||||
return await _retrieve_multi_query(
|
||||
plugin,
|
||||
query,
|
||||
rewrite_llm,
|
||||
collection_id,
|
||||
embedding_model_uuid,
|
||||
fetch_k,
|
||||
filters,
|
||||
search_type,
|
||||
vector_weight,
|
||||
stage_durations,
|
||||
)
|
||||
elif query_rewrite == "step_back":
|
||||
return await _retrieve_step_back(
|
||||
plugin,
|
||||
query,
|
||||
rewrite_llm,
|
||||
collection_id,
|
||||
embedding_model_uuid,
|
||||
fetch_k,
|
||||
filters,
|
||||
search_type,
|
||||
vector_weight,
|
||||
stage_durations,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unknown query_rewrite strategy: {query_rewrite}, falling back to direct search"
|
||||
)
|
||||
query_vector: list[float] = []
|
||||
if _uses_vector(search_type):
|
||||
query_vectors = await _timed_call(
|
||||
stage_durations,
|
||||
"rewrite_embedding",
|
||||
lambda: plugin.invoke_embedding(embedding_model_uuid, [query]),
|
||||
)
|
||||
query_vector = query_vectors[0]
|
||||
return await _timed_call(
|
||||
stage_durations,
|
||||
"rewrite_vector_search",
|
||||
lambda: plugin.vector_search(
|
||||
collection_id=collection_id,
|
||||
query_vector=query_vector,
|
||||
top_k=fetch_k,
|
||||
filters=filters,
|
||||
search_type=search_type,
|
||||
query_text=query,
|
||||
vector_weight=vector_weight,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _retrieve_hyde(
|
||||
plugin,
|
||||
query,
|
||||
rewrite_llm,
|
||||
collection_id,
|
||||
embedding_model_uuid,
|
||||
fetch_k,
|
||||
filters,
|
||||
search_type,
|
||||
vector_weight=None,
|
||||
stage_durations: dict[str, float] | None = None,
|
||||
) -> list[dict]:
|
||||
"""HyDE: generate a hypothetical document, embed it, and search with that vector."""
|
||||
logger.info(
|
||||
f"[HyDE] Generating hypothetical document "
|
||||
f"for query_ref=({_text_log_ref(query)})"
|
||||
)
|
||||
prompt = HYDE_PROMPT.format(query=query)
|
||||
resp = await _timed_call(
|
||||
stage_durations,
|
||||
"rewrite_llm",
|
||||
lambda: plugin.invoke_llm(rewrite_llm, [Message(role="user", content=prompt)]),
|
||||
)
|
||||
hypothetical_doc = _extract_text(resp) or query
|
||||
logger.info(f"[HyDE] Hypothetical document ref: {_text_log_ref(hypothetical_doc)}")
|
||||
|
||||
query_vector: list[float] = []
|
||||
if _uses_vector(search_type):
|
||||
logger.info("[HyDE] Embedding hypothetical document and searching...")
|
||||
hyde_vectors = await _timed_call(
|
||||
stage_durations,
|
||||
"rewrite_embedding",
|
||||
lambda: plugin.invoke_embedding(embedding_model_uuid, [hypothetical_doc]),
|
||||
)
|
||||
query_vector = hyde_vectors[0]
|
||||
else:
|
||||
logger.info("[HyDE] Searching with hypothetical document text...")
|
||||
results = await _timed_call(
|
||||
stage_durations,
|
||||
"rewrite_vector_search",
|
||||
lambda: plugin.vector_search(
|
||||
collection_id=collection_id,
|
||||
query_vector=query_vector,
|
||||
top_k=fetch_k,
|
||||
filters=filters,
|
||||
search_type=search_type,
|
||||
query_text=hypothetical_doc,
|
||||
vector_weight=vector_weight,
|
||||
),
|
||||
)
|
||||
logger.info(f"[HyDE] Search returned {len(results)} results")
|
||||
return results
|
||||
|
||||
|
||||
async def _retrieve_multi_query(
|
||||
plugin,
|
||||
query,
|
||||
rewrite_llm,
|
||||
collection_id,
|
||||
embedding_model_uuid,
|
||||
fetch_k,
|
||||
filters,
|
||||
search_type,
|
||||
vector_weight=None,
|
||||
stage_durations: dict[str, float] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Multi-Query: generate N query variants, search with each, merge and deduplicate."""
|
||||
logger.info(
|
||||
f"[Multi-Query] Generating {MULTI_QUERY_COUNT} sub-queries "
|
||||
f"for query_ref=({_text_log_ref(query)})"
|
||||
)
|
||||
prompt = MULTI_QUERY_PROMPT.format(query=query, n=MULTI_QUERY_COUNT)
|
||||
resp = await _timed_call(
|
||||
stage_durations,
|
||||
"rewrite_llm",
|
||||
lambda: plugin.invoke_llm(rewrite_llm, [Message(role="user", content=prompt)]),
|
||||
)
|
||||
raw_text = _extract_text(resp)
|
||||
sub_queries = [line.strip() for line in raw_text.splitlines() if line.strip()]
|
||||
sub_queries = sub_queries[:MULTI_QUERY_COUNT]
|
||||
logger.info(
|
||||
"[Multi-Query] Generated sub-query refs: "
|
||||
+ ", ".join(_text_log_ref(sq) for sq in sub_queries)
|
||||
)
|
||||
|
||||
# Search original query + sub-queries
|
||||
all_queries = [query] + sub_queries
|
||||
logger.info(
|
||||
f"[Multi-Query] Searching {len(all_queries)} queries "
|
||||
f"(1 original + {len(sub_queries)} generated)"
|
||||
)
|
||||
if _uses_vector(search_type):
|
||||
all_vectors = await _timed_call(
|
||||
stage_durations,
|
||||
"rewrite_embedding",
|
||||
lambda: plugin.invoke_embedding(embedding_model_uuid, all_queries),
|
||||
)
|
||||
else:
|
||||
all_vectors = [[] for _ in all_queries]
|
||||
|
||||
# Search with each vector and merge results
|
||||
seen_ids: set[str] = set()
|
||||
merged: list[dict] = []
|
||||
for i, (search_query, vec) in enumerate(zip(all_queries, all_vectors)):
|
||||
results = await _timed_call(
|
||||
stage_durations,
|
||||
"rewrite_vector_search",
|
||||
lambda search_query=search_query, vec=vec: plugin.vector_search(
|
||||
collection_id=collection_id,
|
||||
query_vector=vec,
|
||||
top_k=fetch_k,
|
||||
filters=filters,
|
||||
search_type=search_type,
|
||||
query_text=search_query,
|
||||
vector_weight=vector_weight,
|
||||
),
|
||||
)
|
||||
new_count = sum(1 for r in results if r["id"] not in seen_ids)
|
||||
logger.info(
|
||||
f"[Multi-Query] Query [{i}] returned {len(results)} results ({new_count} new)"
|
||||
)
|
||||
for r in results:
|
||||
rid = r["id"]
|
||||
if rid not in seen_ids:
|
||||
seen_ids.add(rid)
|
||||
merged.append(r)
|
||||
|
||||
# Prefer lower distance. Older hosts incorrectly exposed distance in score,
|
||||
# so score is only used as a compatibility fallback here.
|
||||
merged.sort(key=_vector_result_sort_key)
|
||||
merged = merged[:fetch_k]
|
||||
logger.info(
|
||||
f"[Multi-Query] Merged: {len(seen_ids)} unique results, returning {len(merged)}"
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
async def _retrieve_step_back(
|
||||
plugin,
|
||||
query,
|
||||
rewrite_llm,
|
||||
collection_id,
|
||||
embedding_model_uuid,
|
||||
fetch_k,
|
||||
filters,
|
||||
search_type,
|
||||
vector_weight=None,
|
||||
stage_durations: dict[str, float] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Step-Back: generate a broader question, search with both original and abstract queries."""
|
||||
logger.info(
|
||||
f"[Step-Back] Generating abstract question "
|
||||
f"for query_ref=({_text_log_ref(query)})"
|
||||
)
|
||||
prompt = STEP_BACK_PROMPT.format(query=query)
|
||||
resp = await _timed_call(
|
||||
stage_durations,
|
||||
"rewrite_llm",
|
||||
lambda: plugin.invoke_llm(rewrite_llm, [Message(role="user", content=prompt)]),
|
||||
)
|
||||
abstract_query = _extract_text(resp) or query
|
||||
logger.info(f"[Step-Back] Abstract query ref: {_text_log_ref(abstract_query)}")
|
||||
|
||||
# Search with both original and abstract queries
|
||||
both_queries = [query, abstract_query]
|
||||
logger.info("[Step-Back] Searching original + abstract queries...")
|
||||
if _uses_vector(search_type):
|
||||
both_vectors = await _timed_call(
|
||||
stage_durations,
|
||||
"rewrite_embedding",
|
||||
lambda: plugin.invoke_embedding(
|
||||
embedding_model_uuid,
|
||||
both_queries,
|
||||
),
|
||||
)
|
||||
else:
|
||||
both_vectors = [[] for _ in both_queries]
|
||||
|
||||
# Search with both vectors and merge
|
||||
seen_ids: set[str] = set()
|
||||
merged: list[dict] = []
|
||||
for i, (search_query, vec) in enumerate(zip(both_queries, both_vectors)):
|
||||
label = "original" if i == 0 else "abstract"
|
||||
results = await _timed_call(
|
||||
stage_durations,
|
||||
"rewrite_vector_search",
|
||||
lambda search_query=search_query, vec=vec: plugin.vector_search(
|
||||
collection_id=collection_id,
|
||||
query_vector=vec,
|
||||
top_k=fetch_k,
|
||||
filters=filters,
|
||||
search_type=search_type,
|
||||
query_text=search_query,
|
||||
vector_weight=vector_weight,
|
||||
),
|
||||
)
|
||||
new_count = sum(1 for r in results if r["id"] not in seen_ids)
|
||||
logger.info(
|
||||
f"[Step-Back] {label} query returned {len(results)} results ({new_count} new)"
|
||||
)
|
||||
for r in results:
|
||||
rid = r["id"]
|
||||
if rid not in seen_ids:
|
||||
seen_ids.add(rid)
|
||||
merged.append(r)
|
||||
|
||||
merged.sort(key=_vector_result_sort_key)
|
||||
merged = merged[:fetch_k]
|
||||
logger.info(
|
||||
f"[Step-Back] Merged: {len(seen_ids)} unique results, returning {len(merged)}"
|
||||
)
|
||||
return merged
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Reranking helpers for retrieval results.
|
||||
|
||||
Supports Host-provided rerank models and the legacy LLM listwise reranker.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
from components.observability.telemetry import hash_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum characters per candidate passage sent to the LLM.
|
||||
_PASSAGE_TRUNCATE = 300
|
||||
|
||||
RERANK_PROMPT = """\
|
||||
Given a query and candidate passages, rank the passages by relevance to the query.
|
||||
Return ONLY the passage numbers in order from most relevant to least relevant.
|
||||
Format: comma-separated numbers, e.g. "3,1,0,2,4"
|
||||
|
||||
Query: {query}
|
||||
|
||||
Candidates:
|
||||
{candidates}
|
||||
|
||||
Ranking:"""
|
||||
|
||||
|
||||
def _extract_text(msg: Message) -> str:
|
||||
"""Extract plain text from an LLM response Message."""
|
||||
if isinstance(msg.content, str):
|
||||
return msg.content.strip()
|
||||
if isinstance(msg.content, list):
|
||||
return "".join(e.text for e in msg.content if e.type == "text").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_ranking(text: str, n: int) -> list[int] | None:
|
||||
"""Parse a comma/space separated list of integers from LLM output.
|
||||
|
||||
Returns a deduplicated list of valid indices (0..n-1), or *None* if
|
||||
parsing fails entirely (no valid indices found).
|
||||
"""
|
||||
nums = re.findall(r"\d+", text)
|
||||
if not nums:
|
||||
return None
|
||||
|
||||
seen: set[int] = set()
|
||||
indices: list[int] = []
|
||||
for s in nums:
|
||||
idx = int(s)
|
||||
if 0 <= idx < n and idx not in seen:
|
||||
seen.add(idx)
|
||||
indices.append(idx)
|
||||
return indices if indices else None
|
||||
|
||||
|
||||
def _candidate_text(res: dict) -> str:
|
||||
"""Extract the display text sent to rerankers."""
|
||||
return res.get("metadata", {}).get("text", "") or ""
|
||||
|
||||
|
||||
def _text_log_ref(text: str | None) -> str:
|
||||
"""Return a privacy-preserving text reference for logs."""
|
||||
return f"hash={hash_text(text)}, length={len(text or '')}"
|
||||
|
||||
|
||||
def _apply_ranking(
|
||||
results: list[dict],
|
||||
ranking: list[int],
|
||||
top_k: int,
|
||||
scores_by_index: dict[int, float] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Apply a candidate index ranking to result dicts."""
|
||||
if len(ranking) < len(results):
|
||||
remaining = [i for i in range(len(results)) if i not in set(ranking)]
|
||||
ranking.extend(remaining)
|
||||
|
||||
reranked = [results[i] for i in ranking[:top_k]]
|
||||
|
||||
# Rewrite distance so downstream sorting stays consistent with reranking.
|
||||
for rank, res in enumerate(reranked):
|
||||
res["distance"] = 0.01 * (rank + 1)
|
||||
if scores_by_index and ranking[rank] in scores_by_index:
|
||||
res["score"] = scores_by_index[ranking[rank]]
|
||||
|
||||
return reranked
|
||||
|
||||
|
||||
async def model_rerank(
|
||||
plugin,
|
||||
rerank_model_uuid: str,
|
||||
query: str,
|
||||
results: list[dict],
|
||||
top_k: int,
|
||||
) -> list[dict]:
|
||||
"""Rerank *results* using a Host rerank model.
|
||||
|
||||
The Host returns score entries with candidate indices. On failure or an
|
||||
unusable response, retrieval falls back to the original order.
|
||||
"""
|
||||
if not results:
|
||||
return results
|
||||
|
||||
n = len(results)
|
||||
logger.info(
|
||||
f"[Rerank] Model reranking {n} candidates "
|
||||
f"for query_ref=({_text_log_ref(query)})"
|
||||
)
|
||||
documents = [_candidate_text(res) for res in results]
|
||||
|
||||
try:
|
||||
scores = await plugin.invoke_rerank(
|
||||
rerank_model_uuid,
|
||||
query,
|
||||
documents,
|
||||
top_k=top_k,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[Rerank] Model rerank call failed, falling back to original order: {e}"
|
||||
)
|
||||
return results[:top_k]
|
||||
|
||||
ranking: list[int] = []
|
||||
scores_by_index: dict[int, float] = {}
|
||||
seen: set[int] = set()
|
||||
|
||||
sorted_scores = sorted(
|
||||
scores,
|
||||
key=lambda item: item.get("relevance_score", 0),
|
||||
reverse=True,
|
||||
)
|
||||
for item in sorted_scores:
|
||||
try:
|
||||
idx = int(item.get("index"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if 0 <= idx < n and idx not in seen:
|
||||
seen.add(idx)
|
||||
ranking.append(idx)
|
||||
score = item.get("relevance_score")
|
||||
if isinstance(score, (int, float)):
|
||||
scores_by_index[idx] = float(score)
|
||||
|
||||
if not ranking:
|
||||
logger.warning(
|
||||
"[Rerank] Model rerank returned no valid indices, "
|
||||
"falling back to original order"
|
||||
)
|
||||
return results[:top_k]
|
||||
|
||||
reranked = _apply_ranking(results, ranking, top_k, scores_by_index)
|
||||
logger.info(
|
||||
f"[Rerank] Done: {n} candidates -> top {len(reranked)} "
|
||||
f"(order: {ranking[:top_k]})"
|
||||
)
|
||||
return reranked
|
||||
|
||||
|
||||
async def llm_rerank(
|
||||
plugin,
|
||||
llm_uuid: str,
|
||||
query: str,
|
||||
results: list[dict],
|
||||
top_k: int,
|
||||
) -> list[dict]:
|
||||
"""Rerank *results* using an LLM and return the top *top_k* entries.
|
||||
|
||||
On any failure (LLM error, unparseable response) the function falls back
|
||||
to returning ``results[:top_k]`` so that retrieval is never blocked.
|
||||
"""
|
||||
if not results:
|
||||
return results
|
||||
|
||||
n = len(results)
|
||||
logger.info(
|
||||
f"[Rerank] LLM reranking {n} candidates "
|
||||
f"for query_ref=({_text_log_ref(query)})"
|
||||
)
|
||||
|
||||
# Build numbered candidate list
|
||||
lines: list[str] = []
|
||||
for i, res in enumerate(results):
|
||||
text = _candidate_text(res)[:_PASSAGE_TRUNCATE]
|
||||
lines.append(f"[{i}] {text}")
|
||||
candidates_block = "\n".join(lines)
|
||||
|
||||
prompt = RERANK_PROMPT.format(query=query, candidates=candidates_block)
|
||||
|
||||
try:
|
||||
resp = await plugin.invoke_llm(
|
||||
llm_uuid, [Message(role="user", content=prompt)]
|
||||
)
|
||||
raw = _extract_text(resp)
|
||||
logger.info(f"[Rerank] LLM response ref: {_text_log_ref(raw)}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Rerank] LLM call failed, falling back to original order: {e}")
|
||||
return results[:top_k]
|
||||
|
||||
ranking = _parse_ranking(raw, n)
|
||||
if ranking is None:
|
||||
logger.warning(
|
||||
"[Rerank] Failed to parse ranking from LLM response, "
|
||||
"falling back to original order"
|
||||
)
|
||||
return results[:top_k]
|
||||
|
||||
reranked = _apply_ranking(results, ranking, top_k)
|
||||
logger.info(
|
||||
f"[Rerank] Done: {n} candidates -> top {len(reranked)} "
|
||||
f"(order: {ranking[:top_k]})"
|
||||
)
|
||||
return reranked
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Index strategy registry and factory."""
|
||||
|
||||
import logging
|
||||
|
||||
from .base import IndexStrategy
|
||||
from .chunk import ChunkStrategy
|
||||
from .parent_child import ParentChildStrategy
|
||||
from .qa import QAStrategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STRATEGIES: dict[str, type[IndexStrategy]] = {
|
||||
"chunk": ChunkStrategy,
|
||||
"parent_child": ParentChildStrategy,
|
||||
"qa": QAStrategy,
|
||||
}
|
||||
|
||||
|
||||
def get_strategy(index_type: str) -> IndexStrategy:
|
||||
"""Return an IndexStrategy instance for the given *index_type*.
|
||||
|
||||
Falls back to :class:`ChunkStrategy` for unknown values.
|
||||
"""
|
||||
cls = _STRATEGIES.get(index_type)
|
||||
if cls is None:
|
||||
logger.warning(
|
||||
f"Unknown index_type '{index_type}', falling back to 'chunk' strategy"
|
||||
)
|
||||
cls = ChunkStrategy
|
||||
return cls()
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Abstract base class for index strategies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
|
||||
class IndexStrategy(ABC):
|
||||
"""Base class for RAG index strategies.
|
||||
|
||||
Each strategy defines how documents are chunked/indexed during ingestion
|
||||
and how search results are post-processed during retrieval.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def build_chunks_and_metadata(
|
||||
self,
|
||||
text: str,
|
||||
doc_id: str,
|
||||
filename: str,
|
||||
creation_settings: dict,
|
||||
plugin=None,
|
||||
*,
|
||||
sections: list | None = None,
|
||||
doc_metadata: dict | None = None,
|
||||
) -> AsyncGenerator[tuple[list[str], list[str], list[dict]], None]:
|
||||
"""Build chunks, IDs, and metadata for a parsed document.
|
||||
|
||||
Yields batches of ``(texts_to_embed, ids, metadatas)``. Most
|
||||
strategies yield a single batch; streaming strategies (e.g. QA)
|
||||
may yield incrementally to enable pipelined embedding.
|
||||
|
||||
|
||||
Args:
|
||||
text: Full parsed text of the document.
|
||||
doc_id: Unique document identifier.
|
||||
filename: Original filename.
|
||||
creation_settings: Knowledge base creation settings dict.
|
||||
plugin: Optional plugin reference for strategies that need
|
||||
Host API access (e.g. LLM calls during ingestion).
|
||||
sections: Optional list of TextSection objects from an external
|
||||
parser. When provided, strategies should use section-aware
|
||||
chunking instead of flat text splitting.
|
||||
doc_metadata: Optional document-level metadata from the parser
|
||||
(e.g. page_count, has_tables). Fields (except ``images``)
|
||||
are merged into each chunk's metadata with a ``doc_`` prefix.
|
||||
|
||||
Yields:
|
||||
A tuple of (texts_to_embed, ids, metadatas).
|
||||
- texts_to_embed: text chunks to be embedded.
|
||||
- ids: unique ID for each chunk.
|
||||
- metadatas: metadata dict for each chunk.
|
||||
"""
|
||||
yield # abstract – subclasses must override
|
||||
|
||||
@staticmethod
|
||||
def _build_doc_meta_fields(doc_metadata: dict | None) -> dict:
|
||||
"""Extract document-level metadata fields with ``doc_`` prefix.
|
||||
|
||||
Excludes heavy fields like ``images`` to keep chunk metadata lean.
|
||||
|
||||
TODO: Chroma 1.5.0+ supports array metadata types (list of str/int/float/bool)
|
||||
with $contains/$not_contains operators. LangBot currently uses Chroma 1.4.1
|
||||
which only accepts scalar types (str, int, float, bool, None). This function
|
||||
converts non-scalar values to JSON strings for compatibility. Once LangBot
|
||||
upgrades to Chroma 1.5.0+, this conversion layer can be removed to enable
|
||||
native array filtering. See: https://www.trychroma.com/changelog/metadata-arrays
|
||||
"""
|
||||
if not doc_metadata:
|
||||
return {}
|
||||
skip = {"images"}
|
||||
result = {}
|
||||
for k, v in doc_metadata.items():
|
||||
if k in skip:
|
||||
continue
|
||||
# Chroma 1.4.1 only accepts scalar types; convert list/dict to JSON string
|
||||
if isinstance(v, (str, int, float, bool)) or v is None:
|
||||
result[f"doc_{k}"] = v
|
||||
else:
|
||||
result[f"doc_{k}"] = json.dumps(v, ensure_ascii=False)
|
||||
return result
|
||||
|
||||
def postprocess_results(self, results: list[dict], top_k: int) -> list[dict]:
|
||||
"""Post-process search results before returning to the caller.
|
||||
|
||||
Default implementation is a no-op passthrough.
|
||||
|
||||
Args:
|
||||
results: Raw search results from the vector store.
|
||||
top_k: Desired number of results.
|
||||
|
||||
Returns:
|
||||
Processed results list.
|
||||
"""
|
||||
return results[:top_k]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Default flat chunking strategy — preserves the original LangRAG behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from .base import IndexStrategy
|
||||
from components.offload import BoundedOffload
|
||||
from ..chunker import chunk_text, chunk_sections, DEFAULT_CHUNK_SIZE, DEFAULT_CHUNK_OVERLAP
|
||||
|
||||
|
||||
class ChunkStrategy(IndexStrategy):
|
||||
"""Simple fixed-size chunking with overlap.
|
||||
|
||||
Each chunk is independently embedded and stored. This is the original
|
||||
behaviour of LangRAG prior to strategy modularisation.
|
||||
|
||||
When *sections* are provided (from an external parser), uses
|
||||
section-aware chunking that preserves heading/page metadata.
|
||||
"""
|
||||
|
||||
async def build_chunks_and_metadata(
|
||||
self,
|
||||
text: str,
|
||||
doc_id: str,
|
||||
filename: str,
|
||||
creation_settings: dict,
|
||||
plugin=None,
|
||||
*,
|
||||
sections: list | None = None,
|
||||
doc_metadata: dict | None = None,
|
||||
) -> AsyncGenerator[tuple[list[str], list[str], list[dict]], None]:
|
||||
chunk_size = creation_settings.get("chunk_size") or DEFAULT_CHUNK_SIZE
|
||||
overlap = creation_settings.get("overlap") or DEFAULT_CHUNK_OVERLAP
|
||||
doc_fields = self._build_doc_meta_fields(doc_metadata)
|
||||
offload = getattr(plugin, "offload", None) or BoundedOffload()
|
||||
|
||||
if sections:
|
||||
# Section-aware path
|
||||
s_chunks = await offload.run(chunk_sections, sections, chunk_size, overlap)
|
||||
chunks_text: list[str] = []
|
||||
ids: list[str] = []
|
||||
metadatas: list[dict] = []
|
||||
for i, sc in enumerate(s_chunks):
|
||||
chunks_text.append(sc.text)
|
||||
ids.append(f"{doc_id}_{i}")
|
||||
meta = {
|
||||
"file_id": doc_id,
|
||||
"document_id": doc_id,
|
||||
"document_name": filename,
|
||||
"chunk_index": i,
|
||||
"text": sc.text,
|
||||
"index_type": "chunk",
|
||||
"heading": sc.heading,
|
||||
"level": sc.level,
|
||||
"page": sc.page,
|
||||
"heading_path": sc.heading_path,
|
||||
"has_table": sc.has_table,
|
||||
"section_index": sc.section_index,
|
||||
}
|
||||
meta.update(doc_fields)
|
||||
metadatas.append(meta)
|
||||
|
||||
yield chunks_text, ids, metadatas
|
||||
else:
|
||||
# Fallback: flat text chunking (original behaviour)
|
||||
chunks = await offload.run(chunk_text, text, chunk_size, overlap)
|
||||
|
||||
ids = []
|
||||
metadatas = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
ids.append(f"{doc_id}_{i}")
|
||||
meta = {
|
||||
"file_id": doc_id,
|
||||
"document_id": doc_id,
|
||||
"document_name": filename,
|
||||
"chunk_index": i,
|
||||
"text": chunk,
|
||||
"index_type": "chunk",
|
||||
}
|
||||
meta.update(doc_fields)
|
||||
metadatas.append(meta)
|
||||
|
||||
yield chunks, ids, metadatas
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
"""Parent-Child index strategy.
|
||||
|
||||
Uses two-level chunking: large parent chunks for context richness, small child
|
||||
chunks for precise embedding matches. Child chunks are embedded, but the
|
||||
metadata ``text`` field stores the parent chunk so that retrieval automatically
|
||||
returns full context.
|
||||
|
||||
When *sections* are provided, each section naturally maps to a parent chunk.
|
||||
Sections shorter than *parent_chunk_size* become a single parent; longer ones
|
||||
are split. Child chunks are then created from each parent as usual.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from .base import IndexStrategy
|
||||
from components.offload import BoundedOffload
|
||||
from ..chunker import (
|
||||
chunk_text,
|
||||
chunk_sections,
|
||||
DEFAULT_CHUNK_OVERLAP,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PARENT_CHUNK_SIZE = 2048
|
||||
DEFAULT_CHILD_CHUNK_SIZE = 256
|
||||
|
||||
|
||||
class ParentChildStrategy(IndexStrategy):
|
||||
"""Two-level parent / child chunking strategy.
|
||||
|
||||
- Parent chunks are large (default 2048 chars), split with no overlap.
|
||||
- Each parent is further split into small child chunks (default 256 chars)
|
||||
with configurable overlap.
|
||||
- Child chunks are the units that get embedded.
|
||||
- The ``text`` metadata field on each child stores the **parent** chunk,
|
||||
so search results automatically carry richer context.
|
||||
- ``postprocess_results`` deduplicates by parent, keeping only the
|
||||
highest-scoring child per parent.
|
||||
|
||||
When *sections* are provided, sections are used as natural parent
|
||||
boundaries via section-aware chunking at parent_chunk_size, then each
|
||||
parent is further split into children.
|
||||
"""
|
||||
|
||||
async def build_chunks_and_metadata(
|
||||
self,
|
||||
text: str,
|
||||
doc_id: str,
|
||||
filename: str,
|
||||
creation_settings: dict,
|
||||
plugin=None,
|
||||
*,
|
||||
sections: list | None = None,
|
||||
doc_metadata: dict | None = None,
|
||||
) -> AsyncGenerator[tuple[list[str], list[str], list[dict]], None]:
|
||||
parent_size = (
|
||||
creation_settings.get("parent_chunk_size") or DEFAULT_PARENT_CHUNK_SIZE
|
||||
)
|
||||
child_size = (
|
||||
creation_settings.get("child_chunk_size") or DEFAULT_CHILD_CHUNK_SIZE
|
||||
)
|
||||
overlap = creation_settings.get("overlap") or DEFAULT_CHUNK_OVERLAP
|
||||
doc_fields = self._build_doc_meta_fields(doc_metadata)
|
||||
offload = getattr(plugin, "offload", None) or BoundedOffload()
|
||||
|
||||
texts_to_embed: list[str] = []
|
||||
ids: list[str] = []
|
||||
metadatas: list[dict] = []
|
||||
|
||||
if sections:
|
||||
# Section-aware: use sections as parent boundaries
|
||||
parent_chunks = await offload.run(chunk_sections, sections, parent_size, 0)
|
||||
|
||||
for p_idx, parent_sc in enumerate(parent_chunks):
|
||||
parent_text = parent_sc.text
|
||||
child_chunks = await offload.run(chunk_text, parent_text, child_size, overlap)
|
||||
for c_idx, child in enumerate(child_chunks):
|
||||
texts_to_embed.append(child)
|
||||
ids.append(f"{doc_id}_p{p_idx}_c{c_idx}")
|
||||
meta = {
|
||||
"file_id": doc_id,
|
||||
"document_id": doc_id,
|
||||
"document_name": filename,
|
||||
"parent_index": p_idx,
|
||||
"child_text": child,
|
||||
"text": parent_text,
|
||||
"index_type": "parent_child",
|
||||
"heading": parent_sc.heading,
|
||||
"level": parent_sc.level,
|
||||
"page": parent_sc.page,
|
||||
"heading_path": parent_sc.heading_path,
|
||||
"has_table": parent_sc.has_table,
|
||||
"section_index": parent_sc.section_index,
|
||||
}
|
||||
meta.update(doc_fields)
|
||||
metadatas.append(meta)
|
||||
else:
|
||||
# Fallback: flat text chunking (original behaviour)
|
||||
parent_chunks_text = await offload.run(chunk_text, text, parent_size, 0)
|
||||
|
||||
for p_idx, parent in enumerate(parent_chunks_text):
|
||||
child_chunks = await offload.run(chunk_text, parent, child_size, overlap)
|
||||
for c_idx, child in enumerate(child_chunks):
|
||||
texts_to_embed.append(child)
|
||||
ids.append(f"{doc_id}_p{p_idx}_c{c_idx}")
|
||||
meta = {
|
||||
"file_id": doc_id,
|
||||
"document_id": doc_id,
|
||||
"document_name": filename,
|
||||
"parent_index": p_idx,
|
||||
"child_text": child,
|
||||
"text": parent,
|
||||
"index_type": "parent_child",
|
||||
}
|
||||
meta.update(doc_fields)
|
||||
metadatas.append(meta)
|
||||
|
||||
yield texts_to_embed, ids, metadatas
|
||||
|
||||
def postprocess_results(self, results: list[dict], top_k: int) -> list[dict]:
|
||||
"""Deduplicate by parent chunk, keeping the highest-scoring child."""
|
||||
|
||||
def _metric(res: dict) -> tuple[int, float]:
|
||||
distance = res.get("distance")
|
||||
if isinstance(distance, (int, float)):
|
||||
return (0, float(distance))
|
||||
|
||||
score = res.get("score")
|
||||
if isinstance(score, (int, float)):
|
||||
return (1, float(score))
|
||||
|
||||
return (2, float("inf"))
|
||||
|
||||
seen: dict[str, dict] = {} # key → best result
|
||||
for res in results:
|
||||
meta = res.get("metadata", {})
|
||||
doc_id = meta.get("document_id", "")
|
||||
parent_idx = meta.get("parent_index", "")
|
||||
key = f"{doc_id}:{parent_idx}"
|
||||
|
||||
if key not in seen:
|
||||
seen[key] = res
|
||||
else:
|
||||
if _metric(res) < _metric(seen[key]):
|
||||
seen[key] = res
|
||||
|
||||
deduped = list(seen.values())
|
||||
return deduped[:top_k]
|
||||
@@ -0,0 +1,260 @@
|
||||
"""QA Index strategy — generates question-answer pairs from document chunks.
|
||||
|
||||
Instead of embedding raw chunk text, this strategy uses an LLM to generate
|
||||
question-answer pairs from each chunk. The *questions* are embedded and stored
|
||||
as vectors, while the *answers* are stored in metadata. At retrieval time the
|
||||
user's query (naturally phrased as a question) matches against generated
|
||||
questions, yielding better semantic alignment than raw-chunk matching.
|
||||
|
||||
This strategy yields Q&A pairs incrementally (per chunk) so that the ingest
|
||||
pipeline can embed and upsert concurrently with ongoing LLM generation.
|
||||
|
||||
When *sections* are provided, section-aware chunks are used instead of flat
|
||||
text chunks, preserving heading/page metadata in each Q&A pair's metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
|
||||
from .base import IndexStrategy
|
||||
from components.offload import BoundedOffload
|
||||
from ..chunker import (
|
||||
chunk_text,
|
||||
chunk_sections,
|
||||
DEFAULT_CHUNK_SIZE,
|
||||
DEFAULT_CHUNK_OVERLAP,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_QUESTIONS_PER_CHUNK = 1
|
||||
|
||||
QA_GENERATION_PROMPT = """\
|
||||
Based on the following text, generate {n} question-answer pairs.
|
||||
Each question should be directly answerable from the text.
|
||||
Each answer should be concise and grounded in the text.
|
||||
|
||||
Text:
|
||||
{chunk_text}
|
||||
|
||||
Output exactly {n} pairs in this JSON format (no other text):
|
||||
[
|
||||
{{"q": "question", "a": "answer"}},
|
||||
...
|
||||
]"""
|
||||
|
||||
|
||||
def _parse_qa_pairs(text: str) -> list[tuple[str, str]]:
|
||||
"""Parse Q&A pairs from LLM response.
|
||||
|
||||
Tries JSON first, falls back to ``Q:/A:`` text format.
|
||||
"""
|
||||
# --- Try JSON ---
|
||||
try:
|
||||
# Extract JSON array even if surrounded by markdown fences
|
||||
json_match = re.search(r"\[.*?\]", text, re.DOTALL)
|
||||
if json_match:
|
||||
items = json.loads(json_match.group())
|
||||
pairs = []
|
||||
for item in items:
|
||||
q = str(item.get("q", "")).strip()
|
||||
a = str(item.get("a", "")).strip()
|
||||
if q and a:
|
||||
pairs.append((q, a))
|
||||
if pairs:
|
||||
return pairs
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
# --- Fallback: Q:/A: text format ---
|
||||
pairs: list[tuple[str, str]] = []
|
||||
blocks = re.split(r"\nQ:\s*", "\n" + text)
|
||||
for block in blocks:
|
||||
block = block.strip()
|
||||
if not block:
|
||||
continue
|
||||
parts = re.split(r"\nA:\s*", block, maxsplit=1)
|
||||
if len(parts) == 2:
|
||||
q, a = parts[0].strip(), parts[1].strip()
|
||||
if q and a:
|
||||
pairs.append((q, a))
|
||||
return pairs
|
||||
|
||||
|
||||
def _extract_text(msg: Message) -> str:
|
||||
"""Extract plain text from an LLM response Message."""
|
||||
if isinstance(msg.content, str):
|
||||
return msg.content.strip()
|
||||
if isinstance(msg.content, list):
|
||||
return "".join(e.text for e in msg.content if e.type == "text").strip()
|
||||
return ""
|
||||
|
||||
|
||||
class QAStrategy(IndexStrategy):
|
||||
"""QA Index strategy.
|
||||
|
||||
During ingestion each chunk is sent to an LLM which generates N
|
||||
question-answer pairs. The **questions** become the texts that get
|
||||
embedded. The **answers** are stored in the ``text`` metadata field so
|
||||
they are returned as retrieval results. The original chunk is kept in
|
||||
``source_chunk`` for additional context.
|
||||
|
||||
Yields Q&A pairs per chunk so the ingest pipeline can start embedding
|
||||
while the next chunk's LLM call is in flight.
|
||||
|
||||
When *sections* are provided, section-aware chunks are used, and each
|
||||
Q&A pair inherits the chunk's heading/page/heading_path metadata.
|
||||
"""
|
||||
|
||||
async def build_chunks_and_metadata(
|
||||
self,
|
||||
text: str,
|
||||
doc_id: str,
|
||||
filename: str,
|
||||
creation_settings: dict,
|
||||
plugin=None,
|
||||
*,
|
||||
sections: list | None = None,
|
||||
doc_metadata: dict | None = None,
|
||||
) -> AsyncGenerator[tuple[list[str], list[str], list[dict]], None]:
|
||||
if plugin is None:
|
||||
raise RuntimeError("QAStrategy requires a plugin reference for LLM calls")
|
||||
|
||||
chunk_size = creation_settings.get("chunk_size") or DEFAULT_CHUNK_SIZE
|
||||
overlap = creation_settings.get("overlap") or DEFAULT_CHUNK_OVERLAP
|
||||
qa_llm_uuid = creation_settings.get("qa_llm_model_uuid", "")
|
||||
n_questions = (
|
||||
creation_settings.get("questions_per_chunk") or DEFAULT_QUESTIONS_PER_CHUNK
|
||||
)
|
||||
doc_fields = self._build_doc_meta_fields(doc_metadata)
|
||||
offload = getattr(plugin, "offload", None) or BoundedOffload()
|
||||
|
||||
if not qa_llm_uuid:
|
||||
raise ValueError(
|
||||
"QA Index requires 'qa_llm_model_uuid' in creation settings"
|
||||
)
|
||||
|
||||
# Build chunk list — section-aware or flat
|
||||
if sections:
|
||||
s_chunks = await offload.run(chunk_sections, sections, chunk_size, overlap)
|
||||
# Wrap into a uniform structure for the loop below
|
||||
chunk_items = [
|
||||
{
|
||||
"text": sc.text,
|
||||
"heading": sc.heading,
|
||||
"level": sc.level,
|
||||
"page": sc.page,
|
||||
"heading_path": sc.heading_path,
|
||||
"has_table": sc.has_table,
|
||||
"section_index": sc.section_index,
|
||||
}
|
||||
for sc in s_chunks
|
||||
]
|
||||
else:
|
||||
flat_chunks = await offload.run(chunk_text, text, chunk_size, overlap)
|
||||
chunk_items = [{"text": c} for c in flat_chunks]
|
||||
|
||||
logger.info(
|
||||
f"[QA] Generating Q&A pairs: {len(chunk_items)} chunks × "
|
||||
f"{n_questions} questions, LLM={qa_llm_uuid}"
|
||||
)
|
||||
|
||||
total_qa = 0
|
||||
|
||||
for chunk_idx, chunk_info in enumerate(chunk_items):
|
||||
chunk = chunk_info["text"]
|
||||
prompt = QA_GENERATION_PROMPT.format(n=n_questions, chunk_text=chunk)
|
||||
messages = [Message(role="user", content=prompt)]
|
||||
|
||||
try:
|
||||
response = await plugin.invoke_llm(qa_llm_uuid, messages)
|
||||
response_text = _extract_text(response)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[QA] LLM call failed for chunk {chunk_idx}: {e}, skipping"
|
||||
)
|
||||
continue
|
||||
|
||||
qa_pairs = _parse_qa_pairs(response_text)
|
||||
if not qa_pairs:
|
||||
logger.warning(
|
||||
f"[QA] No Q&A pairs parsed from chunk {chunk_idx}, "
|
||||
f"raw response: {response_text[:200]!r}"
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"[QA] Chunk {chunk_idx}/{len(chunk_items) - 1}: "
|
||||
f"generated {len(qa_pairs)} Q&A pairs"
|
||||
)
|
||||
|
||||
questions: list[str] = []
|
||||
ids: list[str] = []
|
||||
metadatas: list[dict] = []
|
||||
|
||||
for qa_idx, (question, answer) in enumerate(qa_pairs):
|
||||
vec_id = f"{doc_id}_{chunk_idx}_qa{qa_idx}"
|
||||
questions.append(question)
|
||||
ids.append(vec_id)
|
||||
meta = {
|
||||
"file_id": doc_id,
|
||||
"document_id": doc_id,
|
||||
"document_name": filename,
|
||||
"chunk_index": chunk_idx,
|
||||
"qa_index": qa_idx,
|
||||
"question": question,
|
||||
"answer": answer,
|
||||
"text": answer,
|
||||
"source_chunk": chunk,
|
||||
"index_type": "qa",
|
||||
}
|
||||
# Add section metadata if available
|
||||
if "heading" in chunk_info:
|
||||
meta["heading"] = chunk_info["heading"]
|
||||
meta["level"] = chunk_info["level"]
|
||||
meta["page"] = chunk_info["page"]
|
||||
meta["heading_path"] = chunk_info["heading_path"]
|
||||
meta["has_table"] = chunk_info["has_table"]
|
||||
meta["section_index"] = chunk_info["section_index"]
|
||||
meta.update(doc_fields)
|
||||
metadatas.append(meta)
|
||||
|
||||
total_qa += len(questions)
|
||||
yield questions, ids, metadatas
|
||||
|
||||
logger.info(f"[QA] Total: {total_qa} Q&A pairs from {len(chunk_items)} chunks")
|
||||
|
||||
def postprocess_results(self, results: list[dict], top_k: int) -> list[dict]:
|
||||
"""Deduplicate by source chunk, keeping the highest-scoring QA per chunk."""
|
||||
|
||||
def _metric(res: dict) -> tuple[int, float]:
|
||||
distance = res.get("distance")
|
||||
if isinstance(distance, (int, float)):
|
||||
return (0, float(distance))
|
||||
|
||||
score = res.get("score")
|
||||
if isinstance(score, (int, float)):
|
||||
return (1, float(score))
|
||||
|
||||
return (2, float("inf"))
|
||||
|
||||
seen: dict[str, dict] = {}
|
||||
for res in results:
|
||||
meta = res.get("metadata", {})
|
||||
doc_id = meta.get("document_id", "")
|
||||
chunk_idx = meta.get("chunk_index", "")
|
||||
key = f"{doc_id}:{chunk_idx}"
|
||||
|
||||
if key not in seen:
|
||||
seen[key] = res
|
||||
else:
|
||||
if _metric(res) < _metric(seen[key]):
|
||||
seen[key] = res
|
||||
|
||||
return list(seen.values())[:top_k]
|
||||
Reference in New Issue
Block a user