feat: add durable document indexing pipeline

This commit is contained in:
gqt
2026-07-13 11:32:14 +08:00
parent eebcd5d6a7
commit 303116f950
12 changed files with 598 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
"""Retrieval-augmented generation infrastructure."""
+63
View File
@@ -0,0 +1,63 @@
from langchain_openai import OpenAIEmbeddings
from kbqa.api.errors import AppError
from kbqa.config import Settings, validate_live_settings
class EmbeddingProvider:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self._client: OpenAIEmbeddings | None = None
def _get_client(self) -> OpenAIEmbeddings:
if self._client is None:
validate_live_settings(self.settings)
self._client = OpenAIEmbeddings(
api_key=self.settings.dashscope_api_key,
base_url=self.settings.dashscope_base_url,
model=self.settings.embedding_model,
dimensions=self.settings.embedding_dim,
chunk_size=20,
max_retries=2,
timeout=60.0,
check_embedding_ctx_length=False,
)
return self._client
def _validate_vectors(self, vectors: list[list[float]]) -> list[list[float]]:
if any(len(vector) != self.settings.embedding_dim for vector in vectors):
raise AppError(
"EMBEDDING_DIMENSION_MISMATCH",
"Embedding 返回的向量维度不正确",
502,
retryable=True,
)
return vectors
async def embed_documents(self, texts: list[str]) -> list[list[float]]:
try:
vectors = await self._get_client().aembed_documents(texts)
except AppError:
raise
except Exception as exc:
raise AppError(
"EMBEDDING_UPSTREAM_ERROR",
"文档向量化失败,请稍后重试",
502,
retryable=True,
) from exc
return self._validate_vectors(vectors)
async def embed_query(self, text: str) -> list[float]:
try:
vector = await self._get_client().aembed_query(text)
except AppError:
raise
except Exception as exc:
raise AppError(
"EMBEDDING_UPSTREAM_ERROR",
"查询向量化失败,请稍后重试",
502,
retryable=True,
) from exc
return self._validate_vectors([vector])[0]
+56
View File
@@ -0,0 +1,56 @@
import asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker
from kbqa.documents.models import Chunk, Document
from kbqa.rag.embeddings import EmbeddingProvider
from kbqa.rag.types import SourceCandidate
from kbqa.rag.vector_store import MilvusVectorStore
class KnowledgeRetriever:
def __init__(
self,
embeddings: EmbeddingProvider,
vector_store: MilvusVectorStore,
session_factory: async_sessionmaker,
) -> None:
self.embeddings = embeddings
self.vector_store = vector_store
self.session_factory = session_factory
async def retrieve(self, query: str, top_k: int) -> list[SourceCandidate]:
vector = await self.embeddings.embed_query(query)
candidate_limit = max(top_k * 3, top_k)
hits = await asyncio.to_thread(self.vector_store.search, vector, candidate_limit)
if not hits:
return []
ids = [hit.chunk_id for hit in hits]
async with self.session_factory() as session:
rows = await session.execute(
select(Chunk, Document)
.join(Document, Document.id == Chunk.document_id)
.where(Chunk.id.in_(ids), Document.status == "indexed")
)
lookup = {chunk.id: (chunk, document) for chunk, document in rows}
sources: list[SourceCandidate] = []
for hit in hits:
record = lookup.get(hit.chunk_id)
if record is None:
continue
chunk, document = record
sources.append(
SourceCandidate(
chunk_id=chunk.id,
document_id=document.id,
filename=document.filename,
order_index=chunk.order_index,
page_number=chunk.page_number,
content=chunk.content,
score=hit.score,
)
)
if len(sources) == top_k:
break
return sources
+29
View File
@@ -0,0 +1,29 @@
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class SourceCandidate:
chunk_id: str
document_id: str
filename: str
order_index: int
page_number: int | None
content: str
score: float
@dataclass(frozen=True, slots=True)
class PreparedChunk:
id: str
document_id: str
order_index: int
page_number: int | None
content: str
@dataclass(frozen=True, slots=True)
class VectorHit:
chunk_id: str
document_id: str
order_index: int
score: float
+94
View File
@@ -0,0 +1,94 @@
import json
from pymilvus import DataType, MilvusClient
from kbqa.rag.types import PreparedChunk, VectorHit
class MilvusVectorStore:
collection_name = "kbqa_chunks"
def __init__(self, client: MilvusClient, embedding_dim: int) -> None:
self.client = client
self.embedding_dim = embedding_dim
def ensure_collection(self) -> None:
if self.client.has_collection(self.collection_name):
return
schema = self.client.create_schema(auto_id=False, enable_dynamic_field=False)
schema.add_field(
field_name="chunk_id",
datatype=DataType.VARCHAR,
is_primary=True,
max_length=36,
)
schema.add_field(
field_name="document_id",
datatype=DataType.VARCHAR,
max_length=36,
)
schema.add_field(field_name="order_index", datatype=DataType.INT64)
schema.add_field(
field_name="vector",
datatype=DataType.FLOAT_VECTOR,
dim=self.embedding_dim,
)
index_params = self.client.prepare_index_params()
index_params.add_index(
field_name="vector",
index_type="AUTOINDEX",
metric_type="COSINE",
)
self.client.create_collection(
collection_name=self.collection_name,
schema=schema,
index_params=index_params,
)
def upsert(self, chunks: list[PreparedChunk], vectors: list[list[float]]) -> None:
if len(chunks) != len(vectors):
raise ValueError("chunks and vectors must have equal lengths")
if not chunks:
return
data = [
{
"chunk_id": chunk.id,
"document_id": chunk.document_id,
"order_index": chunk.order_index,
"vector": vector,
}
for chunk, vector in zip(chunks, vectors, strict=True)
]
self.client.upsert(collection_name=self.collection_name, data=data)
def search(self, vector: list[float], limit: int) -> list[VectorHit]:
results = self.client.search(
collection_name=self.collection_name,
data=[vector],
anns_field="vector",
limit=limit,
output_fields=["document_id", "order_index"],
search_params={"metric_type": "COSINE"},
)
hits: list[VectorHit] = []
for item in results[0] if results else []:
entity = item.get("entity", {})
hits.append(
VectorHit(
chunk_id=str(item["id"]),
document_id=str(entity["document_id"]),
order_index=int(entity["order_index"]),
score=float(item["distance"]),
)
)
return hits
def delete_document(self, document_id: str) -> None:
encoded = json.dumps(document_id)
self.client.delete(
collection_name=self.collection_name,
filter=f"document_id == {encoded}",
)
def ping(self) -> None:
self.client.list_collections()