feat: add hybrid rag retrieval audit

This commit is contained in:
2026-06-22 21:42:12 +08:00
parent b319d41787
commit 82d2a9f0aa
13 changed files with 625 additions and 81 deletions
@@ -4,106 +4,102 @@
## 本轮目标
本轮推进 AI 中台知识库检索增强,把上一轮“整篇知识文档关键词检索”升级为更接近 RAG 的基础结构:
本轮推进 AI 中台知识库检索增强,把早期“整篇知识文档关键词检索”升级为更接近生产 RAG 的基础结构:
- 导入批次。
- 文档切分 chunk。
- chunk 级检索结果。
- 检索命中分数
- 关键词分、向量分、混合分
- 来源定位。
- 法律问答返回结构化主要依据。
- AI 审计记录命中的 chunk ids、来源、分数和检索策略。
## 已完成
1. 数据模型
- 新增 `KnowledgeImportBatchModel`
- 新增 `KnowledgeChunkModel`
- `KnowledgeDocumentModel` 增加
- `import_batch_id`
- `index_status`
- `KnowledgeDocumentModel` 增加 `import_batch_id``index_status`
- `KnowledgeChunkModel` 增加本地 embedding 字段:`embedding_model``embedding_dimension``embedding_json`
- `AiRunAuditModel` 增加检索追踪字段:`retrieval_query``retrieval_strategy``retrieved_chunk_ids_json``retrieved_sources_json`
2. 数据库迁移
- 新增 Alembic migration
- `20260622_0002_add_knowledge_batches_and_chunks.py`
- 支持升级/降级:
- `knowledge_import_batches`
- `knowledge_chunks`
- `knowledge_documents.import_batch_id`
- `knowledge_documents.index_status`
- `20260622_0002_add_knowledge_batches_and_chunks.py`
- `20260622_0003_add_hybrid_retrieval_audit.py`
- 支持升级/回滚。
3. Repository 能力
- 创建知识导入批次。
- 列出知识导入批次。
- 创建和列出知识导入批次。
- 新增知识文档时自动切分 chunk。
- 列出文档 chunk。
- chunk 级关键词检索
- 返回检索分数、命中词、locatordocument_idchunk_id。
- chunk 持久化本地 hash embedding
- 支持 `keyword``vector``hybrid` 三种检索策略
- 搜索结果返回 `score``keyword_score``vector_score``hybrid_score``matched_terms``locator``document_id``chunk_id`
- 增加泛化词过滤,避免“这个合同需要注意什么”这类泛问误命中具体法条。
4. API 能力
- `GET /api/v1/knowledge-import-batches`
- `POST /api/v1/knowledge-import-batches`
- `GET /api/v1/knowledge-documents/{document_id}/chunks`
- `GET /api/v1/knowledge-chunks/search`
- 旧接口 `GET /api/v1/knowledge-documents/search` 保持兼容。
- `GET /api/v1/knowledge-chunks/search?strategy=keyword|vector|hybrid`
- `GET /api/v1/knowledge-documents/search`
- `POST /api/v1/legal/qa`
- `GET /api/v1/audit/ai-runs`
5. 法律问答增强
- 法律问答优先使用 chunk 检索。
- `LegalCitation` 增加:
- `score`
- `locator`
- `document_id`
- `chunk_id`
- `knowledge_base_id`
- `LegalQaResponse` 增加 `primary_sources`
- 旧字段 `citations` 保持兼容。
- 默认使用 `hybrid` 检索。
- `LegalQaResponse.primary_sources` 返回结构化主要依据。
- `LegalCitation` 增加检索分数、定位、chunk id、document id、knowledge base id、命中词和检索策略。
- AI 审计记录保留本次命中的 chunk ids 和结构化来源。
- 清理规则兜底回答里的历史乱码文本,改为可读 UTF-8 中文。
6. SDK 能力
- 新增知识导入批次类型和方法
- 新增 chunk 类型和方法
- 新增 scored chunk search 方法
- 法律问答响应支持 `primary_sources`
- Python SDK 同步知识批次、chunk、混合检索和审计来源字段
- `search_knowledge_chunks(..., strategy="hybrid")` 支持策略参数
- `AiRunAudit` 支持读取 `retrieved_chunk_ids``retrieved_sources`
## 验证结果
已通过:
```powershell
python -m pytest
python -m pytest yuqei-ai-platform/services/ai-platform-api/tests yuqei-ai-sdk-python/tests
```
当前结果:
```text
21 passed
16 passed
```
覆盖点:
- 知识导入批次创建和统计更新。
- 文档自动切分 chunk。
- chunk 搜索返回 score 和 locator
- SQLite 持久化仓库可跨实例读取批次、chunk 和审计
- chunk 返回 embedding 元数据
- chunk 搜索返回关键词分、向量分、混合分和命中词
- 法律问答返回 `primary_sources`
- SDK 覆盖新增知识库检索能力
- AI 审计记录命中的 chunk ids 和结构化来源
- SQLite 持久化后可重新读取 batch、chunk、audit。
- SDK 覆盖新增检索和审计字段。
## 当前边界
本轮仍是关键词检索,不是正式向量检索
本轮采用确定性的本地 hash embedding,目的是先稳定 API、数据结构、审计链路和测试闭环。它不是最终生产级语义向量模型
尚未完成:
- embedding 生成
- 向量库或 pgvector。
- 混合检索排序
- 文档上传解析、OCR、复杂 DOCX/PDF 切分
- AI 审计记录命中文档 ID 列表、token、费用、provider 错误详情
- 接入真实 embedding 模型
- 引入 pgvector 或独立向量库
- 大规模文档解析、OCR、DOCX/PDF 智能切分
- provider token、费用、错误详情的完整审计
- 检索 explain 面板和质量评测集
## 下一轮建议
下一进入知识库索引质量增强
下一进入生产级 RAG 基础设施
1. 增加 embedding 字段和向量检索适配层
2. 引入 `pgvector` 或独立向量库的技术选择和迁移
3. 增加检索 explain 信息:关键词分、向量分、最终分
4. AI 审计记录 `hit_chunk_ids`、provider 错误、token 和成本
5. 法律问答输出按“结论、主要依据、风险提示、建议动作”结构化
1. 选择并接入 embedding provider
2. 增加 pgvector 迁移和向量索引
3. 把当前 hash embedding 作为测试/离线 fallback,生产使用真实向量
4. 增加检索 explain API,展示关键词分、向量分、混合分、过滤原因
5. 建立法律问答评测集,检查“回答是否有依据、依据是否匹配问题、是否误引法条”
@@ -340,6 +340,13 @@ paths:
default: 5
minimum: 1
maximum: 20
- name: strategy
in: query
required: false
schema:
type: string
enum: [keyword, vector, hybrid]
default: hybrid
responses:
"200":
description: Knowledge chunk matches
@@ -466,6 +473,25 @@ components:
knowledge_base_id:
type: string
nullable: true
keyword_score:
type: number
format: float
nullable: true
vector_score:
type: number
format: float
nullable: true
hybrid_score:
type: number
format: float
nullable: true
retrieval_strategy:
type: string
nullable: true
matched_terms:
type: array
items:
type: string
LegalQaRequest:
type: object
required:
@@ -480,6 +506,10 @@ components:
type: array
items:
type: string
retrieval_strategy:
type: string
enum: [keyword, vector, hybrid]
default: hybrid
LegalQaResponse:
type: object
required:
@@ -840,6 +870,12 @@ components:
type: integer
locator:
type: string
embedding_model:
type: string
default: local-hash-v1
embedding_dimension:
type: integer
default: 64
created_at:
type: string
format: date-time
@@ -855,6 +891,62 @@ components:
score:
type: number
format: float
keyword_score:
type: number
format: float
vector_score:
type: number
format: float
hybrid_score:
type: number
format: float
retrieval_strategy:
type: string
enum: [keyword, vector, hybrid]
matched_terms:
type: array
items:
type: string
RetrievalSource:
type: object
required:
- title
- source_type
- reference
properties:
chunk_id:
type: string
nullable: true
document_id:
type: string
nullable: true
knowledge_base_id:
type: string
nullable: true
title:
type: string
source_type:
type: string
reference:
type: string
locator:
type: string
nullable: true
score:
type: number
format: float
keyword_score:
type: number
format: float
vector_score:
type: number
format: float
hybrid_score:
type: number
format: float
retrieval_strategy:
type: string
enum: [keyword, vector, hybrid]
matched_terms:
type: array
items:
@@ -885,6 +977,20 @@ components:
nullable: true
citations_count:
type: integer
retrieval_query:
type: string
nullable: true
retrieval_strategy:
type: string
nullable: true
retrieved_chunk_ids:
type: array
items:
type: string
retrieved_sources:
type: array
items:
$ref: "#/components/schemas/RetrievalSource"
latency_ms:
type: integer
status:
@@ -47,10 +47,14 @@ Core MVP endpoints:
- `POST /api/v1/knowledge-documents`
- `GET /api/v1/knowledge-documents/{document_id}/chunks`
- `GET /api/v1/knowledge-documents/search`
- `GET /api/v1/knowledge-chunks/search`
- `GET /api/v1/knowledge-chunks/search?strategy=keyword|vector|hybrid`
- `POST /api/v1/legal/qa`
- `GET /api/v1/audit/ai-runs`
Knowledge chunk search returns keyword, vector, and hybrid scores. Legal QA uses
hybrid retrieval by default and records retrieved chunk ids plus structured source
metadata in AI run audits.
## Test
From repository root:
@@ -0,0 +1,51 @@
"""add hybrid retrieval audit fields
Revision ID: 20260622_0003
Revises: 20260622_0002
Create Date: 2026-06-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260622_0003"
down_revision: Union[str, None] = "20260622_0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"knowledge_chunks",
sa.Column("embedding_model", sa.String(length=64), nullable=False, server_default="local-hash-v1"),
)
op.add_column(
"knowledge_chunks",
sa.Column("embedding_dimension", sa.Integer(), nullable=False, server_default="64"),
)
op.add_column("knowledge_chunks", sa.Column("embedding_json", sa.Text(), nullable=True))
op.add_column("ai_run_audits", sa.Column("retrieval_query", sa.Text(), nullable=True))
op.add_column("ai_run_audits", sa.Column("retrieval_strategy", sa.String(length=32), nullable=True))
op.add_column(
"ai_run_audits",
sa.Column("retrieved_chunk_ids_json", sa.Text(), nullable=False, server_default="[]"),
)
op.add_column(
"ai_run_audits",
sa.Column("retrieved_sources_json", sa.Text(), nullable=False, server_default="[]"),
)
def downgrade() -> None:
op.drop_column("ai_run_audits", "retrieved_sources_json")
op.drop_column("ai_run_audits", "retrieved_chunk_ids_json")
op.drop_column("ai_run_audits", "retrieval_strategy")
op.drop_column("ai_run_audits", "retrieval_query")
op.drop_column("knowledge_chunks", "embedding_json")
op.drop_column("knowledge_chunks", "embedding_dimension")
op.drop_column("knowledge_chunks", "embedding_model")
@@ -5,6 +5,7 @@ class LegalQaRequest(BaseModel):
question: str = Field(min_length=1)
matter_context: str | None = None
knowledge_base_ids: list[str] = Field(default_factory=list)
retrieval_strategy: str = "hybrid"
class LegalCitation(BaseModel):
@@ -18,6 +19,11 @@ class LegalCitation(BaseModel):
document_id: str | None = None
chunk_id: str | None = None
knowledge_base_id: str | None = None
keyword_score: float | None = None
vector_score: float | None = None
hybrid_score: float | None = None
retrieval_strategy: str | None = None
matched_terms: list[str] = Field(default_factory=list)
class LegalQaResponse(BaseModel):
@@ -34,20 +40,23 @@ def answer_legal_question(
) -> LegalQaResponse:
question = request.question.strip()
context = request.matter_context.strip() if request.matter_context else ""
question_text = question + "\n" + context
question_text = f"{question}\n{context}"
citations_from_search = retrieved_citations or []
if _contains_any(question_text, ["试用期", "劳动合同", "工资", "解除", "竞业"]):
if _contains_any(question_text, ["劳动合同", "试用期", "工资", "解除劳动", "竞业限制"]):
return _with_retrieved_citations(_labor_answer(question), citations_from_search)
if _contains_any(question_text, ["租赁", "房屋", "租金", "押金", "保证金"]):
if _contains_any(question_text, ["租赁", "房屋", "租金", "押金", "保证金", "出租", "承租"]):
return _with_retrieved_citations(_lease_answer(question), citations_from_search)
return _with_retrieved_citations(
LegalQaResponse(
return _with_retrieved_citations(_general_contract_answer(), citations_from_search)
def _general_contract_answer() -> LegalQaResponse:
return LegalQaResponse(
answer=(
"当前问题需要结合合同类型、交易背景和适用地区进一步判断。"
"V2 MVP 会先返回可审计的基础意见;正式版本会先检索企业知识库和法规库,"
"再给出更精确的法律依据、条文引用和处理建议"
"当前问题需要结合合同类型、交易背景、主体身份和履行地点进一步判断。"
"建议先核对合同主体、标的、价款、履行期限、违约责任、解除条件、争议解决和证据留存安排。"
"如果需要更精确的结论,应补充合同正文或关键条款后再进行法律检索和风险判断"
),
citations=[
LegalCitation(
@@ -58,8 +67,6 @@ def answer_legal_question(
)
],
confidence=0.45,
),
citations_from_search,
)
@@ -67,7 +74,7 @@ def _labor_answer(question: str) -> LegalQaResponse:
return LegalQaResponse(
answer=(
f"针对“{question}”,应优先核对劳动合同期限、试用期约定、工资支付记录和解除理由。"
"如果试用期超过法定上限,或者解除缺少事实和制度依据,企业侧会面临补足工资、"
"如果试用期超过法定上限,或者解除缺少事实和制度依据,用人单位可能面临补足工资、"
"违法解除赔偿或继续履行等风险。建议在处理前形成证据清单,并把结论写入审批意见。"
),
citations=[
@@ -92,7 +99,7 @@ def _lease_answer(question: str) -> LegalQaResponse:
return LegalQaResponse(
answer=(
f"针对“{question}”,应先确认租赁标的、租赁期限、租金支付、押金返还、维修义务和解除条件。"
"如果合同没有明确交付状态、押金扣除条件或提前解除责任,后续容易产生举证争议。"
"如果合同没有明确交付状态、押金扣除条件或提前解除责任,后续容易产生举证争议。"
"建议在起草或审查时把交付清单、付款节点和违约责任写成可执行条款。"
),
citations=[
@@ -26,6 +26,7 @@ from yuqei_ai_platform_api.repository import (
ProviderConfigCreate,
chunk_to_citation,
document_to_citation,
search_result_to_retrieval_source,
)
from yuqei_ai_platform_api.state import get_repository
@@ -160,11 +161,13 @@ def create_app(
keyword: str = "",
knowledge_base_id: str | None = None,
limit: int = Query(5, ge=1, le=20),
strategy: str = Query("hybrid", pattern="^(keyword|vector|hybrid)$"),
) -> list[KnowledgeSearchResult]:
return store.search_knowledge_chunks(
keyword,
knowledge_base_ids=[knowledge_base_id] if knowledge_base_id else None,
limit=limit,
strategy=strategy,
)
@app.get(
@@ -182,7 +185,11 @@ def create_app(
)
def run_legal_qa(request: LegalQaRequest) -> LegalQaResponse:
started_at = perf_counter()
citations = _search_citations(store, request)
search_results = _search_chunk_results(store, request)
citations = [chunk_to_citation(result) for result in search_results]
if not citations:
citations = [document_to_citation(document) for document in _search_citation_documents(store, request)]
retrieval_sources = [search_result_to_retrieval_source(result) for result in search_results]
provider_config = store.get_default_provider_config()
prompt_template = store.get_prompt_template("legal_qa.default")
prompt = render_legal_qa_prompt(
@@ -220,6 +227,10 @@ def create_app(
question=request.question,
answer=response.answer,
citations_count=len(response.citations),
retrieval_query=request.question,
retrieval_strategy=request.retrieval_strategy,
retrieved_chunk_ids=[source.chunk_id for source in retrieval_sources if source.chunk_id],
retrieved_sources=retrieval_sources,
latency_ms=max(0, int((perf_counter() - started_at) * 1000)),
status=audit_status,
)
@@ -260,10 +271,21 @@ def _search_citations(
repository: AiPlatformRepository,
request: LegalQaRequest,
) -> list[LegalCitation]:
results = _search_chunk_results(repository, request)
if results:
return [chunk_to_citation(result) for result in results]
return [document_to_citation(document) for document in _search_citation_documents(repository, request)]
def _search_chunk_results(
repository: AiPlatformRepository,
request: LegalQaRequest,
) -> list[KnowledgeSearchResult]:
results = repository.search_knowledge_chunks(
request.question,
knowledge_base_ids=request.knowledge_base_ids,
limit=3,
strategy=request.retrieval_strategy,
)
if not results:
question_text = f"{request.question}\n{request.matter_context or ''}"
@@ -272,12 +294,11 @@ def _search_citations(
keyword,
knowledge_base_ids=request.knowledge_base_ids,
limit=3,
strategy=request.retrieval_strategy,
)
if results:
break
if results:
return [chunk_to_citation(result) for result in results]
return [document_to_citation(document) for document in _search_citation_documents(repository, request)]
return results
def _fallback_keywords(question_text: str) -> list[str]:
@@ -90,6 +90,9 @@ class KnowledgeChunkModel(Base):
content: Mapped[str] = mapped_column(Text)
chunk_index: Mapped[int] = mapped_column(Integer, default=0)
locator: Mapped[str] = mapped_column(String(128), default="")
embedding_model: Mapped[str] = mapped_column(String(64), default="local-hash-v1")
embedding_dimension: Mapped[int] = mapped_column(Integer, default=64)
embedding_json: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
@@ -106,6 +109,10 @@ class AiRunAuditModel(Base):
question: Mapped[str | None] = mapped_column(Text, nullable=True)
answer: Mapped[str | None] = mapped_column(Text, nullable=True)
citations_count: Mapped[int] = mapped_column(Integer, default=0)
retrieval_query: Mapped[str | None] = mapped_column(Text, nullable=True)
retrieval_strategy: Mapped[str | None] = mapped_column(String(32), nullable=True)
retrieved_chunk_ids_json: Mapped[str] = mapped_column(Text, default="[]")
retrieved_sources_json: Mapped[str] = mapped_column(Text, default="[]")
latency_ms: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(String(32), default="succeeded")
created_at: Mapped[datetime] = mapped_column(
@@ -1,5 +1,9 @@
from __future__ import annotations
import hashlib
import json
import math
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from threading import Lock
@@ -24,6 +28,29 @@ from yuqei_ai_platform_api.models import (
)
EMBEDDING_MODEL = "local-hash-v1"
EMBEDDING_DIMENSION = 64
RETRIEVAL_STRATEGIES = {"keyword", "vector", "hybrid"}
MIN_VECTOR_RECALL_SCORE = 0.35
GENERIC_RETRIEVAL_TERMS = {
"合同",
"这个",
"需要",
"注意",
"什么",
"事项",
"问题",
"风险",
"如何",
"怎么",
"是否",
"可以",
"应该",
"处理",
"相关",
}
class ProviderConfigCreate(BaseModel):
provider: str = Field(min_length=1)
model: str = Field(min_length=1)
@@ -93,12 +120,35 @@ class KnowledgeChunk(BaseModel):
content: str
chunk_index: int
locator: str
embedding_model: str = EMBEDDING_MODEL
embedding_dimension: int = EMBEDDING_DIMENSION
embedding_vector: list[float] = Field(default_factory=list, exclude=True)
created_at: datetime
class KnowledgeSearchResult(BaseModel):
chunk: KnowledgeChunk
score: float
keyword_score: float = 0.0
vector_score: float = 0.0
hybrid_score: float = 0.0
retrieval_strategy: str = "hybrid"
matched_terms: list[str] = Field(default_factory=list)
class RetrievalSource(BaseModel):
chunk_id: str | None = None
document_id: str | None = None
knowledge_base_id: str | None = None
title: str
source_type: str
reference: str
locator: str | None = None
score: float = 0.0
keyword_score: float = 0.0
vector_score: float = 0.0
hybrid_score: float = 0.0
retrieval_strategy: str = "hybrid"
matched_terms: list[str] = Field(default_factory=list)
@@ -110,6 +160,10 @@ class AiRunAudit(BaseModel):
question: str | None = None
answer: str | None = None
citations_count: int = 0
retrieval_query: str | None = None
retrieval_strategy: str | None = None
retrieved_chunk_ids: list[str] = Field(default_factory=list)
retrieved_sources: list[RetrievalSource] = Field(default_factory=list)
latency_ms: int = 0
status: str = "succeeded"
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
@@ -155,6 +209,7 @@ class AiPlatformRepository(Protocol):
*,
knowledge_base_ids: list[str] | None = None,
limit: int = 5,
strategy: str = "hybrid",
) -> list[KnowledgeSearchResult]: ...
def add_ai_run_audit(self, audit: AiRunAudit) -> AiRunAudit: ...
@@ -322,10 +377,17 @@ class InMemoryAiPlatformRepository:
*,
knowledge_base_ids: list[str] | None = None,
limit: int = 5,
strategy: str = "hybrid",
) -> list[KnowledgeSearchResult]:
with self._lock:
chunks = list(self.knowledge_chunks.values())
return _rank_knowledge_chunks(chunks, keyword, knowledge_base_ids=knowledge_base_ids, limit=limit)
return _rank_knowledge_chunks(
chunks,
keyword,
knowledge_base_ids=knowledge_base_ids,
limit=limit,
strategy=strategy,
)
def add_ai_run_audit(self, audit: AiRunAudit) -> AiRunAudit:
with self._lock:
@@ -535,6 +597,7 @@ class SqlAlchemyAiPlatformRepository:
*,
knowledge_base_ids: list[str] | None = None,
limit: int = 5,
strategy: str = "hybrid",
) -> list[KnowledgeSearchResult]:
with self._session_factory() as session:
statement = select(KnowledgeChunkModel).order_by(KnowledgeChunkModel.created_at.desc())
@@ -546,10 +609,28 @@ class SqlAlchemyAiPlatformRepository:
keyword,
knowledge_base_ids=knowledge_base_ids,
limit=limit,
strategy=strategy,
)
def add_ai_run_audit(self, audit: AiRunAudit) -> AiRunAudit:
row = AiRunAuditModel(**audit.model_dump())
row = AiRunAuditModel(
id=audit.id,
operation=audit.operation,
provider_config_id=audit.provider_config_id,
prompt_template_id=audit.prompt_template_id,
question=audit.question,
answer=audit.answer,
citations_count=audit.citations_count,
retrieval_query=audit.retrieval_query,
retrieval_strategy=audit.retrieval_strategy,
retrieved_chunk_ids_json=_encode_json_list(audit.retrieved_chunk_ids),
retrieved_sources_json=_encode_json_list(
[source.model_dump() for source in audit.retrieved_sources]
),
latency_ms=audit.latency_ms,
status=audit.status,
created_at=audit.created_at,
)
with self._session_factory.begin() as session:
session.add(row)
session.flush()
@@ -602,6 +683,30 @@ def chunk_to_citation(result: KnowledgeSearchResult) -> LegalCitation:
document_id=chunk.document_id,
chunk_id=chunk.id,
knowledge_base_id=chunk.knowledge_base_id,
keyword_score=result.keyword_score,
vector_score=result.vector_score,
hybrid_score=result.hybrid_score,
retrieval_strategy=result.retrieval_strategy,
matched_terms=result.matched_terms,
)
def search_result_to_retrieval_source(result: KnowledgeSearchResult) -> RetrievalSource:
chunk = result.chunk
return RetrievalSource(
chunk_id=chunk.id,
document_id=chunk.document_id,
knowledge_base_id=chunk.knowledge_base_id,
title=chunk.title,
source_type=chunk.source_type,
reference=chunk.reference,
locator=chunk.locator,
score=result.score,
keyword_score=result.keyword_score,
vector_score=result.vector_score,
hybrid_score=result.hybrid_score,
retrieval_strategy=result.retrieval_strategy,
matched_terms=result.matched_terms,
)
@@ -671,6 +776,9 @@ def _knowledge_chunk_from_model(row: KnowledgeChunkModel) -> KnowledgeChunk:
content=row.content,
chunk_index=row.chunk_index,
locator=row.locator,
embedding_model=row.embedding_model,
embedding_dimension=row.embedding_dimension,
embedding_vector=_decode_embedding(row.embedding_json),
created_at=_ensure_utc(row.created_at),
)
@@ -687,6 +795,9 @@ def _knowledge_chunk_model_from_dto(chunk: KnowledgeChunk) -> KnowledgeChunkMode
content=chunk.content,
chunk_index=chunk.chunk_index,
locator=chunk.locator,
embedding_model=chunk.embedding_model,
embedding_dimension=chunk.embedding_dimension,
embedding_json=_encode_embedding(chunk.embedding_vector),
created_at=chunk.created_at,
)
@@ -700,6 +811,10 @@ def _ai_run_audit_from_model(row: AiRunAuditModel) -> AiRunAudit:
question=row.question,
answer=row.answer,
citations_count=row.citations_count,
retrieval_query=row.retrieval_query,
retrieval_strategy=row.retrieval_strategy,
retrieved_chunk_ids=_decode_json_list(row.retrieved_chunk_ids_json),
retrieved_sources=[RetrievalSource.model_validate(item) for item in _decode_json_list(row.retrieved_sources_json)],
latency_ms=row.latency_ms,
status=row.status,
created_at=_ensure_utc(row.created_at),
@@ -786,6 +901,7 @@ def split_knowledge_document(document: KnowledgeDocument, *, max_chars: int = 50
chunks: list[KnowledgeChunk] = []
for index, content in enumerate(segments, start=1):
embedding_text = f"{document.title}\n{document.reference}\n{content}"
chunks.append(
KnowledgeChunk(
id=f"chunk-{uuid4().hex[:12]}",
@@ -798,6 +914,9 @@ def split_knowledge_document(document: KnowledgeDocument, *, max_chars: int = 50
content=content,
chunk_index=index,
locator=f"chunk-{index}",
embedding_model=EMBEDDING_MODEL,
embedding_dimension=EMBEDDING_DIMENSION,
embedding_vector=_build_embedding(embedding_text),
created_at=datetime.now(UTC),
)
)
@@ -810,19 +929,48 @@ def _rank_knowledge_chunks(
*,
knowledge_base_ids: list[str] | None = None,
limit: int = 5,
strategy: str = "hybrid",
) -> list[KnowledgeSearchResult]:
allowed_base_ids = set(knowledge_base_ids or [])
retrieval_strategy = strategy if strategy in RETRIEVAL_STRATEGIES else "hybrid"
terms = _tokenize(keyword)
if keyword.strip() and not terms:
return []
query_vector = _build_embedding(keyword)
results: list[KnowledgeSearchResult] = []
for chunk in chunks:
if allowed_base_ids and chunk.knowledge_base_id not in allowed_base_ids:
continue
haystack = f"{chunk.title}\n{chunk.reference}\n{chunk.content}".lower()
matched_terms = [term for term in terms if term in haystack]
if terms and not matched_terms:
keyword_score = _score_chunk(chunk, matched_terms, terms_count=len(terms))
vector_score = _vector_score(query_vector, _chunk_embedding(chunk))
if terms and not _passes_retrieval_threshold(
keyword_score=keyword_score,
vector_score=vector_score,
strategy=retrieval_strategy,
):
continue
score = _score_chunk(chunk, matched_terms, terms_count=len(terms))
results.append(KnowledgeSearchResult(chunk=chunk, score=score, matched_terms=matched_terms))
hybrid_score = round(keyword_score * 0.55 + vector_score * 0.45, 4)
if retrieval_strategy == "keyword":
score = keyword_score
elif retrieval_strategy == "vector":
score = vector_score
else:
score = hybrid_score
if terms and score <= 0:
continue
results.append(
KnowledgeSearchResult(
chunk=chunk,
score=score,
keyword_score=keyword_score,
vector_score=vector_score,
hybrid_score=hybrid_score,
retrieval_strategy=retrieval_strategy,
matched_terms=matched_terms,
)
)
return sorted(results, key=lambda item: (item.score, item.chunk.created_at), reverse=True)[:limit]
@@ -830,8 +978,13 @@ def _tokenize(keyword: str) -> list[str]:
normalized = keyword.strip().lower()
if not normalized:
return []
terms = [term for term in normalized.replace("\n", " ").split(" ") if term]
return terms or [normalized]
latin_terms = re.findall(r"[a-z0-9]+", normalized)
cjk_chars = re.findall(r"[\u4e00-\u9fff]", normalized)
cjk_bigrams = [f"{cjk_chars[index]}{cjk_chars[index + 1]}" for index in range(len(cjk_chars) - 1)]
terms = [*latin_terms, *cjk_bigrams]
if not terms:
terms = [term for term in normalized.replace("\n", " ").split(" ") if term]
return [term for term in dict.fromkeys(terms or [normalized]) if term not in GENERIC_RETRIEVAL_TERMS]
def _score_chunk(chunk: KnowledgeChunk, matched_terms: list[str], *, terms_count: int) -> float:
@@ -844,3 +997,82 @@ def _score_chunk(chunk: KnowledgeChunk, matched_terms: list[str], *, terms_count
title_bonus = 0.2 if any(term in chunk.title.lower() for term in matched_terms) else 0.0
reference_bonus = 0.15 if any(term in chunk.reference.lower() for term in matched_terms) else 0.0
return round(min(coverage * 0.65 + density * 0.2 + title_bonus + reference_bonus, 1.0), 4)
def _passes_retrieval_threshold(
*,
keyword_score: float,
vector_score: float,
strategy: str,
) -> bool:
if strategy == "keyword":
return keyword_score > 0
if strategy == "vector":
return vector_score >= MIN_VECTOR_RECALL_SCORE
return keyword_score > 0 or vector_score >= MIN_VECTOR_RECALL_SCORE
def _build_embedding(text: str, *, dimension: int = EMBEDDING_DIMENSION) -> list[float]:
terms = _tokenize(text)
if not terms:
return []
vector = [0.0 for _ in range(dimension)]
for term in terms:
digest = hashlib.sha256(term.encode("utf-8")).digest()
bucket = int.from_bytes(digest[:4], "big") % dimension
sign = 1.0 if digest[4] % 2 == 0 else -1.0
vector[bucket] += sign
norm = math.sqrt(sum(value * value for value in vector))
if norm <= 0:
return []
return [round(value / norm, 6) for value in vector]
def _chunk_embedding(chunk: KnowledgeChunk) -> list[float]:
if chunk.embedding_vector:
return chunk.embedding_vector
return _build_embedding(f"{chunk.title}\n{chunk.reference}\n{chunk.content}")
def _vector_score(query_vector: list[float], chunk_vector: list[float]) -> float:
if not query_vector or not chunk_vector:
return 0.0
length = min(len(query_vector), len(chunk_vector))
dot = sum(query_vector[index] * chunk_vector[index] for index in range(length))
return round(max(dot, 0.0), 4)
def _encode_embedding(vector: list[float]) -> str | None:
if not vector:
return None
return json.dumps(vector, ensure_ascii=False, separators=(",", ":"))
def _decode_embedding(payload: str | None) -> list[float]:
if not payload:
return []
try:
parsed = json.loads(payload)
except json.JSONDecodeError:
return []
if not isinstance(parsed, list):
return []
values: list[float] = []
for item in parsed:
if isinstance(item, int | float):
values.append(float(item))
return values
def _encode_json_list(values: list[object]) -> str:
return json.dumps(values, ensure_ascii=False, separators=(",", ":"))
def _decode_json_list(payload: str | None) -> list[object]:
if not payload:
return []
try:
parsed = json.loads(payload)
except json.JSONDecodeError:
return []
return parsed if isinstance(parsed, list) else []
@@ -128,14 +128,20 @@ def test_knowledge_import_batch_chunks_and_scored_search() -> None:
assert chunks_response.status_code == 200
chunks = chunks_response.json()
assert chunks[0]["locator"] == "chunk-1"
assert chunks[0]["embedding_model"] == "local-hash-v1"
assert chunks[0]["embedding_dimension"] == 64
search_response = client.get(
"/api/v1/knowledge-chunks/search",
params={"keyword": "deposit refund", "knowledge_base_id": "laws-cn"},
params={"keyword": "deposit refund", "knowledge_base_id": "laws-cn", "strategy": "hybrid"},
)
assert search_response.status_code == 200
result = search_response.json()[0]
assert result["score"] > 0
assert result["retrieval_strategy"] == "hybrid"
assert result["keyword_score"] > 0
assert result["vector_score"] >= 0
assert result["hybrid_score"] == result["score"]
assert result["chunk"]["document_id"] == document["id"]
batches_response = client.get("/api/v1/knowledge-import-batches", params={"knowledge_base_id": "laws-cn"})
@@ -171,6 +177,8 @@ def test_legal_qa_uses_chunk_search_and_records_audit() -> None:
assert payload["citations"][0]["reference"].startswith("Lease-001")
assert payload["primary_sources"][0]["score"] > 0
assert payload["primary_sources"][0]["chunk_id"]
assert payload["primary_sources"][0]["retrieval_strategy"] == "hybrid"
assert payload["primary_sources"][0]["hybrid_score"] == payload["primary_sources"][0]["score"]
audit_response = client.get("/api/v1/audit/ai-runs")
assert audit_response.status_code == 200
@@ -178,6 +186,11 @@ def test_legal_qa_uses_chunk_search_and_records_audit() -> None:
assert audits[0]["operation"] == "legal_qa"
assert audits[0]["citations_count"] >= 1
assert audits[0]["prompt_template_id"] is not None
assert audits[0]["retrieval_query"] == "How should deposit refund be handled?"
assert audits[0]["retrieval_strategy"] == "hybrid"
assert audits[0]["retrieved_chunk_ids"]
assert audits[0]["retrieved_sources"][0]["reference"] == "Lease-001"
assert audits[0]["retrieved_sources"][0]["score"] > 0
assert audits[0]["status"] == "fallback"
@@ -230,6 +243,26 @@ def test_sqlalchemy_repository_persists_platform_data(tmp_path) -> None:
question="How should lease deposit refund be handled?",
answer="The contract should clearly state deposit refund conditions.",
citations_count=1,
retrieval_query="How should lease deposit refund be handled?",
retrieval_strategy="hybrid",
retrieved_chunk_ids=["chunk-test-001"],
retrieved_sources=[
{
"chunk_id": "chunk-test-001",
"document_id": "doc-test-001",
"knowledge_base_id": "laws-cn",
"title": "Civil Code",
"source_type": "law",
"reference": "Article 703",
"locator": "chunk-1",
"score": 0.8,
"keyword_score": 0.7,
"vector_score": 0.9,
"hybrid_score": 0.8,
"retrieval_strategy": "hybrid",
"matched_terms": ["lease"],
}
],
latency_ms=12,
status="succeeded",
)
@@ -241,5 +274,11 @@ def test_sqlalchemy_repository_persists_platform_data(tmp_path) -> None:
assert reloaded.list_prompt_templates()[0].version == "v2"
assert reloaded.search_knowledge("lease", knowledge_base_ids=["laws-cn"])[0].reference == "Article 703"
assert reloaded.list_knowledge_import_batches(knowledge_base_id="laws-cn")[0].documents_count == 1
assert reloaded.search_knowledge_chunks("lease", knowledge_base_ids=["laws-cn"])[0].score > 0
assert reloaded.list_ai_run_audits()[0].id == "run-test-001"
chunk_result = reloaded.search_knowledge_chunks("lease", knowledge_base_ids=["laws-cn"])[0]
assert chunk_result.score > 0
assert chunk_result.hybrid_score == chunk_result.score
assert chunk_result.chunk.embedding_vector
audit = reloaded.list_ai_run_audits()[0]
assert audit.id == "run-test-001"
assert audit.retrieved_chunk_ids == ["chunk-test-001"]
assert audit.retrieved_sources[0].reference == "Article 703"
+2 -2
View File
@@ -9,5 +9,5 @@ Initial scope:
- Typed request and response models.
- Model provider config management.
- Prompt template management.
- Knowledge import batches, document insert/search, chunk listing, and scored chunk search.
- AI run audit listing.
- Knowledge import batches, document insert/search, chunk listing, and keyword/vector/hybrid chunk search.
- AI run audit listing with retrieved chunk ids and structured source metadata.
@@ -14,6 +14,7 @@ from yuqei_sdk.ai_platform import (
PromptTemplateCreate,
ProviderConfig,
ProviderConfigCreate,
RetrievalSource,
)
from yuqei_sdk.context import RequestContext
@@ -34,4 +35,5 @@ __all__ = [
"ProviderConfig",
"ProviderConfigCreate",
"RequestContext",
"RetrievalSource",
]
@@ -10,6 +10,7 @@ class LegalQaRequest(BaseModel):
question: str = Field(min_length=1)
matter_context: str | None = None
knowledge_base_ids: list[str] = Field(default_factory=list)
retrieval_strategy: str = "hybrid"
class LegalCitation(BaseModel):
@@ -23,6 +24,11 @@ class LegalCitation(BaseModel):
document_id: str | None = None
chunk_id: str | None = None
knowledge_base_id: str | None = None
keyword_score: float | None = None
vector_score: float | None = None
hybrid_score: float | None = None
retrieval_strategy: str | None = None
matched_terms: list[str] = Field(default_factory=list)
class LegalQaResponse(BaseModel):
@@ -101,12 +107,34 @@ class KnowledgeChunk(BaseModel):
content: str
chunk_index: int
locator: str
embedding_model: str = "local-hash-v1"
embedding_dimension: int = 64
created_at: str
class KnowledgeSearchResult(BaseModel):
chunk: KnowledgeChunk
score: float
keyword_score: float = 0.0
vector_score: float = 0.0
hybrid_score: float = 0.0
retrieval_strategy: str = "hybrid"
matched_terms: list[str] = Field(default_factory=list)
class RetrievalSource(BaseModel):
chunk_id: str | None = None
document_id: str | None = None
knowledge_base_id: str | None = None
title: str
source_type: str
reference: str
locator: str | None = None
score: float = 0.0
keyword_score: float = 0.0
vector_score: float = 0.0
hybrid_score: float = 0.0
retrieval_strategy: str = "hybrid"
matched_terms: list[str] = Field(default_factory=list)
@@ -118,6 +146,10 @@ class AiRunAudit(BaseModel):
question: str | None = None
answer: str | None = None
citations_count: int = 0
retrieval_query: str | None = None
retrieval_strategy: str | None = None
retrieved_chunk_ids: list[str] = Field(default_factory=list)
retrieved_sources: list[RetrievalSource] = Field(default_factory=list)
latency_ms: int = 0
status: str
created_at: str
@@ -291,9 +323,10 @@ class AiPlatformClient:
*,
knowledge_base_id: str | None = None,
limit: int = 5,
strategy: str = "hybrid",
context: RequestContext | None = None,
) -> list[KnowledgeSearchResult]:
params: dict[str, Any] = {"keyword": keyword, "limit": limit}
params: dict[str, Any] = {"keyword": keyword, "limit": limit, "strategy": strategy}
if knowledge_base_id:
params["knowledge_base_id"] = knowledge_base_id
response = self._client.get(
+48 -2
View File
@@ -34,6 +34,11 @@ def test_ai_platform_client_posts_legal_qa_with_context_headers() -> None:
"document_id": "doc-1",
"chunk_id": "chunk-1",
"knowledge_base_id": "laws-cn",
"keyword_score": 0.78,
"vector_score": 0.87,
"hybrid_score": 0.82,
"retrieval_strategy": "hybrid",
"matched_terms": ["probation"],
}
],
"primary_sources": [
@@ -47,6 +52,11 @@ def test_ai_platform_client_posts_legal_qa_with_context_headers() -> None:
"document_id": "doc-1",
"chunk_id": "chunk-1",
"knowledge_base_id": "laws-cn",
"keyword_score": 0.78,
"vector_score": 0.87,
"hybrid_score": 0.82,
"retrieval_strategy": "hybrid",
"matched_terms": ["probation"],
}
],
"confidence": 0.72,
@@ -65,6 +75,8 @@ def test_ai_platform_client_posts_legal_qa_with_context_headers() -> None:
assert response.citations[0].reference == "Article 19 chunk-1"
assert response.primary_sources[0].score == 0.82
assert response.primary_sources[0].hybrid_score == 0.82
assert response.primary_sources[0].retrieval_strategy == "hybrid"
assert captured_request is not None
assert captured_request.url.path == "/api/v1/legal/qa"
assert captured_request.headers["X-Trace-Id"] == "trace-1"
@@ -199,6 +211,8 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
"content": "A lease contract defines rent and use of leased property.",
"chunk_index": 1,
"locator": "chunk-1",
"embedding_model": "local-hash-v1",
"embedding_dimension": 64,
"created_at": "2026-06-22T00:00:00Z",
}
],
@@ -239,9 +253,15 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
"content": "A lease contract defines rent and use of leased property.",
"chunk_index": 1,
"locator": "chunk-1",
"embedding_model": "local-hash-v1",
"embedding_dimension": 64,
"created_at": "2026-06-22T00:00:00Z",
},
"score": 0.82,
"keyword_score": 0.76,
"vector_score": 0.89,
"hybrid_score": 0.82,
"retrieval_strategy": "hybrid",
"matched_terms": ["lease"],
}
],
@@ -258,6 +278,26 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
"question": "How should lease deposit refund be handled?",
"answer": "The contract should clearly state deposit refund conditions.",
"citations_count": 1,
"retrieval_query": "How should lease deposit refund be handled?",
"retrieval_strategy": "hybrid",
"retrieved_chunk_ids": ["chunk-1"],
"retrieved_sources": [
{
"chunk_id": "chunk-1",
"document_id": "doc-1",
"knowledge_base_id": "laws-cn",
"title": "Civil Code",
"source_type": "law",
"reference": "Article 703",
"locator": "chunk-1",
"score": 0.82,
"keyword_score": 0.76,
"vector_score": 0.89,
"hybrid_score": 0.82,
"retrieval_strategy": "hybrid",
"matched_terms": ["lease"],
}
],
"latency_ms": 12,
"status": "succeeded",
"created_at": "2026-06-22T00:00:00Z",
@@ -301,7 +341,13 @@ def test_ai_platform_client_covers_admin_data_endpoints() -> None:
)
).reference == "Article 703"
assert client.list_knowledge_chunks("doc-1")[0].locator == "chunk-1"
assert client.list_knowledge_chunks("doc-1")[0].embedding_model == "local-hash-v1"
assert client.search_knowledge_documents("lease", limit=3)[0].title == "Civil Code"
assert client.search_knowledge_chunks("lease", limit=3)[0].score == 0.82
assert client.list_ai_run_audits(limit=5)[0].operation == "legal_qa"
chunk_result = client.search_knowledge_chunks("lease", limit=3)
assert chunk_result[0].score == 0.82
assert chunk_result[0].hybrid_score == 0.82
audit = client.list_ai_run_audits(limit=5)[0]
assert audit.operation == "legal_qa"
assert audit.retrieved_chunk_ids == ["chunk-1"]
assert audit.retrieved_sources[0].reference == "Article 703"
assert "/api/v1/audit/ai-runs" in seen_paths