Upload 11 files
Browse files- API_SPEC.md +30 -0
- Dockerfile +9 -0
- LICENSE +21 -0
- README.md +9 -3
- app.py +1 -0
- requirements.txt +5 -0
- src/fuzzy_langchain/__init__.py +17 -0
- src/fuzzy_langchain/api.py +63 -0
- src/fuzzy_langchain/core.py +307 -0
- src/fuzzy_langchain/rag.py +93 -0
- src/fuzzy_langchain/retriever.py +63 -0
API_SPEC.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Fuzzy LangChain API
|
| 2 |
+
|
| 3 |
+
## GET /health
|
| 4 |
+
Returns service status and number of ingested documents.
|
| 5 |
+
|
| 6 |
+
## POST /ingest
|
| 7 |
+
```json
|
| 8 |
+
{
|
| 9 |
+
"replace": true,
|
| 10 |
+
"documents": [
|
| 11 |
+
{
|
| 12 |
+
"id": "doc-1",
|
| 13 |
+
"text": "The current access code is BRAVO-9.",
|
| 14 |
+
"title": "Current notice",
|
| 15 |
+
"source": "official",
|
| 16 |
+
"authority": 0.98,
|
| 17 |
+
"freshness": 1.0,
|
| 18 |
+
"reliability": 0.95,
|
| 19 |
+
"metadata": {}
|
| 20 |
+
}
|
| 21 |
+
]
|
| 22 |
+
}
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
## POST /query
|
| 26 |
+
```json
|
| 27 |
+
{"question":"What is the current access code?","k":4}
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
The response contains `answer`, `citations`, `confidence`, `margin`, `decision`, `reason`, and ranked `evidence`.
|
Dockerfile
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY requirements.txt .
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
COPY src ./src
|
| 6 |
+
COPY app.py .
|
| 7 |
+
ENV PYTHONPATH=/app/src
|
| 8 |
+
EXPOSE 7860
|
| 9 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Angshul Majumdar
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Fuzzy LangChain — Hugging Face API
|
| 2 |
+
|
| 3 |
+
Minimal deployable API for the Fuzzy LangChain RAG runtime. This package intentionally contains **only the serving surface and the minimum runtime needed by it**. Benchmarks, experiments, tests, and research documentation belong in the GitHub RAG repository.
|
| 4 |
+
|
| 5 |
+
Endpoints: `GET /health`, `POST /ingest`, `POST /query`.
|
| 6 |
+
|
| 7 |
+
The query path runs through the actual LangChain `BaseRetriever` implementation, fuzzy evidence assessment, optional retrieval expansion, grounded answer generation, citation return, and abstention under insufficient or ambiguous evidence.
|
| 8 |
+
|
| 9 |
+
See `API_SPEC.md` for request/response examples.
|
app.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from fuzzy_langchain.api import app
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.110
|
| 2 |
+
uvicorn>=0.27
|
| 3 |
+
langchain-core>=1.4.7,<2
|
| 4 |
+
numpy>=1.24
|
| 5 |
+
scikit-learn>=1.3
|
src/fuzzy_langchain/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .core import (
|
| 2 |
+
DocumentRecord,
|
| 3 |
+
FuzzyEvidence,
|
| 4 |
+
FuzzyEvidenceController,
|
| 5 |
+
FuzzyIndex,
|
| 6 |
+
FuzzyRAGEngine,
|
| 7 |
+
FuzzyRetrievalConfig,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"DocumentRecord",
|
| 12 |
+
"FuzzyEvidence",
|
| 13 |
+
"FuzzyEvidenceController",
|
| 14 |
+
"FuzzyIndex",
|
| 15 |
+
"FuzzyRAGEngine",
|
| 16 |
+
"FuzzyRetrievalConfig",
|
| 17 |
+
]
|
src/fuzzy_langchain/api.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
from fastapi import FastAPI, HTTPException
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
from .core import DocumentRecord, FuzzyIndex, FuzzyRetrievalConfig
|
| 8 |
+
from .rag import LangChainFuzzyRAG
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class IngestDocument(BaseModel):
|
| 12 |
+
id: str
|
| 13 |
+
text: str
|
| 14 |
+
title: str = ""
|
| 15 |
+
source: str = ""
|
| 16 |
+
authority: float = Field(0.5, ge=0.0, le=1.0)
|
| 17 |
+
freshness: float = Field(0.5, ge=0.0, le=1.0)
|
| 18 |
+
reliability: float = Field(0.5, ge=0.0, le=1.0)
|
| 19 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class IngestRequest(BaseModel):
|
| 23 |
+
documents: list[IngestDocument]
|
| 24 |
+
replace: bool = True
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class QueryRequest(BaseModel):
|
| 28 |
+
question: str
|
| 29 |
+
k: int = Field(4, ge=1, le=20)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def create_app() -> FastAPI:
|
| 33 |
+
app = FastAPI(title="Fuzzy LangChain RAG API", version="1.0.0")
|
| 34 |
+
holder: dict[str, Any] = {"index": FuzzyIndex(config=FuzzyRetrievalConfig())}
|
| 35 |
+
|
| 36 |
+
@app.get("/health")
|
| 37 |
+
def health():
|
| 38 |
+
return {"status": "ok", "documents": len(holder["index"].documents)}
|
| 39 |
+
|
| 40 |
+
@app.post("/ingest")
|
| 41 |
+
def ingest(req: IngestRequest):
|
| 42 |
+
docs: list[DocumentRecord] = []
|
| 43 |
+
for d in req.documents:
|
| 44 |
+
md = dict(d.metadata)
|
| 45 |
+
md.update({"authority": d.authority, "freshness": d.freshness, "reliability": d.reliability})
|
| 46 |
+
docs.append(DocumentRecord(d.id, d.text, d.title, d.source, md))
|
| 47 |
+
if req.replace:
|
| 48 |
+
holder["index"].fit(docs)
|
| 49 |
+
else:
|
| 50 |
+
holder["index"].add(docs)
|
| 51 |
+
return {"documents": len(holder["index"].documents)}
|
| 52 |
+
|
| 53 |
+
@app.post("/query")
|
| 54 |
+
def query(req: QueryRequest):
|
| 55 |
+
if not holder["index"].documents:
|
| 56 |
+
raise HTTPException(status_code=400, detail="No documents ingested")
|
| 57 |
+
rag = LangChainFuzzyRAG(holder["index"], k=req.k)
|
| 58 |
+
return rag.invoke(req.question)
|
| 59 |
+
|
| 60 |
+
return app
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
app = create_app()
|
src/fuzzy_langchain/core.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import Any, Iterable, Sequence
|
| 5 |
+
import math
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 10 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(frozen=True)
|
| 14 |
+
class DocumentRecord:
|
| 15 |
+
id: str
|
| 16 |
+
text: str
|
| 17 |
+
title: str = ""
|
| 18 |
+
source: str = ""
|
| 19 |
+
metadata: dict[str, Any] = field(default_factory=dict)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class FuzzyRetrievalConfig:
|
| 24 |
+
candidate_pool: int = 10
|
| 25 |
+
answer_threshold: float = 0.60
|
| 26 |
+
ambiguity_margin: float = 0.015
|
| 27 |
+
min_raw_relevance: float = 0.14
|
| 28 |
+
expand_threshold: float = 0.48
|
| 29 |
+
extended_pool: int = 16
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass(frozen=True)
|
| 33 |
+
class FuzzyEvidence:
|
| 34 |
+
document: DocumentRecord
|
| 35 |
+
raw_relevance: float
|
| 36 |
+
fuzzy_score: float
|
| 37 |
+
authority: float
|
| 38 |
+
freshness: float
|
| 39 |
+
reliability: float
|
| 40 |
+
memberships: dict[str, float]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class FuzzyEvidenceController:
|
| 44 |
+
"""Small dependency-free Sugeno fuzzy controller for RAG evidence quality.
|
| 45 |
+
|
| 46 |
+
Inputs are normalized to [0, 1]: lexical/semantic relevance, source authority,
|
| 47 |
+
freshness, and source reliability. The rules intentionally make relevance a
|
| 48 |
+
gate: highly trusted but irrelevant evidence must not become high confidence.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
@staticmethod
|
| 52 |
+
def _clip(x: float) -> float:
|
| 53 |
+
return max(0.0, min(1.0, float(x)))
|
| 54 |
+
|
| 55 |
+
@classmethod
|
| 56 |
+
def _left(cls, x: float, full_until: float, zero_at: float) -> float:
|
| 57 |
+
x = cls._clip(x)
|
| 58 |
+
if x <= full_until:
|
| 59 |
+
return 1.0
|
| 60 |
+
if x >= zero_at:
|
| 61 |
+
return 0.0
|
| 62 |
+
return (zero_at - x) / (zero_at - full_until)
|
| 63 |
+
|
| 64 |
+
@classmethod
|
| 65 |
+
def _right(cls, x: float, zero_until: float, full_at: float) -> float:
|
| 66 |
+
x = cls._clip(x)
|
| 67 |
+
if x <= zero_until:
|
| 68 |
+
return 0.0
|
| 69 |
+
if x >= full_at:
|
| 70 |
+
return 1.0
|
| 71 |
+
return (x - zero_until) / (full_at - zero_until)
|
| 72 |
+
|
| 73 |
+
@classmethod
|
| 74 |
+
def _triangle(cls, x: float, left: float, peak: float, right: float) -> float:
|
| 75 |
+
x = cls._clip(x)
|
| 76 |
+
if x <= left or x >= right:
|
| 77 |
+
return 0.0
|
| 78 |
+
if x == peak:
|
| 79 |
+
return 1.0
|
| 80 |
+
if x < peak:
|
| 81 |
+
return (x - left) / (peak - left)
|
| 82 |
+
return (right - x) / (right - peak)
|
| 83 |
+
|
| 84 |
+
def memberships(self, relevance: float, authority: float, freshness: float, reliability: float) -> dict[str, float]:
|
| 85 |
+
r = self._clip(relevance)
|
| 86 |
+
a = self._clip(authority)
|
| 87 |
+
f = self._clip(freshness)
|
| 88 |
+
q = self._clip(reliability)
|
| 89 |
+
return {
|
| 90 |
+
"rel_low": self._left(r, 0.05, 0.30),
|
| 91 |
+
"rel_med": self._triangle(r, 0.12, 0.42, 0.70),
|
| 92 |
+
"rel_high": self._right(r, 0.42, 0.72),
|
| 93 |
+
"auth_low": self._left(a, 0.0, 0.55),
|
| 94 |
+
"auth_med": self._triangle(a, 0.35, 0.62, 0.84),
|
| 95 |
+
"auth_high": self._right(a, 0.68, 0.90),
|
| 96 |
+
"fresh_low": self._left(f, 0.0, 0.42),
|
| 97 |
+
"fresh_med": self._triangle(f, 0.28, 0.58, 0.82),
|
| 98 |
+
"fresh_high": self._right(f, 0.68, 0.90),
|
| 99 |
+
"reliability_low": self._left(q, 0.0, 0.50),
|
| 100 |
+
"reliability_high": self._right(q, 0.58, 0.88),
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
def score(self, relevance: float, authority: float, freshness: float, reliability: float) -> tuple[float, dict[str, float]]:
|
| 104 |
+
m = self.memberships(relevance, authority, freshness, reliability)
|
| 105 |
+
# First-order Sugeno rules. Consequents are confidence-like evidence values.
|
| 106 |
+
rules: list[tuple[float, float]] = [
|
| 107 |
+
(min(m["rel_high"], m["auth_high"], m["fresh_high"], m["reliability_high"]), 0.98),
|
| 108 |
+
(min(m["rel_med"], m["auth_high"], m["fresh_high"], m["reliability_high"]), 0.90),
|
| 109 |
+
(min(m["rel_high"], m["auth_high"]), 0.92),
|
| 110 |
+
(min(m["rel_med"], m["auth_high"]), 0.84),
|
| 111 |
+
(min(m["rel_high"], m["auth_med"], m["fresh_high"]), 0.74),
|
| 112 |
+
(min(m["rel_med"], m["auth_med"]), 0.54),
|
| 113 |
+
(min(m["rel_high"], m["fresh_low"]), 0.28),
|
| 114 |
+
(min(m["rel_high"], m["auth_low"]), 0.24),
|
| 115 |
+
(min(m["rel_med"], m["auth_low"]), 0.20),
|
| 116 |
+
(min(m["rel_high"], m["reliability_low"]), 0.26),
|
| 117 |
+
(m["rel_low"], 0.06),
|
| 118 |
+
]
|
| 119 |
+
denom = sum(w for w, _ in rules)
|
| 120 |
+
if denom <= 1e-12:
|
| 121 |
+
# No fuzzy rule fired strongly; relevance still gates the fallback.
|
| 122 |
+
base = 0.10 + 0.55 * self._clip(relevance)
|
| 123 |
+
else:
|
| 124 |
+
base = sum(w * z for w, z in rules) / denom
|
| 125 |
+
# Relevance is already a fuzzy antecedent and is also enforced by the
|
| 126 |
+
# hard minimum-relevance guard in the final decision.
|
| 127 |
+
value = self._clip(base)
|
| 128 |
+
return value, m
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class FuzzyIndex:
|
| 132 |
+
def __init__(self, documents: Sequence[DocumentRecord] | None = None, config: FuzzyRetrievalConfig | None = None):
|
| 133 |
+
self.config = config or FuzzyRetrievalConfig()
|
| 134 |
+
self.controller = FuzzyEvidenceController()
|
| 135 |
+
self.documents: list[DocumentRecord] = []
|
| 136 |
+
self._vectorizer: TfidfVectorizer | None = None
|
| 137 |
+
self._matrix = None
|
| 138 |
+
if documents:
|
| 139 |
+
self.fit(documents)
|
| 140 |
+
|
| 141 |
+
@staticmethod
|
| 142 |
+
def _trust(doc: DocumentRecord) -> tuple[float, float, float]:
|
| 143 |
+
md = doc.metadata or {}
|
| 144 |
+
authority = float(md.get("authority", 0.50))
|
| 145 |
+
freshness = float(md.get("freshness", 0.50))
|
| 146 |
+
if "reliability" in md:
|
| 147 |
+
reliability = float(md["reliability"])
|
| 148 |
+
elif "reliability_alpha" in md or "reliability_beta" in md:
|
| 149 |
+
a = float(md.get("reliability_alpha", 1.0))
|
| 150 |
+
b = float(md.get("reliability_beta", 1.0))
|
| 151 |
+
reliability = a / max(a + b, 1e-12)
|
| 152 |
+
else:
|
| 153 |
+
reliability = 0.50
|
| 154 |
+
return tuple(max(0.0, min(1.0, x)) for x in (authority, freshness, reliability))
|
| 155 |
+
|
| 156 |
+
def fit(self, documents: Sequence[DocumentRecord]) -> "FuzzyIndex":
|
| 157 |
+
self.documents = list(documents)
|
| 158 |
+
corpus = [f"{d.title}. {d.text}".strip() for d in self.documents]
|
| 159 |
+
self._vectorizer = TfidfVectorizer(ngram_range=(1, 2), stop_words="english", sublinear_tf=True)
|
| 160 |
+
self._matrix = self._vectorizer.fit_transform(corpus) if corpus else None
|
| 161 |
+
return self
|
| 162 |
+
|
| 163 |
+
def add(self, documents: Iterable[DocumentRecord]) -> "FuzzyIndex":
|
| 164 |
+
return self.fit([*self.documents, *list(documents)])
|
| 165 |
+
|
| 166 |
+
def search(self, query: str, k: int = 4, candidate_pool: int | None = None) -> list[FuzzyEvidence]:
|
| 167 |
+
if not self.documents or self._vectorizer is None or self._matrix is None:
|
| 168 |
+
return []
|
| 169 |
+
q = self._vectorizer.transform([query])
|
| 170 |
+
sims = cosine_similarity(q, self._matrix)[0]
|
| 171 |
+
pool = min(candidate_pool or self.config.candidate_pool, len(self.documents))
|
| 172 |
+
candidate_ids = np.argsort(-sims, kind="stable")[:pool]
|
| 173 |
+
hits: list[FuzzyEvidence] = []
|
| 174 |
+
for i in candidate_ids:
|
| 175 |
+
doc = self.documents[int(i)]
|
| 176 |
+
relevance = float(sims[int(i)])
|
| 177 |
+
authority, freshness, reliability = self._trust(doc)
|
| 178 |
+
fuzzy, memberships = self.controller.score(relevance, authority, freshness, reliability)
|
| 179 |
+
hits.append(FuzzyEvidence(doc, relevance, fuzzy, authority, freshness, reliability, memberships))
|
| 180 |
+
hits.sort(key=lambda h: (h.fuzzy_score, h.raw_relevance, h.authority, h.freshness), reverse=True)
|
| 181 |
+
return hits[: max(1, k)]
|
| 182 |
+
|
| 183 |
+
def decision(self, hits: Sequence[FuzzyEvidence]) -> dict[str, Any]:
|
| 184 |
+
if not hits:
|
| 185 |
+
return {"route": "abstain", "confidence": 0.0, "margin": 0.0, "reason": "no_evidence"}
|
| 186 |
+
top = hits[0]
|
| 187 |
+
second = hits[1] if len(hits) > 1 else None
|
| 188 |
+
if second is None:
|
| 189 |
+
margin = top.fuzzy_score
|
| 190 |
+
else:
|
| 191 |
+
# Fuzzy confidence can intentionally plateau for equally trustworthy
|
| 192 |
+
# sources. A small relevance component breaks benign ties while exact
|
| 193 |
+
# equal-authority/equal-relevance contradictions remain ambiguous.
|
| 194 |
+
margin = 0.80 * (top.fuzzy_score - second.fuzzy_score) + 0.20 * (top.raw_relevance - second.raw_relevance)
|
| 195 |
+
if top.raw_relevance < self.config.min_raw_relevance:
|
| 196 |
+
return {"route": "abstain", "confidence": top.fuzzy_score, "margin": margin, "reason": "low_relevance"}
|
| 197 |
+
trusted_enough = top.authority >= 0.60 or top.reliability >= 0.75
|
| 198 |
+
if top.fuzzy_score >= self.config.answer_threshold and margin >= self.config.ambiguity_margin and trusted_enough:
|
| 199 |
+
return {"route": "generate", "confidence": top.fuzzy_score, "margin": margin, "reason": "strong_evidence"}
|
| 200 |
+
if top.fuzzy_score >= self.config.expand_threshold:
|
| 201 |
+
return {"route": "expand", "confidence": top.fuzzy_score, "margin": margin, "reason": "borderline_or_ambiguous"}
|
| 202 |
+
return {"route": "abstain", "confidence": top.fuzzy_score, "margin": margin, "reason": "weak_evidence"}
|
| 203 |
+
|
| 204 |
+
def should_abstain(self, hits: Sequence[FuzzyEvidence]) -> bool:
|
| 205 |
+
return self.decision(hits)["route"] != "generate"
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
class PlainTfidfIndex:
|
| 209 |
+
"""Baseline retriever intentionally unaware of source quality or uncertainty."""
|
| 210 |
+
|
| 211 |
+
def __init__(self, documents: Sequence[DocumentRecord]):
|
| 212 |
+
self.documents = list(documents)
|
| 213 |
+
corpus = [f"{d.title}. {d.text}".strip() for d in self.documents]
|
| 214 |
+
self.vectorizer = TfidfVectorizer(ngram_range=(1, 2), stop_words="english", sublinear_tf=True)
|
| 215 |
+
self.matrix = self.vectorizer.fit_transform(corpus)
|
| 216 |
+
|
| 217 |
+
def search(self, query: str, k: int = 4) -> list[FuzzyEvidence]:
|
| 218 |
+
q = self.vectorizer.transform([query])
|
| 219 |
+
sims = cosine_similarity(q, self.matrix)[0]
|
| 220 |
+
ids = np.argsort(-sims, kind="stable")[: min(k, len(self.documents))]
|
| 221 |
+
out = []
|
| 222 |
+
for i in ids:
|
| 223 |
+
d = self.documents[int(i)]
|
| 224 |
+
s = float(sims[int(i)])
|
| 225 |
+
out.append(FuzzyEvidence(d, s, s, 0.5, 0.5, 0.5, {}))
|
| 226 |
+
return out
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
class ExtractiveGroundedGenerator:
|
| 230 |
+
"""Offline answer synthesizer used for reproducible RAG evaluation.
|
| 231 |
+
|
| 232 |
+
It selects the query-most-relevant sentence from the accepted evidence and
|
| 233 |
+
returns it with an explicit citation. Production users can replace this with
|
| 234 |
+
any LangChain Runnable/LLM without changing fuzzy retrieval or routing.
|
| 235 |
+
"""
|
| 236 |
+
|
| 237 |
+
abstain_text = "Insufficient reliable evidence to answer."
|
| 238 |
+
|
| 239 |
+
@staticmethod
|
| 240 |
+
def _sentences(text: str) -> list[str]:
|
| 241 |
+
parts = re.split(r"(?<=[.!?])\s+", text.strip())
|
| 242 |
+
return [p.strip() for p in parts if p.strip()]
|
| 243 |
+
|
| 244 |
+
def generate(self, question: str, hits: Sequence[FuzzyEvidence]) -> tuple[str, list[str]]:
|
| 245 |
+
if not hits:
|
| 246 |
+
return self.abstain_text, []
|
| 247 |
+
top = hits[0]
|
| 248 |
+
sentences = self._sentences(top.document.text) or [top.document.text]
|
| 249 |
+
if len(sentences) == 1:
|
| 250 |
+
best = sentences[0]
|
| 251 |
+
else:
|
| 252 |
+
vec = TfidfVectorizer(ngram_range=(1, 2), stop_words="english")
|
| 253 |
+
X = vec.fit_transform([question, *sentences])
|
| 254 |
+
scores = cosine_similarity(X[0:1], X[1:])[0]
|
| 255 |
+
best = sentences[int(np.argmax(scores))]
|
| 256 |
+
return f"{best} [{top.document.id}]", [top.document.id]
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
class FuzzyRAGEngine:
|
| 260 |
+
def __init__(self, documents: Sequence[DocumentRecord] | None = None, config: FuzzyRetrievalConfig | None = None):
|
| 261 |
+
self.index = FuzzyIndex(documents or [], config)
|
| 262 |
+
self.generator = ExtractiveGroundedGenerator()
|
| 263 |
+
|
| 264 |
+
def ingest(self, documents: Sequence[DocumentRecord], replace: bool = True) -> int:
|
| 265 |
+
if replace:
|
| 266 |
+
self.index.fit(documents)
|
| 267 |
+
else:
|
| 268 |
+
self.index.add(documents)
|
| 269 |
+
return len(self.index.documents)
|
| 270 |
+
|
| 271 |
+
def query(self, question: str, k: int = 4) -> dict[str, Any]:
|
| 272 |
+
hits = self.index.search(question, k=k)
|
| 273 |
+
decision = self.index.decision(hits)
|
| 274 |
+
expanded = False
|
| 275 |
+
if decision["route"] == "expand":
|
| 276 |
+
expanded = True
|
| 277 |
+
hits = self.index.search(question, k=max(k * 2, 6), candidate_pool=self.index.config.extended_pool)
|
| 278 |
+
decision = self.index.decision(hits)
|
| 279 |
+
if decision["route"] == "expand":
|
| 280 |
+
decision = {**decision, "route": "abstain", "reason": "ambiguous_after_expansion"}
|
| 281 |
+
if decision["route"] == "generate":
|
| 282 |
+
answer, citations = self.generator.generate(question, hits)
|
| 283 |
+
else:
|
| 284 |
+
answer, citations = self.generator.abstain_text, []
|
| 285 |
+
return {
|
| 286 |
+
"question": question,
|
| 287 |
+
"answer": answer,
|
| 288 |
+
"citations": citations,
|
| 289 |
+
"confidence": round(float(decision["confidence"]), 6),
|
| 290 |
+
"margin": round(float(decision["margin"]), 6),
|
| 291 |
+
"decision": decision["route"],
|
| 292 |
+
"reason": decision["reason"],
|
| 293 |
+
"expanded": expanded,
|
| 294 |
+
"evidence": [
|
| 295 |
+
{
|
| 296 |
+
"id": h.document.id,
|
| 297 |
+
"title": h.document.title,
|
| 298 |
+
"source": h.document.source,
|
| 299 |
+
"raw_relevance": round(h.raw_relevance, 6),
|
| 300 |
+
"fuzzy_score": round(h.fuzzy_score, 6),
|
| 301 |
+
"authority": round(h.authority, 6),
|
| 302 |
+
"freshness": round(h.freshness, 6),
|
| 303 |
+
"reliability": round(h.reliability, 6),
|
| 304 |
+
}
|
| 305 |
+
for h in hits
|
| 306 |
+
],
|
| 307 |
+
}
|
src/fuzzy_langchain/rag.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Callable
|
| 4 |
+
|
| 5 |
+
from .core import ExtractiveGroundedGenerator, FuzzyIndex, FuzzyRetrievalConfig
|
| 6 |
+
from .retriever import FuzzyRetriever
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class LangChainFuzzyRAG:
|
| 10 |
+
"""End-to-end LangChain RAG using fuzzy evidence control.
|
| 11 |
+
|
| 12 |
+
The optional ``generator`` receives ``(question, documents)`` and may be any
|
| 13 |
+
callable backed by an LLM. Without one, a deterministic grounded sentence
|
| 14 |
+
synthesizer is used for offline evaluation and zero-key deployments.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, index: FuzzyIndex, k: int = 4, generator: Callable[[str, list[Any]], str] | None = None):
|
| 18 |
+
self.index = index
|
| 19 |
+
self.retriever = FuzzyRetriever(index=index, k=k)
|
| 20 |
+
self.k = k
|
| 21 |
+
self.generator = generator
|
| 22 |
+
self.extractive = ExtractiveGroundedGenerator()
|
| 23 |
+
|
| 24 |
+
def invoke(self, question: str) -> dict[str, Any]:
|
| 25 |
+
docs = self.retriever.invoke(question)
|
| 26 |
+
# Crucially, decision logic uses the LangChain retriever result rather than
|
| 27 |
+
# silently issuing a second retrieval outside the framework path.
|
| 28 |
+
from .core import DocumentRecord, FuzzyEvidence
|
| 29 |
+
|
| 30 |
+
hits: list[FuzzyEvidence] = []
|
| 31 |
+
for d in docs:
|
| 32 |
+
md = dict(d.metadata or {})
|
| 33 |
+
record = DocumentRecord(
|
| 34 |
+
id=str(md.get("id", "unknown")),
|
| 35 |
+
text=d.page_content,
|
| 36 |
+
title=str(md.get("title", "")),
|
| 37 |
+
source=str(md.get("source", "")),
|
| 38 |
+
metadata=md,
|
| 39 |
+
)
|
| 40 |
+
hits.append(
|
| 41 |
+
FuzzyEvidence(
|
| 42 |
+
document=record,
|
| 43 |
+
raw_relevance=float(md.get("raw_relevance", 0.0)),
|
| 44 |
+
fuzzy_score=float(md.get("fuzzy_score", 0.0)),
|
| 45 |
+
authority=float(md.get("authority", 0.5)),
|
| 46 |
+
freshness=float(md.get("freshness", 0.5)),
|
| 47 |
+
reliability=float(md.get("reliability", 0.5)),
|
| 48 |
+
memberships={},
|
| 49 |
+
)
|
| 50 |
+
)
|
| 51 |
+
decision = self.index.decision(hits)
|
| 52 |
+
if decision["route"] == "expand":
|
| 53 |
+
expanded_retriever = FuzzyRetriever(index=self.index, k=max(self.k * 2, 6), candidate_pool=self.index.config.extended_pool)
|
| 54 |
+
docs = expanded_retriever.invoke(question)
|
| 55 |
+
hits = []
|
| 56 |
+
for d in docs:
|
| 57 |
+
md = dict(d.metadata or {})
|
| 58 |
+
record = DocumentRecord(
|
| 59 |
+
id=str(md.get("id", "unknown")), text=d.page_content,
|
| 60 |
+
title=str(md.get("title", "")), source=str(md.get("source", "")), metadata=md
|
| 61 |
+
)
|
| 62 |
+
hits.append(FuzzyEvidence(
|
| 63 |
+
record, float(md.get("raw_relevance", 0.0)), float(md.get("fuzzy_score", 0.0)),
|
| 64 |
+
float(md.get("authority", 0.5)), float(md.get("freshness", 0.5)),
|
| 65 |
+
float(md.get("reliability", 0.5)), {}
|
| 66 |
+
))
|
| 67 |
+
decision = self.index.decision(hits)
|
| 68 |
+
if decision["route"] == "expand":
|
| 69 |
+
decision = {**decision, "route": "abstain", "reason": "ambiguous_after_expansion"}
|
| 70 |
+
if decision["route"] != "generate":
|
| 71 |
+
answer, citations = self.extractive.abstain_text, []
|
| 72 |
+
elif self.generator is not None:
|
| 73 |
+
answer = str(self.generator(question, docs))
|
| 74 |
+
citations = [str(d.metadata.get("id")) for d in docs[:1]]
|
| 75 |
+
else:
|
| 76 |
+
answer, citations = self.extractive.generate(question, hits)
|
| 77 |
+
return {
|
| 78 |
+
"question": question,
|
| 79 |
+
"answer": answer,
|
| 80 |
+
"citations": citations,
|
| 81 |
+
"confidence": float(decision["confidence"]),
|
| 82 |
+
"margin": float(decision["margin"]),
|
| 83 |
+
"decision": decision["route"],
|
| 84 |
+
"reason": decision["reason"],
|
| 85 |
+
"evidence": [dict(d.metadata) for d in docs],
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
def as_runnable(self):
|
| 89 |
+
try:
|
| 90 |
+
from langchain_core.runnables import RunnableLambda
|
| 91 |
+
except Exception as exc: # pragma: no cover
|
| 92 |
+
raise RuntimeError("langchain-core is required for as_runnable()") from exc
|
| 93 |
+
return RunnableLambda(lambda x: self.invoke(x["question"] if isinstance(x, dict) else str(x)))
|
src/fuzzy_langchain/retriever.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from .core import DocumentRecord, FuzzyIndex
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
from langchain_core.documents import Document
|
| 9 |
+
from langchain_core.retrievers import BaseRetriever
|
| 10 |
+
from pydantic import ConfigDict
|
| 11 |
+
except Exception: # pragma: no cover - package remains importable without LangChain
|
| 12 |
+
Document = None
|
| 13 |
+
BaseRetriever = object
|
| 14 |
+
ConfigDict = dict
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class FuzzyRetriever(BaseRetriever):
|
| 18 |
+
"""LangChain BaseRetriever adapter around :class:`FuzzyIndex`."""
|
| 19 |
+
|
| 20 |
+
if BaseRetriever is not object:
|
| 21 |
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
| 22 |
+
|
| 23 |
+
index: Any
|
| 24 |
+
k: int = 4
|
| 25 |
+
candidate_pool: int | None = None
|
| 26 |
+
|
| 27 |
+
def _get_relevant_documents(self, query: str, *, run_manager: Any = None):
|
| 28 |
+
if Document is None:
|
| 29 |
+
raise RuntimeError("langchain-core is required for FuzzyRetriever")
|
| 30 |
+
hits = self.index.search(query, k=self.k, candidate_pool=self.candidate_pool)
|
| 31 |
+
docs = []
|
| 32 |
+
for h in hits:
|
| 33 |
+
md = dict(h.document.metadata)
|
| 34 |
+
md.update(
|
| 35 |
+
{
|
| 36 |
+
"id": h.document.id,
|
| 37 |
+
"title": h.document.title,
|
| 38 |
+
"source": h.document.source,
|
| 39 |
+
"raw_relevance": h.raw_relevance,
|
| 40 |
+
"fuzzy_score": h.fuzzy_score,
|
| 41 |
+
"authority": h.authority,
|
| 42 |
+
"freshness": h.freshness,
|
| 43 |
+
"reliability": h.reliability,
|
| 44 |
+
}
|
| 45 |
+
)
|
| 46 |
+
docs.append(Document(page_content=h.document.text, metadata=md))
|
| 47 |
+
return docs
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def to_records(documents: list[Any]) -> list[DocumentRecord]:
|
| 51 |
+
records: list[DocumentRecord] = []
|
| 52 |
+
for i, d in enumerate(documents):
|
| 53 |
+
md = dict(getattr(d, "metadata", {}) or {})
|
| 54 |
+
records.append(
|
| 55 |
+
DocumentRecord(
|
| 56 |
+
id=str(md.get("id", f"doc-{i}")),
|
| 57 |
+
text=str(getattr(d, "page_content", "")),
|
| 58 |
+
title=str(md.get("title", "")),
|
| 59 |
+
source=str(md.get("source", "")),
|
| 60 |
+
metadata=md,
|
| 61 |
+
)
|
| 62 |
+
)
|
| 63 |
+
return records
|