mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-10 08:21:01 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc5fc485de | ||
|
|
936b1b3bfa | ||
|
|
e0c33b3d39 |
No files matched your search
@@ -565,6 +565,8 @@ Specify `rebuild` to build the index from scratch from all documents in the data
|
|||||||
this the first time you enable the feature, or after changing the embedding backend or
|
this the first time you enable the feature, or after changing the embedding backend or
|
||||||
model.
|
model.
|
||||||
|
|
||||||
|
When using the `huggingface` embedding backend, a model that is already downloaded is loaded from the local cache without contacting the Hugging Face Hub. Only `rebuild` checks the Hub for a newer revision of the model, so a model update is picked up together with re-embedding every document. If the model is not cached yet, it is downloaded on first use.
|
||||||
|
|
||||||
Specify `update` to incrementally index new and changed documents. This is what the
|
Specify `update` to incrementally index new and changed documents. This is what the
|
||||||
scheduled task runs.
|
scheduled task runs.
|
||||||
|
|
||||||
|
|||||||
+15
-8
@@ -4030,14 +4030,21 @@
|
|||||||
<source>Sources</source>
|
<source>Sources</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
||||||
<context context-type="linenumber">46,47</context>
|
<context context-type="linenumber">47,48</context>
|
||||||
|
</context-group>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="6025275430560167531" datatype="html">
|
||||||
|
<source>+<x id="INTERPOLATION" equiv-text="{{ hiddenReferenceCount(message) }}"/> more</source>
|
||||||
|
<context-group purpose="location">
|
||||||
|
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
||||||
|
<context context-type="linenumber">49,50</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8066608938393600549" datatype="html">
|
<trans-unit id="8066608938393600549" datatype="html">
|
||||||
<source>Message</source>
|
<source>Message</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
||||||
<context context-type="linenumber">61,62</context>
|
<context context-type="linenumber">66,67</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context>
|
<context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context>
|
||||||
@@ -4048,22 +4055,22 @@
|
|||||||
<source>Stop</source>
|
<source>Stop</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
||||||
<context context-type="linenumber">74</context>
|
<context context-type="linenumber">79</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
||||||
<context context-type="linenumber">74</context>
|
<context context-type="linenumber">79</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6490688569532630280" datatype="html">
|
<trans-unit id="6490688569532630280" datatype="html">
|
||||||
<source>Send</source>
|
<source>Send</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
||||||
<context context-type="linenumber">78</context>
|
<context context-type="linenumber">83</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.html</context>
|
||||||
<context context-type="linenumber">78</context>
|
<context context-type="linenumber">83</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||||
@@ -4090,14 +4097,14 @@
|
|||||||
<source>Ask a question about this document...</source>
|
<source>Ask a question about this document...</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.ts</context>
|
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.ts</context>
|
||||||
<context context-type="linenumber">61</context>
|
<context context-type="linenumber">83</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="224049052163028286" datatype="html">
|
<trans-unit id="224049052163028286" datatype="html">
|
||||||
<source>Ask a question about a document...</source>
|
<source>Ask a question about a document...</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.ts</context>
|
<context context-type="sourcefile">src/app/components/chat/chat/chat.component.ts</context>
|
||||||
<context context-type="linenumber">62</context>
|
<context context-type="linenumber">84</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8700121026680200191" datatype="html">
|
<trans-unit id="8700121026680200191" datatype="html">
|
||||||
|
|||||||
@@ -42,11 +42,16 @@
|
|||||||
<span class="text-break">{{ message.content }}</span>
|
<span class="text-break">{{ message.content }}</span>
|
||||||
}
|
}
|
||||||
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
|
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
|
||||||
<div class="chat-references list-group mt-3">
|
<div class="chat-references mt-2 pt-2 border-top">
|
||||||
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
|
<div class="d-flex align-items-center text-muted mb-1">
|
||||||
@for (reference of message.references; track reference.id) {
|
<span i18n>Sources</span>
|
||||||
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
@if (hiddenReferenceCount(message) > 0 && !expandedReferences().has(message)) {
|
||||||
<pngx-bs-icon width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></pngx-bs-icon><span class="text-truncate">{{ reference.title }}</span>
|
<button type="button" class="badge rounded-pill bg-body-secondary text-body-secondary fw-normal border-0 ms-2" (click)="expandReferences(message)" i18n>+{{ hiddenReferenceCount(message) }} more</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@for (reference of visibleReferences(message); track reference.id) {
|
||||||
|
<a class="reference-link d-flex align-items-center lh-sm text-primary text-decoration-none py-1" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
||||||
|
<pngx-bs-icon width="0.8em" height="0.8em" name="file-text" class="me-2 flex-shrink-0"></pngx-bs-icon><span class="text-truncate">{{ reference.title }}</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message .flex-grow-1 {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
white-space: initial;
|
white-space: initial;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon
|
|||||||
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
||||||
import {
|
import {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
|
ChatReference,
|
||||||
ChatService,
|
ChatService,
|
||||||
parseChatResponse,
|
parseChatResponse,
|
||||||
} from 'src/app/services/chat.service'
|
} from 'src/app/services/chat.service'
|
||||||
@@ -40,8 +41,29 @@ export class ChatComponent implements OnInit {
|
|||||||
readonly input = signal('')
|
readonly input = signal('')
|
||||||
readonly documentId = signal<number>(undefined)
|
readonly documentId = signal<number>(undefined)
|
||||||
readonly slowResponse = signal(false)
|
readonly slowResponse = signal(false)
|
||||||
|
readonly expandedReferences = signal<Set<ChatMessage>>(new Set())
|
||||||
public popperOptions = pngxPopperOptions
|
public popperOptions = pngxPopperOptions
|
||||||
|
|
||||||
|
static readonly COLLAPSED_REFERENCE_COUNT = 2
|
||||||
|
|
||||||
|
public visibleReferences(message: ChatMessage): ChatReference[] {
|
||||||
|
if (this.expandedReferences().has(message)) {
|
||||||
|
return message.references
|
||||||
|
}
|
||||||
|
return message.references.slice(0, ChatComponent.COLLAPSED_REFERENCE_COUNT)
|
||||||
|
}
|
||||||
|
|
||||||
|
public hiddenReferenceCount(message: ChatMessage): number {
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
message.references.length - ChatComponent.COLLAPSED_REFERENCE_COUNT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public expandReferences(message: ChatMessage): void {
|
||||||
|
this.expandedReferences.update((expanded) => new Set(expanded).add(message))
|
||||||
|
}
|
||||||
|
|
||||||
private chatService: ChatService = inject(ChatService)
|
private chatService: ChatService = inject(ChatService)
|
||||||
private router: Router = inject(Router)
|
private router: Router = inject(Router)
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ logger = logging.getLogger("paperless_ai.chat")
|
|||||||
CHAT_METADATA_DELIMITER = "\n\n__PAPERLESS_CHAT_METADATA__"
|
CHAT_METADATA_DELIMITER = "\n\n__PAPERLESS_CHAT_METADATA__"
|
||||||
CHAT_ERROR_MESSAGE = "Sorry, something went wrong while generating a response."
|
CHAT_ERROR_MESSAGE = "Sorry, something went wrong while generating a response."
|
||||||
CHAT_NO_CONTENT_MESSAGE = "Sorry, I couldn't find any content to answer your question."
|
CHAT_NO_CONTENT_MESSAGE = "Sorry, I couldn't find any content to answer your question."
|
||||||
MAX_CHAT_REFERENCES = 3
|
|
||||||
CHAT_RETRIEVER_TOP_K = 5
|
CHAT_RETRIEVER_TOP_K = 5
|
||||||
|
MAX_CHAT_REFERENCES = CHAT_RETRIEVER_TOP_K
|
||||||
|
|
||||||
|
|
||||||
def _build_chat_prompt(output_language: str | None) -> str:
|
def _build_chat_prompt(output_language: str | None) -> str:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import re
|
import re
|
||||||
|
from contextlib import suppress
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -20,7 +21,17 @@ OCR_LEADER_REGEX = re.compile(r"[._\-\u00b7]{4,}")
|
|||||||
HORIZONTAL_WHITESPACE_REGEX = re.compile(r"[ \t\u00a0]+")
|
HORIZONTAL_WHITESPACE_REGEX = re.compile(r"[ \t\u00a0]+")
|
||||||
|
|
||||||
|
|
||||||
def get_embedding_model(config: AIConfig) -> "BaseEmbedding":
|
def get_embedding_model(
|
||||||
|
config: AIConfig,
|
||||||
|
*,
|
||||||
|
refresh: bool = False,
|
||||||
|
) -> "BaseEmbedding":
|
||||||
|
"""
|
||||||
|
Build the embedding model for the configured backend.
|
||||||
|
|
||||||
|
For Hugging Face, a cached model loads offline by default, saving seconds per
|
||||||
|
call. Pass ``refresh=True`` to check the Hub for a newer model revision.
|
||||||
|
"""
|
||||||
match config.llm_embedding_backend:
|
match config.llm_embedding_backend:
|
||||||
case LLMEmbeddingBackend.OPENAI_LIKE:
|
case LLMEmbeddingBackend.OPENAI_LIKE:
|
||||||
from llama_index.embeddings.openai_like import OpenAILikeEmbedding
|
from llama_index.embeddings.openai_like import OpenAILikeEmbedding
|
||||||
@@ -52,11 +63,15 @@ def get_embedding_model(config: AIConfig) -> "BaseEmbedding":
|
|||||||
case LLMEmbeddingBackend.HUGGINGFACE:
|
case LLMEmbeddingBackend.HUGGINGFACE:
|
||||||
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
|
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
|
||||||
|
|
||||||
return HuggingFaceEmbedding(
|
hf_kwargs = {
|
||||||
model_name=config.llm_embedding_model
|
"model_name": config.llm_embedding_model
|
||||||
or "sentence-transformers/all-MiniLM-L6-v2",
|
or "sentence-transformers/all-MiniLM-L6-v2",
|
||||||
cache_folder=str(settings.DATA_DIR / "hf_cache"),
|
"cache_folder": str(settings.DATA_DIR / "hf_cache"),
|
||||||
)
|
}
|
||||||
|
if not refresh:
|
||||||
|
with suppress(OSError):
|
||||||
|
return HuggingFaceEmbedding(**hf_kwargs, local_files_only=True)
|
||||||
|
return HuggingFaceEmbedding(**hf_kwargs)
|
||||||
case LLMEmbeddingBackend.OLLAMA:
|
case LLMEmbeddingBackend.OLLAMA:
|
||||||
from llama_index.embeddings.ollama import OllamaEmbedding
|
from llama_index.embeddings.ollama import OllamaEmbedding
|
||||||
from ollama import AsyncClient
|
from ollama import AsyncClient
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ def update_llm_index(
|
|||||||
logger.warning("No documents found to index.")
|
logger.warning("No documents found to index.")
|
||||||
|
|
||||||
chunk_size = config.llm_embedding_chunk_size
|
chunk_size = config.llm_embedding_chunk_size
|
||||||
embed_model = get_embedding_model(config)
|
embed_model = get_embedding_model(config, refresh=rebuild)
|
||||||
|
|
||||||
with write_store(embed_model_name=model_name) as store:
|
with write_store(embed_model_name=model_name) as store:
|
||||||
if rebuild or not store.table_exists():
|
if rebuild or not store.table_exists():
|
||||||
|
|||||||
@@ -212,6 +212,10 @@ def test_update_llm_index(
|
|||||||
|
|
||||||
ai_config.assert_called_once()
|
ai_config.assert_called_once()
|
||||||
build_document_node.assert_called_once_with(real_document, chunk_size=512)
|
build_document_node.assert_called_once_with(real_document, chunk_size=512)
|
||||||
|
indexing.get_embedding_model.assert_called_once_with(
|
||||||
|
mock_config,
|
||||||
|
refresh=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -336,6 +340,8 @@ def test_update_llm_index_partial_update(
|
|||||||
|
|
||||||
indexing.update_llm_index(rebuild=False)
|
indexing.update_llm_index(rebuild=False)
|
||||||
|
|
||||||
|
assert indexing.get_embedding_model.call_args.kwargs == {"refresh": False}
|
||||||
|
|
||||||
with indexing.get_vector_store() as store:
|
with indexing.get_vector_store() as store:
|
||||||
assert store.table_exists(), (
|
assert store.table_exists(), (
|
||||||
"Expected the vector store table to exist after incremental update"
|
"Expected the vector store table to exist after incremental update"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from unittest.mock import ANY
|
from unittest.mock import ANY
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
from unittest.mock import call
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -167,10 +168,45 @@ def test_get_embedding_model_huggingface(mock_ai_config):
|
|||||||
MockHuggingFaceEmbedding.assert_called_once_with(
|
MockHuggingFaceEmbedding.assert_called_once_with(
|
||||||
model_name="sentence-transformers/all-MiniLM-L6-v2",
|
model_name="sentence-transformers/all-MiniLM-L6-v2",
|
||||||
cache_folder=str(settings.DATA_DIR / "hf_cache"),
|
cache_folder=str(settings.DATA_DIR / "hf_cache"),
|
||||||
|
local_files_only=True,
|
||||||
)
|
)
|
||||||
assert model == MockHuggingFaceEmbedding.return_value
|
assert model == MockHuggingFaceEmbedding.return_value
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_embedding_model_huggingface_downloads_when_not_cached(mock_ai_config):
|
||||||
|
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.HUGGINGFACE
|
||||||
|
mock_ai_config.return_value.llm_embedding_model = "some/model"
|
||||||
|
downloaded = MagicMock()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"llama_index.embeddings.huggingface.HuggingFaceEmbedding",
|
||||||
|
side_effect=[OSError("not cached"), downloaded],
|
||||||
|
) as MockHuggingFaceEmbedding:
|
||||||
|
model = get_embedding_model(mock_ai_config.return_value)
|
||||||
|
|
||||||
|
cache_folder = str(settings.DATA_DIR / "hf_cache")
|
||||||
|
assert MockHuggingFaceEmbedding.call_args_list == [
|
||||||
|
call(model_name="some/model", cache_folder=cache_folder, local_files_only=True),
|
||||||
|
call(model_name="some/model", cache_folder=cache_folder),
|
||||||
|
]
|
||||||
|
assert model is downloaded
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_embedding_model_huggingface_refresh_checks_the_hub(mock_ai_config):
|
||||||
|
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.HUGGINGFACE
|
||||||
|
mock_ai_config.return_value.llm_embedding_model = "some/model"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"llama_index.embeddings.huggingface.HuggingFaceEmbedding",
|
||||||
|
) as MockHuggingFaceEmbedding:
|
||||||
|
get_embedding_model(mock_ai_config.return_value, refresh=True)
|
||||||
|
|
||||||
|
MockHuggingFaceEmbedding.assert_called_once_with(
|
||||||
|
model_name="some/model",
|
||||||
|
cache_folder=str(settings.DATA_DIR / "hf_cache"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_get_embedding_model_ollama(mock_ai_config):
|
def test_get_embedding_model_ollama(mock_ai_config):
|
||||||
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OLLAMA
|
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OLLAMA
|
||||||
mock_ai_config.return_value.llm_embedding_model = "embeddinggemma"
|
mock_ai_config.return_value.llm_embedding_model = "embeddinggemma"
|
||||||
|
|||||||
Reference in new issue
Block a user